Fleet Desktop - Self-service: Install all button visibility, count, and enabled-state rules (#47856)
This commit is contained in:
+5
-3
@@ -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 () => {
|
||||
|
||||
+5
-5
@@ -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}
|
||||
|
||||
+44
-5
@@ -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 () => {
|
||||
|
||||
+18
-2
@@ -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)
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user