Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/storybook-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

Expand Down
25 changes: 25 additions & 0 deletions frontend/src/components/AdminPanel.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof AdminPanel>;

export default meta;

type Story = StoryObj<typeof meta>;

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",
},
};
83 changes: 83 additions & 0 deletions frontend/src/components/AdminPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<AdminPanel currentBrandName="LineageWeave" onBrandNameChange={vi.fn()} accessToken="token" />,
);
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" });
Comment thread
seonghobae marked this conversation as resolved.
const onBrandNameChange = vi.fn();
render(
<AdminPanel currentBrandName="LineageWeave" onBrandNameChange={onBrandNameChange} accessToken="token" />,
);

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(
<AdminPanel currentBrandName="LineageWeave" onBrandNameChange={vi.fn()} accessToken="token" />,
);

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(
<AdminPanel currentBrandName="LineageWeave" onBrandNameChange={vi.fn()} accessToken="token" />,
);

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");
});
});
32 changes: 16 additions & 16 deletions frontend/src/components/AdminPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,22 @@ export function AdminPanel({ currentBrandName, onBrandNameChange, accessToken }:
const [saved, setSaved] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(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;
Comment thread
seonghobae marked this conversation as resolved.
setSaving(true);
setError(null);
try {
const config = await updateTenantConfig(accessToken, normalizedName);
onBrandNameChange(config.brandName);
setSaved(true);
setTimeout(() => setSaved(false), 3000);
Comment thread
seonghobae marked this conversation as resolved.
} catch (err: any) {
setError(err.message || "Failed to update settings");
} finally {
setSaving(false);
}
}

Expand All @@ -51,11 +51,11 @@ export function AdminPanel({ currentBrandName, onBrandNameChange, accessToken }:
disabled={saving}
/>
<div>
<button type="submit" className="btn-primary" disabled={saving || !draftName.trim() || draftName === currentBrandName}>
<button type="submit" className="btn-primary" disabled={saving || !normalizedName || normalizedName === currentBrandName}>
{saving ? t("Saving...") : t("Save settings")}
</button>
{saved && <span style={{ marginLeft: "1rem", color: "green" }}>{t("Settings saved!")}</span>}
{error && <span style={{ marginLeft: "1rem", color: "red" }}>{t(error)}</span>}
{saved && <span role="status" style={{ marginLeft: "1rem", color: "green" }}>{t("Settings saved!")}</span>}
{error && <span role="alert" style={{ marginLeft: "1rem", color: "red" }}>{t(error)}</span>}
</div>
</form>
</div>
Expand Down