From d38d9a0b86f83a6f7b690245e2a9e6c97ae23aa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sun, 9 Aug 2026 12:06:38 +0100 Subject: [PATCH] fix(plugins): show install loading state (#2454) --- apps/web/app/settings/plugins/page.test.tsx | 41 +++++- .../plugins/install-plugin-dialog.tsx | 5 +- apps/web/e2e/helpers/plugin-install.ts | 34 +++++ .../tests/plugins/mobile-plugin-nav.spec.ts | 32 +++++ apps/web/e2e/tests/plugins/plugins.spec.ts | 27 ++++ .../plugin-install-loading-state/plan.md | 124 ++++++++++++++++++ .../task-01-install-loading-state.md | 78 +++++++++++ docs/specs/plugins/spec.md | 11 ++ 8 files changed, 345 insertions(+), 7 deletions(-) create mode 100644 apps/web/e2e/helpers/plugin-install.ts create mode 100644 docs/plans/plugin-install-loading-state/plan.md create mode 100644 docs/plans/plugin-install-loading-state/task-01-install-loading-state.md diff --git a/apps/web/app/settings/plugins/page.test.tsx b/apps/web/app/settings/plugins/page.test.tsx index 3bd91eba90..5de5f39cfc 100644 --- a/apps/web/app/settings/plugins/page.test.tsx +++ b/apps/web/app/settings/plugins/page.test.tsx @@ -8,6 +8,7 @@ import type { ReactElement } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { toast } from "sonner"; import { SettingsSaveProvider } from "@/components/settings/settings-save-provider"; +import type { InstallResult } from "@/lib/api/domains/plugins-api"; import type { PluginRecord, SyncResult } from "@/lib/types/plugins"; vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn() } })); @@ -95,6 +96,8 @@ const PLUGIN_ID = "acme-tools"; const NEW_PLUGIN_ID = "new-plugin"; const SYNC_BUTTON_TESTID = "plugins-sync-button"; const INSTALL_TRIGGER_TESTID = "install-plugin-trigger"; +const INSTALL_URL_INPUT_TESTID = "install-plugin-url-input"; +const INSTALL_URL_SUBMIT_TESTID = "install-plugin-url-submit"; const NEW_PLUGIN_URL = "https://example.test/new-plugin-1.0.0.tar.gz"; const UPLOAD_FILENAME = "new-plugin-1.0.0.tar.gz"; @@ -252,15 +255,41 @@ describe("PluginsSettingsPage", () => { }); describe("PluginsSettingsPage install dialog", () => { + it("shows a spinner while a URL install is pending", async () => { + let resolveInstall!: (result: InstallResult) => void; + const pendingInstall = new Promise((resolve) => { + resolveInstall = resolve; + }); + installPluginFromUrlSpy.mockReturnValueOnce(pendingInstall); + setStoreState([]); + + render(); + fireEvent.click(screen.getByTestId(INSTALL_TRIGGER_TESTID)); + fireEvent.change(screen.getByTestId(INSTALL_URL_INPUT_TESTID), { + target: { value: NEW_PLUGIN_URL }, + }); + fireEvent.click(screen.getByTestId(INSTALL_URL_SUBMIT_TESTID)); + + await vi.waitFor(() => expect(installPluginFromUrlSpy).toHaveBeenCalledWith(NEW_PLUGIN_URL)); + const submit = screen.getByTestId(INSTALL_URL_SUBMIT_TESTID); + expect((submit as HTMLButtonElement).disabled).toBe(true); + expect(submit.getAttribute("aria-busy")).toBe("true"); + expect(submit.querySelector(".animate-spin")).not.toBeNull(); + expect(submit.textContent).toMatch(/installing/i); + + resolveInstall({ plugin: installedPlugin() }); + await vi.waitFor(() => expect(screen.queryByTestId("install-plugin-dialog")).toBeNull()); + }); + it("installs a plugin from a URL: calls the API, loads the bundle, and updates the store", async () => { setStoreState([]); render(); fireEvent.click(screen.getByTestId(INSTALL_TRIGGER_TESTID)); - fireEvent.change(screen.getByTestId("install-plugin-url-input"), { + fireEvent.change(screen.getByTestId(INSTALL_URL_INPUT_TESTID), { target: { value: NEW_PLUGIN_URL }, }); - fireEvent.click(screen.getByTestId("install-plugin-url-submit")); + fireEvent.click(screen.getByTestId(INSTALL_URL_SUBMIT_TESTID)); await vi.waitFor(() => expect(installPluginFromUrlSpy).toHaveBeenCalledWith(NEW_PLUGIN_URL)); const upsertPlugin = storeState.upsertPlugin as ReturnType; @@ -340,10 +369,10 @@ describe("PluginsSettingsPage install dialog state reset", () => { render(); fireEvent.click(screen.getByTestId(INSTALL_TRIGGER_TESTID)); - fireEvent.change(screen.getByTestId("install-plugin-url-input"), { + fireEvent.change(screen.getByTestId(INSTALL_URL_INPUT_TESTID), { target: { value: NEW_PLUGIN_URL }, }); - fireEvent.click(screen.getByTestId("install-plugin-url-submit")); + fireEvent.click(screen.getByTestId(INSTALL_URL_SUBMIT_TESTID)); await vi.waitFor(() => expect(toast.warning).toHaveBeenCalledWith( @@ -365,10 +394,10 @@ describe("PluginsSettingsPage install dialog state reset", () => { render(); fireEvent.click(screen.getByTestId(INSTALL_TRIGGER_TESTID)); - fireEvent.change(screen.getByTestId("install-plugin-url-input"), { + fireEvent.change(screen.getByTestId(INSTALL_URL_INPUT_TESTID), { target: { value: "https://example.test/bad.tar.gz" }, }); - fireEvent.click(screen.getByTestId("install-plugin-url-submit")); + fireEvent.click(screen.getByTestId(INSTALL_URL_SUBMIT_TESTID)); await vi.waitFor(() => expect(screen.getByTestId("install-plugin-error").textContent).toMatch(/bad checksum/), diff --git a/apps/web/components/settings/plugins/install-plugin-dialog.tsx b/apps/web/components/settings/plugins/install-plugin-dialog.tsx index 8c5f2a9323..7803ac1c65 100644 --- a/apps/web/components/settings/plugins/install-plugin-dialog.tsx +++ b/apps/web/components/settings/plugins/install-plugin-dialog.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { IconLoader2 } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Dialog, @@ -134,9 +135,11 @@ export function InstallPluginDialog({ data-testid={install.testId} onClick={install.onClick} disabled={install.disabled} + aria-busy={busy || undefined} className="cursor-pointer" > - {t("plugins:install")} + {busy ?