fix: keep entry form mounted when a new entry is first saved
On mobile, typing the first characters of a work description on a new entry caused the form to remount: the auto-save POST replaced the temp entry with the server copy, changing its React key, which destroyed the textarea, dropped focus and closed the keyboard. - Key EntryForm on a stable clientKey that survives the id swap - Merge only the server id into the local entry so keystrokes typed while the create request is in flight are kept - Track temp-id -> real-id and in-flight creates so queued changes become PUTs instead of duplicate POSTs; deletes resolve to the real row Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -65,7 +65,7 @@ export default function DayCard({ date, entries, homeowners, onEntryChange, onAd
|
|||||||
<div className="px-4 pb-4 space-y-3">
|
<div className="px-4 pb-4 space-y-3">
|
||||||
{(entries || []).map((entry) => (
|
{(entries || []).map((entry) => (
|
||||||
<EntryForm
|
<EntryForm
|
||||||
key={entry.id}
|
key={entry.clientKey || entry.id}
|
||||||
entry={entry}
|
entry={entry}
|
||||||
homeowners={homeowners}
|
homeowners={homeowners}
|
||||||
onHomeownerAdded={onHomeownerAdded}
|
onHomeownerAdded={onHomeownerAdded}
|
||||||
|
|||||||
@@ -58,6 +58,16 @@ export default function Timesheet() {
|
|||||||
const [overtime, setOvertime] = useState(null);
|
const [overtime, setOvertime] = useState(null);
|
||||||
const saveTimer = useRef(null);
|
const saveTimer = useRef(null);
|
||||||
const pendingChanges = useRef({});
|
const pendingChanges = useRef({});
|
||||||
|
// Client-created entries keep a temp id until the server assigns a real one.
|
||||||
|
// idMap: tempId -> realId (after create resolves)
|
||||||
|
// creating: tempId -> Promise<realId> (while create is in flight)
|
||||||
|
const idMap = useRef({});
|
||||||
|
const creating = useRef({});
|
||||||
|
|
||||||
|
function makeNewEntry(date) {
|
||||||
|
const id = `new-${date}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||||
|
return { id, clientKey: id, date, homeownerId: '', hoursWorked: '', workDescription: '', _isNew: true };
|
||||||
|
}
|
||||||
|
|
||||||
const weekDates = getWeekDates(selectedDate);
|
const weekDates = getWeekDates(selectedDate);
|
||||||
const weekParam = formatWeekParam(selectedDate);
|
const weekParam = formatWeekParam(selectedDate);
|
||||||
@@ -85,10 +95,12 @@ export default function Timesheet() {
|
|||||||
// Ensure at least one empty entry per day
|
// Ensure at least one empty entry per day
|
||||||
weekDates.forEach((d) => {
|
weekDates.forEach((d) => {
|
||||||
if (byDate[d].length === 0) {
|
if (byDate[d].length === 0) {
|
||||||
byDate[d] = [{ id: `new-${d}-0`, date: d, homeownerId: '', hoursWorked: '', workDescription: '', _isNew: true }];
|
byDate[d] = [makeNewEntry(d)];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
idMap.current = {};
|
||||||
|
creating.current = {};
|
||||||
setEntries(byDate);
|
setEntries(byDate);
|
||||||
setTimesheet(timesheetRes.data?.timesheet || timesheetRes.data);
|
setTimesheet(timesheetRes.data?.timesheet || timesheetRes.data);
|
||||||
setHomeowners(homeownersRes.data.homeowners || homeownersRes.data || []);
|
setHomeowners(homeownersRes.data.homeowners || homeownersRes.data || []);
|
||||||
@@ -124,18 +136,51 @@ export default function Timesheet() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (data._isNew || entryId.startsWith('new-')) {
|
const isTemp = entryId.startsWith('new-');
|
||||||
const res = await api.post('/entries', payload);
|
let realId = isTemp ? idMap.current[entryId] : entryId;
|
||||||
// Replace temp ID with real ID
|
|
||||||
setEntries((prev) => {
|
// A create for this temp id is already in flight — wait for it, then update.
|
||||||
const dk = data.date;
|
if (isTemp && !realId && creating.current[entryId]) {
|
||||||
return {
|
realId = await creating.current[entryId];
|
||||||
...prev,
|
}
|
||||||
[dk]: prev[dk].map((e) => (e.id === entryId ? { ...res.data.entry || res.data, date: dk } : e)),
|
|
||||||
};
|
if (isTemp && !realId) {
|
||||||
|
const createPromise = api.post('/entries', payload).then((res) => {
|
||||||
|
const created = res.data.entry || res.data;
|
||||||
|
idMap.current[entryId] = created.id;
|
||||||
|
// Merge the server id into the existing local entry. Keep the
|
||||||
|
// clientKey (so React does not remount the form) and keep whatever
|
||||||
|
// the user has typed since the request was sent.
|
||||||
|
setEntries((prev) => {
|
||||||
|
const dk = data.date;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[dk]: (prev[dk] || []).map((e) =>
|
||||||
|
e.id === entryId
|
||||||
|
? {
|
||||||
|
...e,
|
||||||
|
id: created.id,
|
||||||
|
clientKey: e.clientKey,
|
||||||
|
date: dk,
|
||||||
|
homeownerId: e.homeownerId,
|
||||||
|
hoursWorked: e.hoursWorked,
|
||||||
|
workDescription: e.workDescription,
|
||||||
|
_isNew: false,
|
||||||
|
}
|
||||||
|
: e
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return created.id;
|
||||||
});
|
});
|
||||||
|
creating.current[entryId] = createPromise;
|
||||||
|
try {
|
||||||
|
await createPromise;
|
||||||
|
} finally {
|
||||||
|
delete creating.current[entryId];
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await api.put(`/entries/${entryId}`, payload);
|
await api.put(`/entries/${realId}`, payload);
|
||||||
}
|
}
|
||||||
setSaveStatus('saved');
|
setSaveStatus('saved');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -164,27 +209,27 @@ export default function Timesheet() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleAddEntry(date) {
|
function handleAddEntry(date) {
|
||||||
const newEntry = {
|
const newEntry = makeNewEntry(date);
|
||||||
id: `new-${date}-${Date.now()}`,
|
|
||||||
date,
|
|
||||||
homeownerId: '',
|
|
||||||
hoursWorked: '',
|
|
||||||
workDescription: '',
|
|
||||||
_isNew: true,
|
|
||||||
};
|
|
||||||
setEntries((prev) => ({ ...prev, [date]: [...(prev[date] || []), newEntry] }));
|
setEntries((prev) => ({ ...prev, [date]: [...(prev[date] || []), newEntry] }));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteEntry(entryId) {
|
async function handleDeleteEntry(entryId) {
|
||||||
try {
|
try {
|
||||||
if (!entryId.startsWith('new-')) {
|
// Resolve a temp id to its server id if the create already happened
|
||||||
await api.delete(`/entries/${entryId}`);
|
// (or is still in flight) so we do not orphan a row on the server.
|
||||||
|
let realId = entryId;
|
||||||
|
if (entryId.startsWith('new-')) {
|
||||||
|
realId = idMap.current[entryId] || (creating.current[entryId] ? await creating.current[entryId] : null);
|
||||||
}
|
}
|
||||||
|
if (realId) {
|
||||||
|
await api.delete(`/entries/${realId}`);
|
||||||
|
}
|
||||||
|
delete pendingChanges.current[entryId];
|
||||||
setEntries((prev) => {
|
setEntries((prev) => {
|
||||||
const updated = {};
|
const updated = {};
|
||||||
Object.entries(prev).forEach(([dk, dayEntries]) => {
|
Object.entries(prev).forEach(([dk, dayEntries]) => {
|
||||||
const filtered = dayEntries.filter((e) => e.id !== entryId);
|
const filtered = dayEntries.filter((e) => e.id !== entryId && e.id !== realId);
|
||||||
updated[dk] = filtered.length > 0 ? filtered : [{ id: `new-${dk}-0`, date: dk, homeownerId: '', hoursWorked: '', workDescription: '', _isNew: true }];
|
updated[dk] = filtered.length > 0 ? filtered : [makeNewEntry(dk)];
|
||||||
});
|
});
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user