From 8944331f2867aa3c58b0682841ea8aac0cb5367f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Jul 2026 09:00:24 -0400 Subject: [PATCH] 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 --- ui/src/e2e/sidebar-customization.e2e.test.ts | 22 ++++--- ui/src/pages/workboard/view-card-modal.ts | 66 ++++++++++++++------ ui/src/pages/workboard/workboard.e2e.test.ts | 22 +++++-- 3 files changed, 76 insertions(+), 34 deletions(-) diff --git a/ui/src/e2e/sidebar-customization.e2e.test.ts b/ui/src/e2e/sidebar-customization.e2e.test.ts index f9111d851374..f987a5f9ac06 100644 --- a/ui/src/e2e/sidebar-customization.e2e.test.ts +++ b/ui/src/e2e/sidebar-customization.e2e.test.ts @@ -36,6 +36,15 @@ async function roundedWidth(locator: Locator): Promise { 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) => { diff --git a/ui/src/pages/workboard/view-card-modal.ts b/ui/src/pages/workboard/view-card-modal.ts index 01b1fd5c9417..ba58f0017efe 100644 --- a/ui/src/pages/workboard/view-card-modal.ts +++ b/ui/src/pages/workboard/view-card-modal.ts @@ -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(".workboard-draft__submit"); + if (draftSubmit) { + draftSubmit.disabled = draftActionsBusy || !state.draftTitle.trim(); + } + const commentSubmit = form.querySelector(".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?.(); - }} /> @@ -306,14 +339,10 @@ export function renderCardModal(props: WorkboardProps) { @@ -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?.(); - }} >
-