feat(frontend): slice 4 — migrate Dashboard + Applications to shadcn/Tailwind

Web UI rework.
- Migrate pages/Dashboard.tsx off @mui (shortcut CRUD dialogs, machine
  switcher, BackupDashboardWidget mount; shortcuts reuse ConfirmDialog/
  DialogFooter from slice 2)
- Migrate pages/Applications.tsx shell off @mui (tabs + library counts);
  keeps <Media/> child intact (Media.tsx still MUI, deferred to slice 7
  with its DataGrid)
- Behavioral tests for both pages

Gate: build + lint + test green.
This commit is contained in:
Developer
2026-06-17 13:15:51 +00:00
parent 77c6b62ee2
commit c721f0dece
6 changed files with 621 additions and 371 deletions
+76 -125
View File
@@ -1,16 +1,8 @@
import { useMemo, useState } from "react";
import {
Alert,
Box,
Card,
CardContent,
Chip,
Grid,
Stack,
Tab,
Typography,
} from "@mui/material";
import { useSearchParams } from "react-router-dom";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { TabsTrigger } from "@/components/ui/tabs";
import { Media } from "./Media";
import { useCounts, useLibraries } from "../hooks/useDashboard";
import { useMonitoringSettings } from "../hooks/useSettings";
@@ -37,110 +29,67 @@ function JellyfinLibraryStats() {
title="Library stats"
description="Compact Jellyfin summary for the selected machine."
action={
<Chip
label={selectedMachineId ? "Selected machine" : "Default machine"}
variant="outlined"
size="small"
/>
<Badge variant="outline">
{selectedMachineId ? "Selected machine" : "Default machine"}
</Badge>
}
>
<Stack spacing={1.25}>
<div className="flex flex-col gap-2">
{counts ? (
<Grid container spacing={1}>
<Grid size={{ xs: 6, md: 3 }}>
<Card variant="outlined">
<CardContent sx={{ py: 1.25, px: 1.5, textAlign: "center" }}>
<Typography variant="caption" color="text.secondary">
Total
</Typography>
<Typography
variant="h6"
sx={{ fontWeight: 800, lineHeight: 1.1 }}
>
{(
counts.movies +
counts.series +
counts.episodes
).toLocaleString()}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<Card variant="outlined">
<CardContent sx={{ py: 1.25, px: 1.5, textAlign: "center" }}>
<Typography variant="caption" color="text.secondary">
Movies
</Typography>
<Typography
variant="h6"
sx={{ fontWeight: 800, lineHeight: 1.1 }}
>
{counts.movies.toLocaleString()}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<Card variant="outlined">
<CardContent sx={{ py: 1.25, px: 1.5, textAlign: "center" }}>
<Typography variant="caption" color="text.secondary">
Series
</Typography>
<Typography
variant="h6"
sx={{ fontWeight: 800, lineHeight: 1.1 }}
>
{counts.series.toLocaleString()}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<Card variant="outlined">
<CardContent sx={{ py: 1.25, px: 1.5, textAlign: "center" }}>
<Typography variant="caption" color="text.secondary">
Episodes
</Typography>
<Typography
variant="h6"
sx={{ fontWeight: 800, lineHeight: 1.1 }}
>
{counts.episodes.toLocaleString()}
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
<div className="grid grid-cols-2 gap-2 md:grid-cols-4">
<div className="rounded-lg border bg-card px-3 py-2 text-center">
<span className="text-xs text-muted-foreground">Total</span>
<div className="text-base leading-tight font-extrabold">
{(
counts.movies +
counts.series +
counts.episodes
).toLocaleString()}
</div>
</div>
<div className="rounded-lg border bg-card px-3 py-2 text-center">
<span className="text-xs text-muted-foreground">Movies</span>
<div className="text-base leading-tight font-extrabold">
{counts.movies.toLocaleString()}
</div>
</div>
<div className="rounded-lg border bg-card px-3 py-2 text-center">
<span className="text-xs text-muted-foreground">Series</span>
<div className="text-base leading-tight font-extrabold">
{counts.series.toLocaleString()}
</div>
</div>
<div className="rounded-lg border bg-card px-3 py-2 text-center">
<span className="text-xs text-muted-foreground">Episodes</span>
<div className="text-base leading-tight font-extrabold">
{counts.episodes.toLocaleString()}
</div>
</div>
</div>
) : null}
{libraries?.length ? (
<Grid container spacing={1}>
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
{libraries.map((library) => (
<Grid key={library.library} size={{ xs: 12, md: 6 }}>
<Card variant="outlined">
<CardContent sx={{ py: 1.1, px: 1.5 }}>
<Stack spacing={0.5}>
<Typography
variant="subtitle2"
sx={{ fontWeight: 700 }}
noWrap
>
{library.library}
</Typography>
<Typography variant="body2" color="text.secondary">
Total {library.total.toLocaleString()} · Movies{" "}
{library.movies.toLocaleString()} · Series{" "}
{library.series.toLocaleString()}
</Typography>
</Stack>
</CardContent>
</Card>
</Grid>
<div
key={library.library}
className="rounded-lg border bg-card px-3 py-2"
>
<div className="flex flex-col gap-1">
<span className="truncate text-sm font-semibold">
{library.library}
</span>
<span className="text-sm text-muted-foreground">
Total {library.total.toLocaleString()} · Movies{" "}
{library.movies.toLocaleString()} · Series{" "}
{library.series.toLocaleString()}
</span>
</div>
</div>
))}
</Grid>
</div>
) : null}
</Stack>
</div>
</SectionCard>
);
}
@@ -149,39 +98,41 @@ export function Applications() {
const [tab, setTab] = useState("jellyfin");
return (
<Stack spacing={2.25}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 800 }}>
Applications
</Typography>
<Typography variant="body2" color="text.secondary">
<div className="flex flex-col gap-4">
<div>
<h1 className="text-lg font-semibold">Applications</h1>
<p className="text-sm text-muted-foreground">
Browse application-specific tools from a compact tabbed workspace.
</Typography>
</Box>
</p>
</div>
<TabbedCard
value={tab}
onChange={setTab}
tabs={[
<Tab key="jellyfin" value="jellyfin" label="Jellyfin" />,
<Tab key="nextcloud" value="nextcloud" label="Nextcloud" />,
<TabsTrigger key="jellyfin" value="jellyfin">
Jellyfin
</TabsTrigger>,
<TabsTrigger key="nextcloud" value="nextcloud">
Nextcloud
</TabsTrigger>,
]}
>
{tab === "jellyfin" ? (
<Stack spacing={2}>
<div className="flex flex-col gap-4">
<JellyfinLibraryStats />
<Media />
</Stack>
</div>
) : (
<Card variant="outlined">
<CardContent sx={{ p: 1.5 }}>
<Alert severity="info">
<div className="rounded-lg border bg-card p-3">
<Alert>
<AlertDescription>
Nextcloud support will be added in a future update.
</Alert>
</CardContent>
</Card>
</AlertDescription>
</Alert>
</div>
)}
</TabbedCard>
</Stack>
</div>
);
}
+245 -241
View File
@@ -1,27 +1,25 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Alert,
Box,
Button,
Card,
CardContent,
Chip,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
FormControl,
FormControlLabel,
FormHelperText,
Grid,
InputLabel,
MenuItem,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
Stack,
Switch,
TextField,
Typography,
} from "@mui/material";
import { useNavigate } from "react-router-dom";
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
useActivity,
useDashboardShortcuts,
@@ -32,6 +30,7 @@ import { useMonitoringSettings } from "../hooks/useSettings";
import type { DashboardShortcut, DashboardShortcutInput } from "../types";
import { NowPlaying } from "../components/NowPlaying";
import { SectionCard } from "../components/SectionCard";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { DialogFooter } from "../components/DialogFooter";
import BackupDashboardWidget from "../components/BackupDashboardWidget";
@@ -71,6 +70,28 @@ function shortcutHref(shortcut: DashboardShortcut): string {
return `/users?user=${encodeURIComponent(shortcut.user_id)}`;
}
function Field({
label,
htmlFor,
helper,
children,
}: {
label: string;
htmlFor: string;
helper?: string;
children: React.ReactNode;
}) {
return (
<div className="flex flex-col gap-1.5">
<Label htmlFor={htmlFor}>{label}</Label>
{children}
{helper ? (
<p className="text-xs text-muted-foreground">{helper}</p>
) : null}
</div>
);
}
function ShortcutDialog({
open,
draft,
@@ -85,129 +106,155 @@ function ShortcutDialog({
onSave: () => void;
}) {
return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
<DialogTitle>{draft.id ? "Edit shortcut" : "New shortcut"}</DialogTitle>
<DialogContent dividers>
<Stack spacing={1.25} sx={{ pt: 0.25 }}>
<Grid container spacing={1}>
<Grid size={{ xs: 12, sm: 6, md: 5 }}>
<TextField
fullWidth
size="small"
label="Label"
value={draft.label}
onChange={(e) => onChange({ ...draft, label: e.target.value })}
/>
</Grid>
<Grid size={{ xs: 12, sm: 3, md: 2 }}>
<TextField
fullWidth
size="small"
label="Icon"
value={draft.icon}
onChange={(e) => onChange({ ...draft, icon: e.target.value })}
helperText="Emoji or glyph"
/>
</Grid>
<Grid size={{ xs: 12, sm: 3, md: 5 }}>
<FormControl fullWidth size="small">
<InputLabel>Type</InputLabel>
<Select
label="Type"
value={draft.shortcut_type}
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) onClose();
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{draft.id ? "Edit shortcut" : "New shortcut"}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-12">
<div className="flex flex-col gap-1.5 sm:col-span-5">
<Field label="Label" htmlFor="shortcut-label">
<Input
id="shortcut-label"
value={draft.label}
onChange={(e) =>
onChange({ ...draft, label: e.target.value })
}
/>
</Field>
</div>
<div className="flex flex-col gap-1.5 sm:col-span-2">
<Field
label="Icon"
htmlFor="shortcut-icon"
helper="Emoji or glyph"
>
<Input
id="shortcut-icon"
value={draft.icon}
onChange={(e) => onChange({ ...draft, icon: e.target.value })}
/>
</Field>
</div>
<div className="flex flex-col gap-1.5 sm:col-span-5">
<Field
label="Type"
htmlFor="shortcut-type"
helper="Website opens a URL. Saved actions jump to a task. Users deep-link."
>
<Select
value={draft.shortcut_type}
onValueChange={(value) =>
onChange({
...draft,
shortcut_type: e.target
.value as DashboardShortcutInput["shortcut_type"],
shortcut_type:
value as DashboardShortcutInput["shortcut_type"],
})
}
>
<MenuItem value="website">Website</MenuItem>
<MenuItem value="action">Saved action</MenuItem>
<MenuItem value="user">User</MenuItem>
<SelectTrigger id="shortcut-type" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="website">Website</SelectItem>
<SelectItem value="action">Saved action</SelectItem>
<SelectItem value="user">User</SelectItem>
</SelectContent>
</Select>
<FormHelperText>
Website opens a URL. Saved actions jump to a task. Users
deep-link.
</FormHelperText>
</FormControl>
</Grid>
</Grid>
</Field>
</div>
</div>
{draft.shortcut_type === "website" ? (
<TextField
fullWidth
size="small"
<Field
label="Website URL"
value={draft.url}
onChange={(e) => onChange({ ...draft, url: e.target.value })}
helperText="https:// is added if missing."
/>
htmlFor="shortcut-url"
helper="https:// is added if missing."
>
<Input
id="shortcut-url"
value={draft.url}
onChange={(e) => onChange({ ...draft, url: e.target.value })}
/>
</Field>
) : draft.shortcut_type === "action" ? (
<Grid container spacing={1.25}>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
fullWidth
size="small"
label="Task ID"
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<Field
label="Task ID"
htmlFor="shortcut-task"
helper="Saved action ID."
>
<Input
id="shortcut-task"
value={draft.task_id}
onChange={(e) =>
onChange({ ...draft, task_id: e.target.value })
}
helperText="Saved action ID."
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
fullWidth
size="small"
label="Machine ID"
</Field>
<Field
label="Machine ID"
htmlFor="shortcut-machine"
helper="Optional machine target."
>
<Input
id="shortcut-machine"
value={draft.machine_id}
onChange={(e) =>
onChange({ ...draft, machine_id: e.target.value })
}
helperText="Optional machine target."
/>
</Grid>
</Grid>
</Field>
</div>
) : (
<TextField
fullWidth
size="small"
<Field
label="User ID"
value={draft.user_id}
onChange={(e) => onChange({ ...draft, user_id: e.target.value })}
helperText="Jellyfin user ID."
/>
)}
<TextField
fullWidth
size="small"
label="Notes"
value={draft.notes}
onChange={(e) => onChange({ ...draft, notes: e.target.value })}
/>
<FormControlLabel
control={
<Switch
checked={draft.enabled}
htmlFor="shortcut-user"
helper="Jellyfin user ID."
>
<Input
id="shortcut-user"
value={draft.user_id}
onChange={(e) =>
onChange({ ...draft, enabled: e.target.checked })
onChange({ ...draft, user_id: e.target.value })
}
/>
}
label="Enabled"
/>
</Stack>
</Field>
)}
<Field label="Notes" htmlFor="shortcut-notes">
<Input
id="shortcut-notes"
value={draft.notes}
onChange={(e) => onChange({ ...draft, notes: e.target.value })}
/>
</Field>
<div className="flex items-center gap-2">
<Switch
id="shortcut-enabled"
checked={draft.enabled}
onCheckedChange={(checked) =>
onChange({ ...draft, enabled: checked })
}
/>
<Label htmlFor="shortcut-enabled">Enabled</Label>
</div>
</div>
<DialogFooter
onCancel={onClose}
onConfirm={onSave}
confirmLabel="Save shortcut"
confirmBusyLabel="Save shortcut"
/>
</DialogContent>
<DialogFooter
onCancel={onClose}
onConfirm={onSave}
confirmLabel="Save shortcut"
confirmBusyLabel="Save shortcut"
/>
</Dialog>
);
}
@@ -237,72 +284,42 @@ function ShortcutCard({
: shortcut.user_id || "No user configured";
return (
<Card variant="outlined" sx={{ height: "100%" }}>
<CardContent sx={{ p: 1.25 }}>
<Stack spacing={1}>
<Stack
direction="row"
spacing={1}
sx={{ justifyContent: "space-between", alignItems: "flex-start" }}
<Card className="h-full">
<CardContent className="flex flex-col gap-3 p-3">
<div className="flex flex-row items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate font-semibold">{shortcut.label}</div>
<div className="truncate text-sm text-muted-foreground">
{subtitle}
</div>
</div>
<div className="flex flex-row items-center gap-2">
{shortcut.icon ? (
<div className="grid size-8 place-items-center rounded-md bg-muted text-lg">
{shortcut.icon}
</div>
) : null}
<Badge variant="outline">{shortcut.shortcut_type}</Badge>
</div>
</div>
{shortcut.notes ? (
<p className="text-xs text-muted-foreground">{shortcut.notes}</p>
) : null}
<div className="flex flex-row flex-wrap gap-2">
<Button
size="sm"
disabled={!shortcut.enabled || !href}
onClick={onOpen}
>
<Box sx={{ minWidth: 0 }}>
<Typography sx={{ fontWeight: 700 }} noWrap>
{shortcut.label}
</Typography>
<Typography variant="body2" color="text.secondary" noWrap>
{subtitle}
</Typography>
</Box>
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
{shortcut.icon ? (
<Box
sx={{
width: 32,
height: 32,
borderRadius: 1.5,
display: "grid",
placeItems: "center",
bgcolor: "action.hover",
fontSize: 18,
}}
>
{shortcut.icon}
</Box>
) : null}
<Chip
size="small"
variant="outlined"
label={shortcut.shortcut_type}
/>
</Stack>
</Stack>
{shortcut.notes ? (
<Typography variant="caption" color="text.secondary">
{shortcut.notes}
</Typography>
) : null}
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap" }}>
<Button
size="small"
variant="contained"
disabled={!shortcut.enabled || !href}
onClick={onOpen}
>
Open
</Button>
<Button size="small" variant="outlined" onClick={onEdit}>
Edit
</Button>
<Button
size="small"
variant="outlined"
color="error"
onClick={onDelete}
>
Delete
</Button>
</Stack>
</Stack>
Open
</Button>
<Button size="sm" variant="outline" onClick={onEdit}>
Edit
</Button>
<Button size="sm" variant="destructive" onClick={onDelete}>
Delete
</Button>
</div>
</CardContent>
</Card>
);
@@ -360,40 +377,41 @@ export function Dashboard() {
};
return (
<Stack spacing={2.25}>
<div className="flex flex-col gap-4">
<SectionCard
title="Shortcuts"
description="Quick links to websites today, with room for action and user shortcuts later."
action={
<Button variant="outlined" onClick={openCreateShortcut}>
<Button variant="outline" onClick={openCreateShortcut}>
Add shortcut
</Button>
}
>
{shortcuts.length ? (
<Grid container spacing={1.25}>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
{shortcuts.map((shortcut) => (
<Grid key={shortcut.id} size={{ xs: 12, md: 6, lg: 4 }}>
<ShortcutCard
shortcut={shortcut}
onOpen={() => {
const href = shortcutHref(shortcut);
if (shortcut.shortcut_type === "website") {
window.open(href, "_blank", "noopener,noreferrer");
} else if (href) {
navigate(href);
}
}}
onEdit={() => openEditShortcut(shortcut)}
onDelete={() => setDeleteShortcutId(shortcut.id)}
/>
</Grid>
<ShortcutCard
key={shortcut.id}
shortcut={shortcut}
onOpen={() => {
const href = shortcutHref(shortcut);
if (shortcut.shortcut_type === "website") {
window.open(href, "_blank", "noopener,noreferrer");
} else if (href) {
navigate(href);
}
}}
onEdit={() => openEditShortcut(shortcut)}
onDelete={() => setDeleteShortcutId(shortcut.id)}
/>
))}
</Grid>
</div>
) : (
<Alert severity="info">
No shortcuts yet. Add a website now, then add action or user
shortcuts later.
<Alert>
<AlertDescription>
No shortcuts yet. Add a website now, then add action or user
shortcuts later.
</AlertDescription>
</Alert>
)}
</SectionCard>
@@ -403,25 +421,23 @@ export function Dashboard() {
description="Live sessions and idle users from Jellyfin."
action={
jellyfinMachines.length > 1 ? (
<FormControl size="small" sx={{ minWidth: 180 }}>
<Select
value={selectedJellyfinId}
onChange={(e) => setActiveJellyfinMachineId(e.target.value)}
sx={{ fontSize: "0.8rem" }}
>
<Select
value={selectedJellyfinId}
onValueChange={(value) => setActiveJellyfinMachineId(value)}
>
<SelectTrigger className="h-8 w-[180px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{jellyfinMachines.map((m) => (
<MenuItem key={m.id} value={m.id}>
<SelectItem key={m.id} value={m.id}>
{m.name}
</MenuItem>
</SelectItem>
))}
</Select>
</FormControl>
</SelectContent>
</Select>
) : jellyfinMachines.length === 1 ? (
<Chip
label={jellyfinMachines[0].name}
size="small"
variant="outlined"
/>
<Badge variant="outline">{jellyfinMachines[0].name}</Badge>
) : null
}
>
@@ -444,31 +460,19 @@ export function Dashboard() {
onClose={() => setShortcutDialogOpen(false)}
onSave={saveShortcutDraft}
/>
<Dialog
<ConfirmDialog
open={Boolean(deleteShortcutId)}
onClose={() => setDeleteShortcutId(null)}
fullWidth
maxWidth="xs"
>
<DialogTitle>Delete shortcut?</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary">
This cannot be undone. The shortcut will be removed from the
dashboard.
</Typography>
</DialogContent>
<DialogFooter
onCancel={() => setDeleteShortcutId(null)}
onConfirm={() => {
if (deleteShortcutId) {
deleteShortcut.mutate(deleteShortcutId);
}
setDeleteShortcutId(null);
}}
confirmLabel="Delete"
confirmColor="error"
/>
</Dialog>
</Stack>
title="Delete shortcut?"
message="This cannot be undone. The shortcut will be removed from the dashboard."
confirmLabel="Delete"
onCancel={() => setDeleteShortcutId(null)}
onConfirm={() => {
if (deleteShortcutId) {
deleteShortcut.mutate(deleteShortcutId);
}
setDeleteShortcutId(null);
}}
/>
</div>
);
}
@@ -0,0 +1,67 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { Applications } from "../Applications";
// Applications still embeds the MUI Media child (migrated in slice 7). Mock it
// so this slice-4 test stays focused on the migrated Applications shell and
// does not pull the still-MUI DataGrid into the jsdom render.
vi.mock("../Media", () => ({
Media: () => <div data-testid="media-child">Media</div>,
}));
vi.mock("react-router-dom", () => ({
useSearchParams: () => [new URLSearchParams(), vi.fn()],
}));
vi.mock("../../hooks/useSettings", () => ({
useMonitoringSettings: () => ({
data: [
{
id: "m1",
name: "Main",
enabled: true,
services: ["jellyfin"],
},
],
}),
}));
vi.mock("../../hooks/useDashboard", () => ({
useCounts: () => ({
data: { movies: 10, series: 5, episodes: 100 },
}),
useLibraries: () => ({
data: [
{ library: "Movies", total: 10, movies: 10, series: 0 },
{ library: "Shows", total: 5, movies: 0, series: 5 },
],
}),
}));
describe("Applications", () => {
it("renders the Jellyfin library counts grid and tabs, and keeps the Media child", () => {
render(<Applications />);
// Library stats header.
expect(screen.getByText("Library stats")).toBeInTheDocument();
// Counts: Total = 10 + 5 + 100 = 115, plus the per-type counts.
expect(screen.getByText("115")).toBeInTheDocument();
expect(screen.getByText("Episodes")).toBeInTheDocument();
// Library rows render their per-library totals (unique strings).
expect(
screen.getByText(/Total 10 · Movies 10 · Series 0/),
).toBeInTheDocument();
expect(
screen.getByText(/Total 5 · Movies 0 · Series 5/),
).toBeInTheDocument();
// Tabs present.
expect(screen.getByRole("tab", { name: "Jellyfin" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Nextcloud" })).toBeInTheDocument();
// The still-MUI Media child is rendered unchanged inside the Jellyfin tab.
expect(screen.getByTestId("media-child")).toBeInTheDocument();
});
});
@@ -0,0 +1,110 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Dashboard } from "../Dashboard";
import type { DashboardShortcut } from "../../types";
// Stub the composed widgets so the test exercises Dashboard's own behavior
// (shortcut CRUD) without rendering the session panel or the backup query.
vi.mock("../../components/NowPlaying", () => ({
NowPlaying: () => <div data-testid="now-playing-stub" />,
}));
vi.mock("../../components/BackupDashboardWidget", () => ({
default: () => <div data-testid="backup-widget-stub" />,
}));
const navigate = vi.fn();
vi.mock("react-router-dom", () => ({
useNavigate: () => navigate,
}));
vi.mock("../../hooks/useSettings", () => ({
useMonitoringSettings: () => ({ data: [] }),
}));
const saveShortcutMutate = vi.fn().mockResolvedValue({});
const deleteShortcutMutate = vi.fn();
let shortcuts: DashboardShortcut[] = [];
vi.mock("../../hooks/useDashboard", () => ({
useActivity: () => ({ data: undefined }),
useDashboardShortcuts: () => ({ data: shortcuts }),
useSaveDashboardShortcut: () => ({ mutateAsync: saveShortcutMutate }),
useDeleteDashboardShortcut: () => ({ mutate: deleteShortcutMutate }),
}));
function websiteShortcut(
overrides: Partial<DashboardShortcut> = {},
): DashboardShortcut {
return {
id: "s1",
label: "Wiki",
shortcut_type: "website",
enabled: true,
icon: "📚",
url: "example.com",
task_id: "",
machine_id: "",
user_id: "",
notes: "Team wiki",
created_at: 0,
updated_at: 0,
...overrides,
} as DashboardShortcut;
}
beforeEach(() => {
navigate.mockReset();
saveShortcutMutate.mockClear();
deleteShortcutMutate.mockClear();
shortcuts = [];
});
describe("Dashboard", () => {
it("shows the empty-state alert when there are no shortcuts", () => {
render(<Dashboard />);
expect(screen.getByText(/No shortcuts yet/)).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Add shortcut" }),
).toBeInTheDocument();
});
it("renders a shortcut card and deletes it via the confirm dialog", async () => {
shortcuts = [websiteShortcut()];
render(<Dashboard />);
expect(screen.getByText("Wiki")).toBeInTheDocument();
// Open the delete confirm.
await userEvent.click(screen.getByRole("button", { name: "Delete" }));
expect(screen.getByText("Delete shortcut?")).toBeInTheDocument();
// Confirm deletion -> delete mutation fires with the shortcut id.
const dialogs = screen.getAllByRole("button", { name: "Delete" });
// The card "Delete" plus the confirm "Delete"; confirm is the last one.
await userEvent.click(dialogs[dialogs.length - 1]);
expect(deleteShortcutMutate).toHaveBeenCalledTimes(1);
expect(deleteShortcutMutate).toHaveBeenCalledWith("s1");
});
it("creates a shortcut via the dialog and saves it", async () => {
render(<Dashboard />);
await userEvent.click(screen.getByRole("button", { name: "Add shortcut" }));
// Edit dialog opens in "New shortcut" mode.
expect(screen.getByText("New shortcut")).toBeInTheDocument();
// Fill the label and save.
await userEvent.type(screen.getByLabelText("Label"), "Grafana");
await userEvent.click(
screen.getByRole("button", { name: "Save shortcut" }),
);
expect(saveShortcutMutate).toHaveBeenCalledTimes(1);
const saved = saveShortcutMutate.mock.calls[0][0];
expect(saved.label).toBe("Grafana");
expect(saved.shortcut_type).toBe("website");
});
});