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">
|
||||
{(entries || []).map((entry) => (
|
||||
<EntryForm
|
||||
key={entry.id}
|
||||
key={entry.clientKey || entry.id}
|
||||
entry={entry}
|
||||
homeowners={homeowners}
|
||||
onHomeownerAdded={onHomeownerAdded}
|
||||
|
||||
@@ -58,6 +58,16 @@ export default function Timesheet() {
|
||||
const [overtime, setOvertime] = useState(null);
|
||||
const saveTimer = useRef(null);
|
||||
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 weekParam = formatWeekParam(selectedDate);
|
||||
@@ -85,10 +95,12 @@ export default function Timesheet() {
|
||||
// Ensure at least one empty entry per day
|
||||
weekDates.forEach((d) => {
|
||||
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);
|
||||
setTimesheet(timesheetRes.data?.timesheet || timesheetRes.data);
|
||||
setHomeowners(homeownersRes.data.homeowners || homeownersRes.data || []);
|
||||
@@ -124,18 +136,51 @@ export default function Timesheet() {
|
||||
};
|
||||
|
||||
try {
|
||||
if (data._isNew || entryId.startsWith('new-')) {
|
||||
const res = await api.post('/entries', payload);
|
||||
// Replace temp ID with real ID
|
||||
setEntries((prev) => {
|
||||
const dk = data.date;
|
||||
return {
|
||||
...prev,
|
||||
[dk]: prev[dk].map((e) => (e.id === entryId ? { ...res.data.entry || res.data, date: dk } : e)),
|
||||
};
|
||||
const isTemp = entryId.startsWith('new-');
|
||||
let realId = isTemp ? idMap.current[entryId] : entryId;
|
||||
|
||||
// A create for this temp id is already in flight — wait for it, then update.
|
||||
if (isTemp && !realId && creating.current[entryId]) {
|
||||
realId = await creating.current[entryId];
|
||||
}
|
||||
|
||||
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 {
|
||||
await api.put(`/entries/${entryId}`, payload);
|
||||
await api.put(`/entries/${realId}`, payload);
|
||||
}
|
||||
setSaveStatus('saved');
|
||||
} catch (err) {
|
||||
@@ -164,27 +209,27 @@ export default function Timesheet() {
|
||||
}
|
||||
|
||||
function handleAddEntry(date) {
|
||||
const newEntry = {
|
||||
id: `new-${date}-${Date.now()}`,
|
||||
date,
|
||||
homeownerId: '',
|
||||
hoursWorked: '',
|
||||
workDescription: '',
|
||||
_isNew: true,
|
||||
};
|
||||
const newEntry = makeNewEntry(date);
|
||||
setEntries((prev) => ({ ...prev, [date]: [...(prev[date] || []), newEntry] }));
|
||||
}
|
||||
|
||||
async function handleDeleteEntry(entryId) {
|
||||
try {
|
||||
if (!entryId.startsWith('new-')) {
|
||||
await api.delete(`/entries/${entryId}`);
|
||||
// Resolve a temp id to its server id if the create already happened
|
||||
// (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) => {
|
||||
const updated = {};
|
||||
Object.entries(prev).forEach(([dk, dayEntries]) => {
|
||||
const filtered = dayEntries.filter((e) => e.id !== entryId);
|
||||
updated[dk] = filtered.length > 0 ? filtered : [{ id: `new-${dk}-0`, date: dk, homeownerId: '', hoursWorked: '', workDescription: '', _isNew: true }];
|
||||
const filtered = dayEntries.filter((e) => e.id !== entryId && e.id !== realId);
|
||||
updated[dk] = filtered.length > 0 ? filtered : [makeNewEntry(dk)];
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user