feat(frontend): slice 2 — migrate 11 shared components to shadcn/Tailwind
Web UI rework. Shared-components slice (drift prevention): - Migrate SectionCard, SelectionRailCard, TabbedCard, MetricCard, DiskSpaceCard, HoverEditButton, DialogFooter, ConfirmDialog, LibraryOverview, NowPlaying, SessionActivityPanel off @mui - HoverEditButton: MUI IconButton + EditOutlined -> Button + lucide Pencil - Status mapping uses the success Badge variant (chart-2) for healthy - Exported APIs preserved so consuming pages still compile (no page edits) - 11 behavioral Vitest component tests added Gate: build + lint + test green.
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ConfirmDialog } from "../ConfirmDialog";
|
||||
|
||||
describe("ConfirmDialog", () => {
|
||||
it("renders the title and message and wires confirm/cancel", async () => {
|
||||
const onCancel = vi.fn();
|
||||
const onConfirm = vi.fn();
|
||||
render(
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="Delete machine?"
|
||||
message="This cannot be undone."
|
||||
confirmLabel="Delete"
|
||||
onCancel={onCancel}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Delete machine?")).toBeInTheDocument();
|
||||
expect(screen.getByText("This cannot be undone.")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders nothing when closed", () => {
|
||||
render(
|
||||
<ConfirmDialog
|
||||
open={false}
|
||||
title="Hidden"
|
||||
message="nope"
|
||||
onCancel={() => {}}
|
||||
onConfirm={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText("Hidden")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { DialogFooter } from "../DialogFooter";
|
||||
|
||||
describe("DialogFooter", () => {
|
||||
it("renders cancel/confirm labels and wires both callbacks", async () => {
|
||||
const onCancel = vi.fn();
|
||||
const onConfirm = vi.fn();
|
||||
render(
|
||||
<DialogFooter
|
||||
onCancel={onCancel}
|
||||
cancelLabel="Cancel"
|
||||
onConfirm={onConfirm}
|
||||
confirmLabel="Save"
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("prefers the busy label and maps confirmColor=error to destructive", () => {
|
||||
render(
|
||||
<DialogFooter
|
||||
onCancel={() => {}}
|
||||
onConfirm={() => {}}
|
||||
confirmLabel="Delete"
|
||||
confirmBusyLabel="Deleting…"
|
||||
confirmColor="error"
|
||||
/>,
|
||||
);
|
||||
const confirm = screen.getByRole("button", { name: "Deleting…" });
|
||||
expect(confirm).toBeInTheDocument();
|
||||
expect(confirm.getAttribute("data-variant")).toBe("destructive");
|
||||
});
|
||||
|
||||
it("renders the secondary action when provided", () => {
|
||||
render(
|
||||
<DialogFooter
|
||||
onCancel={() => {}}
|
||||
onConfirm={() => {}}
|
||||
confirmLabel="OK"
|
||||
secondaryAction={<button type="button">Test SSH</button>}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Test SSH" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { DiskSpaceCard } from "../DiskSpaceCard";
|
||||
|
||||
describe("DiskSpaceCard", () => {
|
||||
it("preserves the used / free / total breakdown and percent headline", () => {
|
||||
render(
|
||||
<DiskSpaceCard
|
||||
used={500000000000}
|
||||
available={500000000000}
|
||||
size={1000000000000}
|
||||
usedPct="50"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/50 used/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("Used")).toBeInTheDocument();
|
||||
expect(screen.getByText("Free")).toBeInTheDocument();
|
||||
expect(screen.getByText("Total")).toBeInTheDocument();
|
||||
// Disk space label
|
||||
expect(screen.getByText(/disk space/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { HoverEditButton } from "../HoverEditButton";
|
||||
|
||||
describe("HoverEditButton", () => {
|
||||
it("fires onClick and exposes the default aria-label", async () => {
|
||||
const onClick = vi.fn();
|
||||
render(<HoverEditButton onClick={onClick} />);
|
||||
const button = screen.getByRole("button", { name: "Edit" });
|
||||
await userEvent.click(button);
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("honors a custom label", () => {
|
||||
render(<HoverEditButton onClick={() => {}} label="Rename machine" />);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Rename machine" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { LibraryOverview } from "../LibraryOverview";
|
||||
import type { LibraryCount } from "../../types";
|
||||
|
||||
const libraries: LibraryCount[] = [
|
||||
{
|
||||
library: "Films",
|
||||
type: "movies",
|
||||
movies: 100,
|
||||
series: 0,
|
||||
episodes: 0,
|
||||
total: 100,
|
||||
},
|
||||
{
|
||||
library: "Shows",
|
||||
type: "tvshows",
|
||||
movies: 0,
|
||||
series: 12,
|
||||
episodes: 240,
|
||||
total: 252,
|
||||
},
|
||||
];
|
||||
|
||||
describe("LibraryOverview", () => {
|
||||
it("renders movie and TV library cards with their counts", () => {
|
||||
render(<LibraryOverview libraries={libraries} />);
|
||||
expect(screen.getByText("Movie libraries")).toBeInTheDocument();
|
||||
expect(screen.getByText("TV libraries")).toBeInTheDocument();
|
||||
expect(screen.getByText("Films")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Total: 100 \| Movies: 100/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Shows")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Total: 252 \| Series: 12/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MetricCard } from "../MetricCard";
|
||||
|
||||
describe("MetricCard", () => {
|
||||
it("renders the label, value, and subtext on the comfortable ramp", () => {
|
||||
render(
|
||||
<MetricCard label="Movies" value="1,234" subtext="across 3 libraries" />,
|
||||
);
|
||||
expect(screen.getByText("Movies")).toBeInTheDocument();
|
||||
expect(screen.getByText("1,234")).toBeInTheDocument();
|
||||
expect(screen.getByText(/across 3 libraries/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits subtext when not provided", () => {
|
||||
render(<MetricCard label="Series" value="42" />);
|
||||
expect(screen.getByText("Series")).toBeInTheDocument();
|
||||
expect(screen.getByText("42")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/subtext/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { NowPlaying } from "../NowPlaying";
|
||||
|
||||
describe("NowPlaying", () => {
|
||||
it("renders the dashboard empty-state message contract when there are no sessions", () => {
|
||||
render(<NowPlaying sessions={[]} />);
|
||||
expect(
|
||||
screen.getByText("No recent user activity sessions right now."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { SectionCard } from "../SectionCard";
|
||||
|
||||
describe("SectionCard", () => {
|
||||
it("renders title, description, action, and children", () => {
|
||||
render(
|
||||
<SectionCard
|
||||
title="Shortcuts"
|
||||
description="Quick links"
|
||||
action={<button type="button">Add</button>}
|
||||
>
|
||||
<p>Body content</p>
|
||||
</SectionCard>,
|
||||
);
|
||||
expect(screen.getByText("Shortcuts")).toBeInTheDocument();
|
||||
expect(screen.getByText("Quick links")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Add" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Body content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders without a description or action", () => {
|
||||
render(<SectionCard title="Only title">children</SectionCard>);
|
||||
expect(screen.getByText("Only title")).toBeInTheDocument();
|
||||
expect(screen.getByText("children")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { SelectionRailCard } from "../SelectionRailCard";
|
||||
|
||||
describe("SelectionRailCard", () => {
|
||||
it("renders the title, body, and footer and honors minHeight", () => {
|
||||
render(
|
||||
<SelectionRailCard
|
||||
title="Saved tasks"
|
||||
description="Pick one"
|
||||
minHeight={200}
|
||||
footer={<button type="button">New task</button>}
|
||||
>
|
||||
<div>Task A</div>
|
||||
</SelectionRailCard>,
|
||||
);
|
||||
expect(screen.getByText("Saved tasks")).toBeInTheDocument();
|
||||
expect(screen.getByText("Task A")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "New task" }),
|
||||
).toBeInTheDocument();
|
||||
// minHeight is applied to the Card via inline style.
|
||||
const card = screen
|
||||
.getByText("Saved tasks")
|
||||
.closest("[data-slot='card']") as HTMLElement | null;
|
||||
expect(card?.style.minHeight).toBe("200px");
|
||||
});
|
||||
|
||||
it("renders without a footer", () => {
|
||||
render(<SelectionRailCard title="No footer">body</SelectionRailCard>);
|
||||
expect(screen.getByText("No footer")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { SessionActivityPanel } from "../SessionActivityPanel";
|
||||
import type { NowPlayingSession } from "../../types";
|
||||
|
||||
function session(
|
||||
overrides: Partial<NowPlayingSession> = {},
|
||||
): NowPlayingSession {
|
||||
return {
|
||||
user: "alice",
|
||||
title: "Movie",
|
||||
type: "Movie",
|
||||
state: "playing",
|
||||
transcoding: "no",
|
||||
transcoding_type: "",
|
||||
device: "Web",
|
||||
session_id: "s1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("SessionActivityPanel", () => {
|
||||
it("maps a playing (healthy) session to the success Badge variant", () => {
|
||||
render(<SessionActivityPanel sessions={[session({ state: "playing" })]} />);
|
||||
const badge = screen.getByText("Playing");
|
||||
expect(badge.getAttribute("data-variant")).toBe("success");
|
||||
});
|
||||
|
||||
it("maps paused → warning and idle → secondary", () => {
|
||||
const { rerender } = render(
|
||||
<SessionActivityPanel sessions={[session({ state: "paused" })]} />,
|
||||
);
|
||||
expect(screen.getByText("Paused").getAttribute("data-variant")).toBe(
|
||||
"warning",
|
||||
);
|
||||
rerender(<SessionActivityPanel sessions={[session({ state: "idle" })]} />);
|
||||
expect(screen.getByText("Idle").getAttribute("data-variant")).toBe(
|
||||
"secondary",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the empty-state message when there are no sessions", () => {
|
||||
render(
|
||||
<SessionActivityPanel sessions={[]} emptyMessage="Nothing playing." />,
|
||||
);
|
||||
expect(screen.getByText("Nothing playing.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onSelectSession on row click and on the action button", async () => {
|
||||
const onSelectSession = vi.fn();
|
||||
render(
|
||||
<SessionActivityPanel
|
||||
sessions={[session({ state: "playing" })]}
|
||||
onSelectSession={onSelectSession}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByText("alice"));
|
||||
expect(onSelectSession).toHaveBeenCalledTimes(1);
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Open in Users" }),
|
||||
);
|
||||
expect(onSelectSession).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { TabbedCard } from "../TabbedCard";
|
||||
import { TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
describe("TabbedCard", () => {
|
||||
it("renders the provided tab triggers and reports selection changes", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<TabbedCard
|
||||
value="jellyfin"
|
||||
onChange={onChange}
|
||||
tabs={[
|
||||
<TabsTrigger key="jellyfin" value="jellyfin">
|
||||
Jellyfin
|
||||
</TabsTrigger>,
|
||||
<TabsTrigger key="nextcloud" value="nextcloud">
|
||||
Nextcloud
|
||||
</TabsTrigger>,
|
||||
]}
|
||||
>
|
||||
<p>Body</p>
|
||||
</TabbedCard>,
|
||||
);
|
||||
expect(screen.getByText("Jellyfin")).toBeInTheDocument();
|
||||
expect(screen.getByText("Body")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText("Nextcloud"));
|
||||
expect(onChange).toHaveBeenCalledWith("nextcloud");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user