Fleet Desktop - Self-service: Install all button visibility, count, and enabled-state rules (#47856)

This commit is contained in:
RachelElysia
2026-06-18 16:03:25 -04:00
committed by GitHub
parent d17b10b9d5
commit 96be00c3ce
6 changed files with 105 additions and 25 deletions
@@ -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 () => {
@@ -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 ? (
<InstallAllInCategoryButton
uninstalledCount={uninstalledCount}
@@ -28,18 +28,57 @@ describe("InstallAllInCategoryButton", () => {
).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(<InstallAllInCategoryButton {...baseProps} uninstalledCount={0} />);
expect(screen.getByRole("button", { name: /Install all/i })).toBeDisabled();
render(
<InstallAllInCategoryButton
{...baseProps}
uninstalledCount={0}
hasInProgressInCategory
/>
);
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(<InstallAllInCategoryButton {...baseProps} uninstalledCount={0} />);
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(
<InstallAllInCategoryButton
{...baseProps}
uninstalledCount={0}
hasInProgressInCategory={false}
/>
);
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(
<InstallAllInCategoryButton {...baseProps} hasInProgressInCategory />
);
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 () => {
@@ -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}
>
<Icon name="install" color="ui-fleet-black-75" />
Install all ({uninstalledCount})
{label}
</Button>
{showModal && (
<InstallAllInCategoryModal
@@ -128,20 +128,29 @@ describe("hasInProgressInstallAllItems", () => {
).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
);
});
@@ -14,6 +14,17 @@ const IN_PROGRESS_UI_STATUSES = new Set<string>([
...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<string>([
"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<string>([
...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)
);