fix(ui): stop Workboard draft fields reverting during edits (#115141)

* fix(ui): preserve Workboard draft edits

* chore: leave release notes to release automation

* test(ui): wait for settings takeover controls
This commit is contained in:
Peter Steinberger
2026-07-28 09:00:24 -04:00
committed by GitHub
parent 2cdd1246ed
commit 8944331f28
3 changed files with 76 additions and 34 deletions
+13 -9
View File
@@ -36,6 +36,15 @@ async function roundedWidth(locator: Locator): Promise<number> {
return Math.round((await locator.boundingBox())?.width ?? 0);
}
async function waitForSettingsSidebar(page: Page) {
const sidebar = page.locator(".settings-sidebar");
const search = sidebar.getByRole("searchbox", { name: "Search settings" });
await sidebar.waitFor({ state: "visible" });
// The route shell can paint before the takeover controls settle on a loaded CI host.
await search.waitFor({ state: "visible" });
return { search, sidebar };
}
function visibleDrawerButton(page: Page) {
return page.locator(".topbar-nav-toggle:visible, .chat-pane__nav-toggle:visible").first();
}
@@ -160,12 +169,11 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () =
try {
await page.goto(`${server.baseUrl}settings/general`);
const settingsSidebar = page.locator(".settings-sidebar");
const { search: settingsSearchInput, sidebar: settingsSidebar } =
await waitForSettingsSidebar(page);
const settingsSearchShell = settingsSidebar.locator(".settings-sidebar__search");
const settingsSearchInput = settingsSidebar.locator(".settings-sidebar__search-input");
const settingsNav = settingsSidebar.locator(".settings-sidebar__nav");
const firstSettingsLink = settingsSidebar.locator(".settings-sidebar__item").first();
await settingsSidebar.waitFor();
await expect
.poll(() =>
page
@@ -333,8 +341,8 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () =
await expect.poll(() => identityCard.isVisible()).toBe(true);
await openSettingsFromIdentity();
await expect.poll(() => new URL(page.url()).pathname).toBe("/settings/general");
const settingsSidebar = page.locator(".settings-sidebar");
await expect.poll(() => settingsSidebar.isVisible()).toBe(true);
const { search: settingsSearch, sidebar: settingsSidebar } =
await waitForSettingsSidebar(page);
await expect.poll(() => sidebar.isVisible()).toBe(false);
await expect
.poll(() =>
@@ -347,12 +355,8 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () =
await captureUiProof(page, "01a-settings-takeover.png");
await captureSettingsSidebarProof(settingsSidebar, "01a-settings-search-initial.png");
await holdUiProof(page);
const settingsSearch = settingsSidebar.getByRole("searchbox", {
name: "Search settings",
});
const settingsLinks = settingsSidebar.locator(".settings-sidebar__item");
const allSettingsLabels = await trimmedTextContents(settingsLinks);
await expect.poll(() => settingsSearch.isVisible()).toBe(true);
await expect
.poll(() =>
settingsSearch.evaluate((input) => {
+47 -19
View File
@@ -28,6 +28,36 @@ const workboardCardModalTitleId = "workboard-card-modal-title";
const workboardCardModalDescriptionId = "workboard-card-modal-description";
export const workboardCardModalId = "workboard-card-modal";
// Keep keystroke state local to the form. A parent render can restore stale
// controlled values before the next field is edited or the draft is submitted.
function syncDraftTextInput(
state: WorkboardUiState,
form: HTMLFormElement,
input: HTMLInputElement | HTMLTextAreaElement,
draftActionsBusy: boolean,
) {
if (input.classList.contains("workboard-draft__title")) {
state.draftTitle = input.value;
} else if (input.classList.contains("workboard-draft__notes")) {
state.draftNotes = input.value;
} else if (input.classList.contains("workboard-draft__labels")) {
state.draftLabels = input.value;
} else if (input.classList.contains("workboard-comments__input")) {
state.draftCommentBody = input.value;
} else {
return;
}
const draftSubmit = form.querySelector<HTMLButtonElement>(".workboard-draft__submit");
if (draftSubmit) {
draftSubmit.disabled = draftActionsBusy || !state.draftTitle.trim();
}
const commentSubmit = form.querySelector<HTMLButtonElement>(".workboard-comments__submit");
if (commentSubmit) {
commentSubmit.disabled = draftActionsBusy || !state.draftCommentBody.trim();
}
}
function defineTemplate(
id: WorkboardTemplateId,
draftKey: string,
@@ -168,6 +198,17 @@ export function renderCardModal(props: WorkboardProps) {
id=${workboardCardModalId}
class="workboard-draft"
aria-busy=${draftActionsBusy ? "true" : "false"}
@input=${(event: InputEvent) => {
const input = event.target;
if (input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement) {
syncDraftTextInput(
state,
event.currentTarget as HTMLFormElement,
input,
draftActionsBusy,
);
}
}}
@submit=${(event: SubmitEvent) => {
event.preventDefault();
if (draftActionsBusy) {
@@ -238,10 +279,6 @@ export function renderCardModal(props: WorkboardProps) {
placeholder=${t("workboard.titlePlaceholder")}
?disabled=${draftActionsBusy}
.value=${state.draftTitle}
@input=${(event: InputEvent) => {
state.draftTitle = (event.currentTarget as HTMLInputElement).value;
props.onRequestUpdate?.();
}}
/>
</label>
<label class="workboard-field">
@@ -251,10 +288,6 @@ export function renderCardModal(props: WorkboardProps) {
placeholder=${t("workboard.notesPlaceholder")}
?disabled=${draftActionsBusy}
.value=${state.draftNotes}
@input=${(event: InputEvent) => {
state.draftNotes = (event.currentTarget as HTMLTextAreaElement).value;
props.onRequestUpdate?.();
}}
></textarea>
</label>
</div>
@@ -306,14 +339,10 @@ export function renderCardModal(props: WorkboardProps) {
<label class="workboard-field workboard-field--wide">
<span>${t("workboard.fieldLabels")}</span>
<input
class="input"
class="input workboard-draft__labels"
placeholder=${t("workboard.labelsPlaceholder")}
?disabled=${draftActionsBusy}
.value=${state.draftLabels}
@input=${(event: InputEvent) => {
state.draftLabels = (event.currentTarget as HTMLInputElement).value;
props.onRequestUpdate?.();
}}
/>
</label>
</div>
@@ -339,14 +368,10 @@ export function renderCardModal(props: WorkboardProps) {
maxlength="2000"
?disabled=${draftActionsBusy}
.value=${state.draftCommentBody}
@input=${(event: InputEvent) => {
state.draftCommentBody = (event.currentTarget as HTMLTextAreaElement).value;
props.onRequestUpdate?.();
}}
></textarea>
<div class="workboard-modal__actions">
<button
class="btn"
class="btn workboard-comments__submit"
type="button"
?disabled=${draftActionsBusy || !state.draftCommentBody.trim()}
@click=${() => {
@@ -365,7 +390,10 @@ export function renderCardModal(props: WorkboardProps) {
: nothing}
</div>
<div class="workboard-modal__actions">
<button class="btn primary" ?disabled=${draftActionsBusy || !state.draftTitle.trim()}>
<button
class="btn primary workboard-draft__submit"
?disabled=${draftActionsBusy || !state.draftTitle.trim()}
>
${editing ? t("common.save") : t("common.create")}
</button>
<button
+16 -6
View File
@@ -105,6 +105,16 @@ async function chooseWorkboardSelectFieldOption(
}, optionValue);
}
async function setWorkboardDraftField(
scope: Page | Locator,
label: string,
value: string,
): Promise<void> {
const input = scope.getByLabel(label);
await input.fill(value);
await expect.poll(() => input.inputValue()).toBe(value);
}
async function waitForRequests(
gateway: MockGatewayControls,
method: string,
@@ -440,10 +450,10 @@ describeControlUiE2e("Control UI Workboard mocked Gateway E2E", () => {
const createDialog = writable.page.getByRole("dialog", { name: "New card" });
const createForm = writable.page.locator('openclaw-modal-dialog[label="New card"]');
await expect.poll(() => createDialog.isVisible()).toBe(true);
await createForm.getByLabel("Title").fill(createdCard.title);
await createForm.getByLabel("Notes").fill(createdCard.notes ?? "");
await setWorkboardDraftField(createForm, "Title", createdCard.title);
await setWorkboardDraftField(createForm, "Notes", createdCard.notes ?? "");
await chooseWorkboardSelectOption(createForm, "Thread", linkedSessionName);
await createForm.getByLabel("Labels").fill("ui, proof");
await setWorkboardDraftField(createForm, "Labels", "ui, proof");
await captureScreenshot(writable.page, artifacts, "02-create-dialog");
const createBefore = (await writableGateway.getRequests("workboard.cards.create")).length;
await createForm.getByRole("button", { name: /^Create$/u }).click();
@@ -495,10 +505,10 @@ describeControlUiE2e("Control UI Workboard mocked Gateway E2E", () => {
const editDialog = writable.page.getByRole("dialog", { name: "Edit card" });
const editForm = writable.page.locator('openclaw-modal-dialog[label="Edit card"]');
await expect.poll(() => editDialog.isVisible()).toBe(true);
await editForm.getByLabel("Title").fill(editedCard.title);
await editForm.getByLabel("Notes").fill(editedCard.notes ?? "");
await setWorkboardDraftField(editForm, "Title", editedCard.title);
await setWorkboardDraftField(editForm, "Notes", editedCard.notes ?? "");
await chooseWorkboardSelectOption(editForm, "Priority", "High");
await editForm.getByLabel("Labels").fill("ui, proof, e2e");
await setWorkboardDraftField(editForm, "Labels", "ui, proof, e2e");
const updateBeforeEdit = (await writableGateway.getRequests("workboard.cards.update")).length;
await editForm.getByRole("button", { name: /^Save$/u }).click();
const editRequest = await waitForNextRequest(