From 96be00c3ce29ff16ef9869c9abe33751c4750da6 Mon Sep 17 00:00:00 2001 From: RachelElysia <71795832+RachelElysia@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:03:25 -0400 Subject: [PATCH] Fleet Desktop - Self-service: Install all button visibility, count, and enabled-state rules (#47856) --- .../SelfServiceCard/SelfServiceCard.tests.tsx | 8 +-- .../SelfServiceCard/SelfServiceCard.tsx | 10 ++-- .../InstallAllInCategoryButton.tests.tsx | 49 +++++++++++++++++-- .../InstallAllInCategoryButton.tsx | 20 +++++++- .../Software/SelfService/helpers.tests.ts | 23 ++++++--- .../cards/Software/SelfService/helpers.ts | 20 ++++++-- 6 files changed, 105 insertions(+), 25 deletions(-) diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceCard/SelfServiceCard.tests.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceCard/SelfServiceCard.tests.tsx index 0b558c2458..dc6e1a3148 100644 --- a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceCard/SelfServiceCard.tests.tsx +++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceCard/SelfServiceCard.tests.tsx @@ -319,9 +319,11 @@ describe("SelfServiceCard", () => { }); }); - it("disables the install-all button when an item in the category is in-progress", async () => { + // `install_all` skips items already in INSTALLED_OR_IN_FLIGHT, so a second + // click only queues whatever's still eligible. The button stays enabled + // whenever count > 0. See #47855. + it("keeps the install-all button enabled when an item is in progress and there are still uninstalled items", async () => { const props = createTestProps({ - queryParams: { ...DEFAULT_QUERY_PARAMS, category_id: 42 }, enhancedSoftware: [ { ...createMockDeviceSoftware({ name: "uninstalled-app" }), @@ -340,7 +342,7 @@ describe("SelfServiceCard", () => { const button = await screen.findByRole("button", { name: /Install all/i, }); - expect(button).toBeDisabled(); + expect(button).toBeEnabled(); }); it("posts to install_all and fires onInstallAllSuccess when the confirm modal is submitted", async () => { diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceCard/SelfServiceCard.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceCard/SelfServiceCard.tsx index 6f0d427299..4ca5a8de20 100644 --- a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceCard/SelfServiceCard.tsx +++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceCard/SelfServiceCard.tsx @@ -232,12 +232,12 @@ const SelfServiceCard = ({ }) : softwareInSelectedCategory; - // The button shows in all four variants (including "All"). On "All", - // `categoryId` is undefined; the click posts to install_all without a + // The button is shown on desktop in the "All" filter and in any selected + // category. On + // "All", `categoryId` is undefined; the click posts to install_all without a // category_id query param and the BE installs every eligible (uninstalled, - // not-in-progress) self-service item. Disabled state is driven purely by - // hasInProgressInCategory || uninstalledCount === 0 — no special case for - // categoryId === undefined. + // not-in-progress) self-service item. Visibility, count, and disabled state + // are owned by InstallAllInCategoryButton — see #47855 for the full rules. const installAllButton = !isMobileView ? ( { ).toBeInTheDocument(); }); - it("is disabled when uninstalledCount is 0", () => { + it("renders without the (0) suffix and stays disabled when count is 0 but an install is in progress", () => { const render = createCustomRenderer({ withBackendMock: true }); - render(); - expect(screen.getByRole("button", { name: /Install all/i })).toBeDisabled(); + render( + + ); + const button = screen.getByRole("button", { name: /^Install all$/i }); + expect(button).toBeInTheDocument(); + expect(button).toBeDisabled(); + expect(screen.queryByText(/\(0\)/)).not.toBeInTheDocument(); }); - it("is disabled when hasInProgressInCategory is true", () => { + it("is not rendered when count is 0 and nothing is in progress", () => { + const render = createCustomRenderer({ withBackendMock: true }); + render(); + expect( + screen.queryByRole("button", { name: /Install all/i }) + ).not.toBeInTheDocument(); + }); + + // Per design intent, `hasInProgressInCategory` is only true for + // install/script in-flight statuses (see helpers.ts). A category whose only + // active work is an "updating..." item should report hasInProgressInCategory + // = false, so the button is hidden when count is also 0. + it("is not rendered when count is 0 and the parent reports no install_all in flight", () => { + const render = createCustomRenderer({ withBackendMock: true }); + render( + + ); + expect( + screen.queryByRole("button", { name: /Install all/i }) + ).not.toBeInTheDocument(); + }); + + // `install_all` skips items already in INSTALLED_OR_IN_FLIGHT, so a second + // click during a batch only queues whatever is still eligible. The button + // stays enabled whenever there's something actionable to click. See #47855 + // for the full visibility/count/enabled rules. + it("stays enabled when count > 0 even if an install_all batch is in flight", () => { const render = createCustomRenderer({ withBackendMock: true }); render( ); - expect(screen.getByRole("button", { name: /Install all/i })).toBeDisabled(); + expect(screen.getByRole("button", { name: /Install all/i })).toBeEnabled(); }); it("opens the confirmation modal when clicked", async () => { diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/components/InstallAllInCategoryButton/InstallAllInCategoryButton.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/components/InstallAllInCategoryButton/InstallAllInCategoryButton.tsx index 7c689007e8..1ea47271de 100644 --- a/frontend/pages/hosts/details/cards/Software/SelfService/components/InstallAllInCategoryButton/InstallAllInCategoryButton.tsx +++ b/frontend/pages/hosts/details/cards/Software/SelfService/components/InstallAllInCategoryButton/InstallAllInCategoryButton.tsx @@ -52,7 +52,23 @@ const InstallAllInCategoryButton = ({ } }, [deviceToken, categoryId, onSuccess, renderFlash]); - const isDisabled = hasInProgressInCategory || uninstalledCount === 0; + // Nothing eligible and no install_all batch running — drop the button from + // the DOM. When a previous batch IS still running (count === 0 && + // hasInProgressInCategory), fall through and render a disabled "Install all" + // (no count) so the user keeps a visual anchor on the action they triggered + // until items settle. + if (uninstalledCount === 0 && !hasInProgressInCategory) { + return null; + } + + // `count === 0` only reaches this line during an in-flight batch (the + // early-return handles count=0 with no batch). That's the one and only + // state where the button renders disabled. + const isDisabled = uninstalledCount === 0; + const label = + uninstalledCount === 0 + ? "Install all" + : `Install all (${uninstalledCount})`; return ( <> @@ -63,7 +79,7 @@ const InstallAllInCategoryButton = ({ disabled={isDisabled} > - Install all ({uninstalledCount}) + {label} {showModal && ( { ).toBe(true); }); - it("returns true for pending statuses (offline-host scheduled)", () => { + it("returns true for install/script in-flight statuses", () => { + expect(hasInProgressInstallAllItems([makeItem("installing")])).toBe(true); + expect(hasInProgressInstallAllItems([makeItem("running_script")])).toBe( + true + ); expect(hasInProgressInstallAllItems([makeItem("pending_install")])).toBe( true ); - expect(hasInProgressInstallAllItems([makeItem("pending_uninstall")])).toBe( + expect(hasInProgressInstallAllItems([makeItem("pending_script")])).toBe( true ); }); - it("returns true for uninstalling / updating / running_script", () => { - expect(hasInProgressInstallAllItems([makeItem("uninstalling")])).toBe(true); - expect(hasInProgressInstallAllItems([makeItem("updating")])).toBe(true); - expect(hasInProgressInstallAllItems([makeItem("running_script")])).toBe( - true + it("returns false for update/uninstall in-flight statuses (not install_all operations)", () => { + expect(hasInProgressInstallAllItems([makeItem("updating")])).toBe(false); + expect(hasInProgressInstallAllItems([makeItem("uninstalling")])).toBe( + false + ); + expect(hasInProgressInstallAllItems([makeItem("pending_update")])).toBe( + false + ); + expect(hasInProgressInstallAllItems([makeItem("pending_uninstall")])).toBe( + false ); }); diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/helpers.ts b/frontend/pages/hosts/details/cards/Software/SelfService/helpers.ts index 0af30ff55f..a04afa0831 100644 --- a/frontend/pages/hosts/details/cards/Software/SelfService/helpers.ts +++ b/frontend/pages/hosts/details/cards/Software/SelfService/helpers.ts @@ -14,6 +14,17 @@ const IN_PROGRESS_UI_STATUSES = new Set([ ...HOST_SOFTWARE_UI_PENDING_STATUSES, ]); +/** Statuses that specifically indicate an install_all-style operation is in + * flight. Narrower than IN_PROGRESS_UI_STATUSES: updates and uninstalls aren't + * triggered by install_all, so they shouldn't keep the button visible after + * `uninstalledCount` drops to 0. */ +const INSTALL_ALL_IN_FLIGHT_UI_STATUSES = new Set([ + "installing", + "running_script", + "pending_install", + "pending_script", +]); + /** Statuses indicating the user cannot click "Install" — already done or in-flight. */ const INSTALLED_OR_IN_FLIGHT_UI_STATUSES = new Set([ ...IN_PROGRESS_UI_STATUSES, @@ -96,9 +107,12 @@ export const countUninstalledForInstallAll = ( (item) => !INSTALLED_OR_IN_FLIGHT_UI_STATUSES.has(item.ui_status) ).length; -/** True if any item in the list is currently in-progress (install_all should - * be disabled until they all leave that state). */ +/** True if any item in the list is currently being installed/scripted by an + * install_all-style operation. Updates and uninstalls don't count — those are + * orthogonal operations and shouldn't keep the install_all button visible. */ export const hasInProgressInstallAllItems = ( software: IDeviceSoftwareWithUiStatus[] ): boolean => - software.some((item) => IN_PROGRESS_UI_STATUSES.has(item.ui_status)); + software.some((item) => + INSTALL_ALL_IN_FLIGHT_UI_STATUSES.has(item.ui_status) + );