Skip to content

Commit eeaa11d

Browse files
Preserve Task form drafts across panel refreshes
Refs #204.
1 parent 0e989e5 commit eeaa11d

7 files changed

Lines changed: 333 additions & 15 deletions

File tree

frontend/src/surfaces/work-detail/card-panel.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ export function createCardPanel(context) {
9090
};
9191
renderCardPanel();
9292
if (detail.activeTaskPanelTask?.cardId === cardId)
93-
renderTaskPanel();
93+
renderTaskPanel({ preserveDrafts: true });
9494
}
9595
} catch (err) {
9696
if (

frontend/src/surfaces/work-detail/route-state.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ export function createRouteState(context) {
264264
)
265265
renderTasksSurface(getAllDocuments(), "queue");
266266
if (detail.activeTaskPanelTask && isWorkspaceRouteFresh(token))
267-
renderTaskPanel();
267+
renderTaskPanel({ preserveDrafts: true });
268268
}
269269

270270
return {

frontend/src/surfaces/work-detail/task-actions.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ export function createTaskActions(context) {
8484
}
8585
detail.activeTaskPanelConflict = err.payload;
8686
detail.activeTaskMutationBusy = false;
87-
renderTaskPanel();
87+
renderTaskPanel({ preserveDrafts: true });
8888
return null;
8989
}
9090
if (isActiveTask) {

frontend/src/surfaces/work-detail/task-evidence.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export function createTaskEvidence(context) {
5252
if (files.length === 0) {
5353
if (hasActiveTask) detail.activeTaskPanelTask._hasFiles = false;
5454
if (hadFiles && detail.activeTaskPanelId === taskId) {
55-
renderTaskPanel();
55+
renderTaskPanel({ preserveDrafts: true });
5656
return;
5757
}
5858
const empty = document.createElement("small");
@@ -64,7 +64,7 @@ export function createTaskEvidence(context) {
6464
if (hasActiveTask) {
6565
detail.activeTaskPanelTask._hasFiles = true;
6666
if (!hadFiles && detail.activeTaskPanelId === taskId) {
67-
renderTaskPanel();
67+
renderTaskPanel({ preserveDrafts: true });
6868
return;
6969
}
7070
}
@@ -149,7 +149,7 @@ export function createTaskEvidence(context) {
149149
await response.json();
150150
if (detail.activeTaskPanelTask)
151151
detail.activeTaskPanelTask._hasFiles = true;
152-
renderTaskPanel();
152+
renderTaskPanel({ preserveDrafts: true });
153153
} catch (err) {
154154
reportError(`Upload failed: ${err.message || "request failed"}`);
155155
}
@@ -228,9 +228,13 @@ export function createTaskEvidence(context) {
228228
const titleInput = document.createElement("input");
229229
titleInput.type = "text";
230230
titleInput.placeholder = "Artifact title";
231+
// Marked so a panel rerender can hand a half-typed registration back to the
232+
// operator instead of clearing it.
233+
titleInput.dataset.panelField = "artifact-title";
231234
const urlInput = document.createElement("input");
232235
urlInput.type = "url";
233236
urlInput.placeholder = "https://...";
237+
urlInput.dataset.panelField = "artifact-url";
234238
const addBtn = document.createElement("button");
235239
addBtn.type = "button";
236240
addBtn.className = "task-action-btn";

frontend/src/surfaces/work-detail/task-panel.js

Lines changed: 112 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ export function createTaskPanel(context) {
109109
if (fetched) {
110110
detail.activeTaskPanelTask = fetched;
111111
detail.activeTaskPanelArtifacts = [];
112-
renderTaskPanel();
112+
renderTaskPanel({ preserveDrafts: true });
113113
}
114114
const artifacts = fetched ? await loadArtifactsForTask(fetched) : [];
115115
if (
@@ -119,7 +119,7 @@ export function createTaskPanel(context) {
119119
)
120120
return;
121121
detail.activeTaskPanelArtifacts = artifacts;
122-
renderTaskPanel();
122+
renderTaskPanel({ preserveDrafts: true });
123123
} catch (err) {
124124
if (isWorkspaceRouteFresh(token) && detail.activeTaskPanelId === taskId) {
125125
taskPanelTitle.textContent =
@@ -141,11 +141,96 @@ export function createTaskPanel(context) {
141141
}
142142
}
143143

144-
function renderTaskPanel() {
144+
// Asynchronous rerenders (artifact and file hydration, snapshot refreshes)
145+
// rebuild this panel while an operator may be mid-edit. Rebuilding blindly
146+
// throws away pending drafts. Preserve all tagged field values, keep
147+
// detach-driven blur/change from committing a half-typed edit, then return
148+
// focus and selection only to the field the operator was actually using.
149+
// Explicit renders keep their existing ownership semantics: a successful
150+
// mutation or an operator's Discard must be allowed to replace drafts.
151+
let taskPanelRebuilding = false;
152+
153+
function isInsideTaskPanelBody(node) {
154+
let current = node;
155+
while (current) {
156+
if (current === taskPanelBody) return true;
157+
current = current.parentElement || null;
158+
}
159+
return false;
160+
}
161+
162+
function readFieldSelection(field) {
163+
try {
164+
if (typeof field.selectionStart !== "number") return null;
165+
return {
166+
start: field.selectionStart,
167+
end: field.selectionEnd,
168+
direction: field.selectionDirection || "none",
169+
};
170+
} catch {
171+
return null;
172+
}
173+
}
174+
175+
function captureTaskPanelFields() {
176+
const fields = new Map();
177+
for (const field of taskPanelBody.querySelectorAll("[data-panel-field]")) {
178+
const key = field.dataset?.panelField;
179+
if (!key || fields.has(key)) continue;
180+
fields.set(key, {
181+
value: typeof field.value === "string" ? field.value : "",
182+
selection: readFieldSelection(field),
183+
});
184+
}
185+
return fields;
186+
}
187+
188+
function getFocusedPanelFieldKey() {
189+
const active = document.activeElement;
190+
return active && isInsideTaskPanelBody(active)
191+
? active.dataset?.panelField || null
192+
: null;
193+
}
194+
195+
function restoreTaskPanelFields(capturedFields, focusedFieldKey) {
196+
let focusedField = null;
197+
for (const [key, capture] of capturedFields) {
198+
const field = taskPanelBody.querySelector(
199+
`[data-panel-field="${key}"]`,
200+
);
201+
if (!field || field.disabled) continue;
202+
if (field.value !== capture.value) field.value = capture.value;
203+
if (key !== focusedFieldKey) continue;
204+
focusedField = field;
205+
if (capture.selection && typeof field.setSelectionRange === "function") {
206+
try {
207+
field.setSelectionRange(
208+
capture.selection.start,
209+
capture.selection.end,
210+
capture.selection.direction,
211+
);
212+
} catch {
213+
// Field types without a text selection keep the restored value only.
214+
}
215+
}
216+
}
217+
if (focusedField) focusedField.focus();
218+
}
219+
220+
function renderTaskPanel(options = {}) {
145221
const task = detail.activeTaskPanelTask;
222+
const preserveDrafts = options.preserveDrafts === true;
223+
const capturedFields = preserveDrafts ? captureTaskPanelFields() : new Map();
224+
const focusedFieldKey = getFocusedPanelFieldKey();
146225
taskPanelTitle.textContent = task ? workTaskTitle(task) : "Task";
147-
taskPanelBody.replaceChildren();
226+
taskPanelRebuilding = true;
227+
try {
228+
taskPanelBody.replaceChildren();
229+
} finally {
230+
taskPanelRebuilding = false;
231+
}
148232
if (!task) return;
233+
let conflictClaimedFocus = false;
149234

150235
if (!isCanonicalWorkTask(task)) {
151236
throw new Error("Task payload is not in the canonical versioned shape");
@@ -189,6 +274,7 @@ export function createTaskPanel(context) {
189274
recovery.append(heading, latestState, retained, controls);
190275
taskPanelBody.append(recovery);
191276
recovery.focus();
277+
conflictClaimedFocus = true;
192278
}
193279

194280
const routeContextParts = [
@@ -278,15 +364,28 @@ export function createTaskPanel(context) {
278364
label.textContent = `${task.requiredLinkName}`;
279365
const input = document.createElement("input");
280366
input.type = "url";
367+
input.dataset.panelField = "required-link";
368+
const savedLink = task.link || "";
281369
input.value =
282370
detail.activeTaskPanelDraft?.kind === "link"
283371
? detail.activeTaskPanelDraft.payload.link
284-
: task.link || "";
372+
: savedLink;
285373
input.placeholder = "https://...";
286374
input.disabled = detail.activeTaskMutationBusy;
287-
input.addEventListener("change", () =>
288-
saveTaskLink(task.id, input.value, task.version),
289-
);
375+
// `change` is the normal browser commit. `blur` also covers a draft that
376+
// was restored after a background rerender, where the browser no longer
377+
// treats the restored text as a user edit. `committedLink` tracks the
378+
// last value sent so neither path submits the same link twice.
379+
let committedLink = savedLink;
380+
const commitLink = () => {
381+
if (taskPanelRebuilding) return undefined;
382+
const next = input.value;
383+
if (next === committedLink) return undefined;
384+
committedLink = next;
385+
return saveTaskLink(task.id, next, task.version);
386+
};
387+
input.addEventListener("change", commitLink);
388+
input.addEventListener("blur", commitLink);
290389
input.addEventListener("keydown", (event) => {
291390
if (event.key === "Enter") {
292391
event.preventDefault();
@@ -347,6 +446,7 @@ export function createTaskPanel(context) {
347446
nextLabel.textContent = "Next";
348447
const nextInput = document.createElement("input");
349448
nextInput.type = "date";
449+
nextInput.dataset.panelField = "follow-up-next";
350450
nextInput.value =
351451
detail.activeTaskPanelDraft?.kind === "follow-up-sent"
352452
? detail.activeTaskPanelDraft.payload.nextFollowUpAt
@@ -461,6 +561,10 @@ export function createTaskPanel(context) {
461561
instructions.append(link);
462562
taskPanelBody.append(instructions);
463563
}
564+
565+
if (!conflictClaimedFocus && preserveDrafts) {
566+
restoreTaskPanelFields(capturedFields, focusedFieldKey);
567+
}
464568
}
465569

466570
function renderTaskInstructionDoc(task) {

frontend/test/support/fake-dom.mjs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,10 @@ export class FakeElement {
114114
this.isConnected = true;
115115
this.removed = false;
116116
this.focused = false;
117+
this.ownerDocument = null;
118+
this.selectionStart = null;
119+
this.selectionEnd = null;
120+
this.selectionDirection = "none";
117121
}
118122

119123
get textContent() {
@@ -243,6 +247,22 @@ export class FakeElement {
243247

244248
focus() {
245249
this.focused = true;
250+
const owner = this.ownerDocument || globalThis.document;
251+
if (owner instanceof FakeDocument) owner.activeElement = this;
252+
}
253+
254+
blur() {
255+
this.focused = false;
256+
const owner = this.ownerDocument || globalThis.document;
257+
if (owner instanceof FakeDocument && owner.activeElement === this) {
258+
owner.activeElement = null;
259+
}
260+
}
261+
262+
setSelectionRange(start, end, direction = "none") {
263+
this.selectionStart = start;
264+
this.selectionEnd = end;
265+
this.selectionDirection = direction;
246266
}
247267

248268
click() {
@@ -263,20 +283,26 @@ export class FakeDocument {
263283
constructor(...roots) {
264284
this.roots = roots;
265285
this.created = [];
286+
this.activeElement = null;
287+
for (const root of roots) adoptDocument(root, this);
266288
}
267289

268290
addRoot(root) {
269291
this.roots.push(root);
292+
adoptDocument(root, this);
270293
}
271294

272295
createElement(tagName) {
273296
const element = new FakeElement(tagName);
297+
element.ownerDocument = this;
274298
this.created.push(element);
275299
return element;
276300
}
277301

278302
createTextNode(value) {
279-
return new FakeTextNode(value);
303+
const node = new FakeTextNode(value);
304+
node.ownerDocument = this;
305+
return node;
280306
}
281307

282308
querySelectorAll(selector) {
@@ -290,6 +316,12 @@ export class FakeDocument {
290316
}
291317
}
292318

319+
function adoptDocument(root, ownerDocument) {
320+
if (!(root instanceof FakeElement)) return;
321+
root.ownerDocument = ownerDocument;
322+
for (const child of root.children) adoptDocument(child, ownerDocument);
323+
}
324+
293325
export function findByText(root, text, selector = "*") {
294326
return [root, ...descendants(root)].find(
295327
(element) =>

0 commit comments

Comments
 (0)