diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 6ba03a951..5b0fe143b 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -8,6 +8,7 @@ buyer-facing control you can click before changing product CSS. | `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` | | `AnalysisRun/CutoffKnownBody` | Read the cutoff-known sentence, then compare it with the live body below. | `--color-accent-border`, `--space-panel-block`, `--radius-panel`, `CutoffKnownBody` | | `Analysis/LineageEntityPicker` | Choose which corp to reconstruct, then click Request a lineage reconstruction. | `--space-control-gap`, `--size-control-min`, `--radius-control`, `LineageEntityPicker` | +| `Admin/AdminPanel` | Change the tenant brand name, then verify the saved or failed state before leaving settings. | `--surface`, `--border`, `--space-panel-block`, `AdminPanel` | | `Lineage/LineageDag` | Open the current branch node; compare empty, grouped/forked, ungrouped, and long-title states before changing graph CSS. | `--surface`, `--border`, `LineageDag` | | `Chrome/PopupCloseButton` | Close the evidence panel or post popup. | `--space-close-inset`, `--font-size-close`, `PopupCloseButton` | diff --git a/frontend/src/components/AdminPanel.stories.tsx b/frontend/src/components/AdminPanel.stories.tsx new file mode 100644 index 000000000..f028de4b8 --- /dev/null +++ b/frontend/src/components/AdminPanel.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { AdminPanel } from "./AdminPanel"; + +const meta = { + title: "Admin/AdminPanel", + component: AdminPanel, + args: { + currentBrandName: "LineageWeave", + onBrandNameChange: () => undefined, + accessToken: "demo-access-token", + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +// Edge case: a long tenant brand name near the input's practical width. +export const LongBrandName: Story = { + args: { + currentBrandName: "A Very Long Tenant Brand Name For Layout Testing Purposes", + }, +}; diff --git a/frontend/src/components/AdminPanel.test.tsx b/frontend/src/components/AdminPanel.test.tsx new file mode 100644 index 000000000..c72b436a0 --- /dev/null +++ b/frontend/src/components/AdminPanel.test.tsx @@ -0,0 +1,83 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { AdminPanel } from "./AdminPanel"; +import * as api from "../api"; + +describe("AdminPanel", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("ignores unchanged and whitespace-only submissions", async () => { + const updateSpy = vi.spyOn(api, "updateTenantConfig"); + render( + , + ); + const button = screen.getByRole("button", { name: "Save settings" }); + const input = screen.getByRole("textbox", { name: "Tenant brand name" }); + const form = button.closest("form"); + + expect(button).toBeDisabled(); + fireEvent.submit(form!); + await userEvent.clear(input); + await userEvent.type(input, " "); + expect(button).toBeDisabled(); + fireEvent.submit(form!); + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it("saves a changed brand name and reports it back to the caller", async () => { + const timeoutSpy = vi.spyOn(globalThis, "setTimeout"); + const updateSpy = vi + .spyOn(api, "updateTenantConfig") + .mockResolvedValue({ brandName: "Renamed Corp" }); + const onBrandNameChange = vi.fn(); + render( + , + ); + + const input = screen.getByRole("textbox", { name: "Tenant brand name" }); + await userEvent.clear(input); + await userEvent.type(input, "Renamed Corp"); + await userEvent.click(screen.getByRole("button", { name: "Save settings" })); + + expect(updateSpy).toHaveBeenCalledWith("token", "Renamed Corp"); + expect(await screen.findByRole("status")).toHaveTextContent("Settings saved!"); + expect(onBrandNameChange).toHaveBeenCalledWith("Renamed Corp"); + const timeoutIndex = timeoutSpy.mock.calls.findIndex((call) => call[1] === 3000); + expect(timeoutIndex).toBeGreaterThanOrEqual(0); + clearTimeout(timeoutSpy.mock.results[timeoutIndex].value); + act(() => (timeoutSpy.mock.calls[timeoutIndex][0] as () => void)()); + expect(screen.queryByRole("status")).toBeNull(); + }); + + it("shows an error and leaves the form editable when the save fails", async () => { + vi.spyOn(api, "updateTenantConfig").mockRejectedValue(new Error("Failed to update settings")); + render( + , + ); + + const input = screen.getByRole("textbox", { name: "Tenant brand name" }); + await userEvent.clear(input); + await userEvent.type(input, "Renamed Corp"); + await userEvent.click(screen.getByRole("button", { name: "Save settings" })); + + expect(await screen.findByRole("alert")).toHaveTextContent("Failed to update settings"); + expect(screen.getByRole("button", { name: "Save settings" })).not.toBeDisabled(); + }); + + it("uses the actionable fallback when a failure has no message", async () => { + vi.spyOn(api, "updateTenantConfig").mockRejectedValue(new Error("")); + render( + , + ); + + const input = screen.getByRole("textbox", { name: "Tenant brand name" }); + await userEvent.clear(input); + await userEvent.type(input, "Renamed Corp"); + await userEvent.click(screen.getByRole("button", { name: "Save settings" })); + + expect(await screen.findByRole("alert")).toHaveTextContent("Failed to update settings"); + }); +}); diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index af098f827..4f5ac2172 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -13,22 +13,22 @@ export function AdminPanel({ currentBrandName, onBrandNameChange, accessToken }: const [saved, setSaved] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); + const normalizedName = draftName.trim(); async function handleSave(e: React.FormEvent) { e.preventDefault(); - if (draftName.trim()) { - setSaving(true); - setError(null); - try { - const config = await updateTenantConfig(accessToken, draftName.trim()); - onBrandNameChange(config.brandName); - setSaved(true); - setTimeout(() => setSaved(false), 3000); - } catch (err: any) { - setError(err.message || "Failed to update settings"); - } finally { - setSaving(false); - } + if (!normalizedName || normalizedName === currentBrandName) return; + setSaving(true); + setError(null); + try { + const config = await updateTenantConfig(accessToken, normalizedName); + onBrandNameChange(config.brandName); + setSaved(true); + setTimeout(() => setSaved(false), 3000); + } catch (err: any) { + setError(err.message || "Failed to update settings"); + } finally { + setSaving(false); } } @@ -51,11 +51,11 @@ export function AdminPanel({ currentBrandName, onBrandNameChange, accessToken }: disabled={saving} />
- - {saved && {t("Settings saved!")}} - {error && {t(error)}} + {saved && {t("Settings saved!")}} + {error && {t(error)}}