fix(ui): lock workboard draft during save (#105600)

This commit is contained in:
Peter Steinberger
2026-07-12 11:36:33 -07:00
committed by GitHub
parent 41a1fd696b
commit 17a464b7ee
4 changed files with 87 additions and 16 deletions

View File

@@ -502,9 +502,9 @@ describe("renderWorkboard", () => {
"Unsaved edit",
);
container
.querySelector<HTMLButtonElement>('button[aria-label="Cancel"]')
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const cancelButton = container.querySelector<HTMLButtonElement>('button[aria-label="Cancel"]');
expect(cancelButton?.disabled).toBe(false);
cancelButton?.click();
expect(state.draftOpen).toBe(false);
});

View File

@@ -342,13 +342,14 @@ function trapWorkboardDialogFocus(event: KeyboardEvent, root: HTMLElement) {
function handleWorkboardDialogKeydown(
event: KeyboardEvent,
props: WorkboardProps,
close: () => void,
close: () => boolean | void,
) {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
close();
props.onRequestUpdate?.();
if (close() !== false) {
props.onRequestUpdate?.();
}
return;
}
if (event.key === "Tab") {
@@ -997,19 +998,33 @@ function renderWorkboardSelect<Value extends string>(params: {
requestUpdate?: () => void;
className?: string;
showLabel?: boolean;
disabled?: boolean;
}) {
const selected = params.options.find((option) => option.value === params.value);
const selectedLabel = selected?.label ?? params.value;
const select = html`
<details
class="workboard-select ${params.className ?? ""}"
class="workboard-select ${params.disabled
? "workboard-select--disabled"
: ""} ${params.className ?? ""}"
@toggle=${(event: Event) => {
const details = event.currentTarget as HTMLDetailsElement;
if (params.disabled) {
details.open = false;
return;
}
closeOtherWorkboardSelectMenus(details);
positionWorkboardSelectMenu(details);
syncWorkboardSelectDocumentCloser();
}}
@keydown=${handleWorkboardSelectKeydown}
@keydown=${(event: KeyboardEvent) => {
if (params.disabled) {
event.preventDefault();
event.stopPropagation();
return;
}
handleWorkboardSelectKeydown(event);
}}
@focusout=${(event: FocusEvent) => {
const details = event.currentTarget as HTMLDetailsElement;
if (!(event.relatedTarget instanceof Node) || !details.contains(event.relatedTarget)) {
@@ -1021,6 +1036,14 @@ function renderWorkboardSelect<Value extends string>(params: {
class="input workboard-select__trigger"
aria-label=${`${params.label}: ${selectedLabel}`}
aria-haspopup="listbox"
aria-disabled=${params.disabled ? "true" : "false"}
tabindex=${params.disabled ? "-1" : "0"}
@click=${(event: MouseEvent) => {
if (params.disabled) {
event.preventDefault();
event.stopPropagation();
}
}}
>
<span class="workboard-select__value">${selectedLabel}</span>
<span class="workboard-select__chevron" aria-hidden="true">${icons.chevronDown}</span>
@@ -1036,7 +1059,7 @@ function renderWorkboardSelect<Value extends string>(params: {
tabindex="-1"
aria-selected=${optionSelected}
aria-disabled=${option.disabled === true}
?disabled=${option.disabled}
?disabled=${params.disabled || option.disabled}
@click=${(event: Event) => {
if (option.disabled) {
return;
@@ -1479,13 +1502,22 @@ function renderCardModal(props: WorkboardProps) {
const draftCommentBusy = editing && state.busyCardIds.has(state.editingCardId ?? "");
const draftActionsBusy =
!canMutate(props) || state.loading || state.dispatching || draftCommentBusy;
// Save completion resets this shared draft. Lock every edit and dismissal path
// only for that write so stale drafts can still use Cancel to recover readiness.
const draftDismissalBusy = state.draftSaving;
const dismissDraft = () => {
if (draftDismissalBusy) {
return false;
}
resetDraft(state);
return true;
};
return html`
<div
class="workboard-modal"
role="presentation"
@click=${(event: MouseEvent) => {
if (event.target === event.currentTarget) {
resetDraft(state);
if (event.target === event.currentTarget && dismissDraft()) {
props.onRequestUpdate?.();
}
}}
@@ -1497,10 +1529,11 @@ function renderCardModal(props: WorkboardProps) {
aria-modal="true"
aria-labelledby=${workboardCardModalTitleId}
aria-describedby=${workboardCardModalDescriptionId}
aria-busy=${draftActionsBusy ? "true" : "false"}
tabindex="-1"
${ref((element) => syncWorkboardDialog(element, "[data-workboard-autofocus='true']"))}
@keydown=${(event: KeyboardEvent) =>
handleWorkboardDialogKeydown(event, props, () => resetDraft(state))}
handleWorkboardDialogKeydown(event, props, dismissDraft)}
@submit=${(event: SubmitEvent) => {
event.preventDefault();
if (draftActionsBusy) {
@@ -1527,9 +1560,11 @@ function renderCardModal(props: WorkboardProps) {
class="btn btn--icon workboard-card__icon"
type="button"
aria-label=${t("common.cancel")}
?disabled=${draftDismissalBusy}
@click=${() => {
resetDraft(state);
props.onRequestUpdate?.();
if (dismissDraft()) {
props.onRequestUpdate?.();
}
}}
>
${icons.x}
@@ -1547,6 +1582,7 @@ function renderCardModal(props: WorkboardProps) {
? "workboard-template-strip__button--active"
: ""}"
type="button"
?disabled=${draftActionsBusy}
@click=${() => {
applyTemplate(state, template.id);
props.onRequestUpdate?.();
@@ -1566,6 +1602,7 @@ function renderCardModal(props: WorkboardProps) {
class="input workboard-draft__title"
data-workboard-autofocus="true"
placeholder=${t("workboard.titlePlaceholder")}
?disabled=${draftActionsBusy}
.value=${state.draftTitle}
@input=${(event: InputEvent) => {
state.draftTitle = (event.currentTarget as HTMLInputElement).value;
@@ -1578,6 +1615,7 @@ function renderCardModal(props: WorkboardProps) {
<textarea
class="input workboard-draft__notes"
placeholder=${t("workboard.notesPlaceholder")}
?disabled=${draftActionsBusy}
.value=${state.draftNotes}
@input=${(event: InputEvent) => {
state.draftNotes = (event.currentTarget as HTMLTextAreaElement).value;
@@ -1595,6 +1633,7 @@ function renderCardModal(props: WorkboardProps) {
state.draftStatus = value;
},
requestUpdate: props.onRequestUpdate,
disabled: draftActionsBusy,
})}
${renderWorkboardSelect({
value: state.draftPriority,
@@ -1604,6 +1643,7 @@ function renderCardModal(props: WorkboardProps) {
state.draftPriority = value;
},
requestUpdate: props.onRequestUpdate,
disabled: draftActionsBusy,
})}
${renderWorkboardSelect({
value: state.draftAgentId,
@@ -1613,6 +1653,7 @@ function renderCardModal(props: WorkboardProps) {
state.draftAgentId = value;
},
requestUpdate: props.onRequestUpdate,
disabled: draftActionsBusy,
})}
${renderWorkboardSelect({
value: state.draftSessionKey,
@@ -1622,12 +1663,14 @@ function renderCardModal(props: WorkboardProps) {
state.draftSessionKey = value;
},
requestUpdate: props.onRequestUpdate,
disabled: draftActionsBusy,
})}
<label class="workboard-field workboard-field--wide">
<span>${t("workboard.fieldLabels")}</span>
<input
class="input"
placeholder=${t("workboard.labelsPlaceholder")}
?disabled=${draftActionsBusy}
.value=${state.draftLabels}
@input=${(event: InputEvent) => {
state.draftLabels = (event.currentTarget as HTMLInputElement).value;
@@ -1656,6 +1699,7 @@ function renderCardModal(props: WorkboardProps) {
class="input workboard-comments__input"
aria-labelledby="workboard-card-comments-title"
maxlength="2000"
?disabled=${draftActionsBusy}
.value=${state.draftCommentBody}
@input=${(event: InputEvent) => {
state.draftCommentBody = (event.currentTarget as HTMLTextAreaElement).value;
@@ -1689,9 +1733,11 @@ function renderCardModal(props: WorkboardProps) {
<button
class="btn"
type="button"
?disabled=${draftDismissalBusy}
@click=${() => {
resetDraft(state);
props.onRequestUpdate?.();
if (dismissDraft()) {
props.onRequestUpdate?.();
}
}}
>
${t("common.cancel")}

View File

@@ -426,6 +426,26 @@ describeControlUiE2e("Control UI Workboard mocked Gateway E2E", () => {
status: "todo",
title: createdCard.title,
});
expect(await createDialog.getByLabel("Title").isDisabled()).toBe(true);
expect(await createDialog.getByLabel("Notes").isDisabled()).toBe(true);
expect(await createDialog.getByLabel("Labels").isDisabled()).toBe(true);
expect(
await createDialog.locator(".workboard-select__trigger[aria-disabled='true']").count(),
).toBe(4);
const pendingCancelButtons = createDialog.getByRole("button", {
name: "Cancel",
exact: true,
});
expect(await pendingCancelButtons.count()).toBe(2);
expect(await pendingCancelButtons.first().isDisabled()).toBe(true);
expect(await pendingCancelButtons.last().isDisabled()).toBe(true);
expect(await createDialog.locator(".workboard-template-strip button:disabled").count()).toBe(
5,
);
await writable.page.keyboard.press("Escape");
await expect.poll(() => createDialog.isVisible()).toBe(true);
await writable.page.locator(".workboard-modal").click({ position: { x: 4, y: 4 } });
await expect.poll(() => createDialog.isVisible()).toBe(true);
await writableGateway.resolveDeferred("workboard.cards.create", { card: createdCard });
await cardInColumn(writable.page, "Todo", createdCard.title).waitFor({ state: "visible" });
await captureScreenshot(writable.page, artifacts, "03-created-card");

View File

@@ -538,6 +538,11 @@
white-space: normal;
}
.workboard-select--disabled .workboard-select__trigger {
cursor: not-allowed;
opacity: 0.5;
}
.workboard-field--wide {
grid-column: 1 / -1;
}