Named dashboards + pinned service links (Slice 10)
Wire up named dashboards end-to-end. The /d/:slug route (already
referenced by useDashboards-driven nav entries from slice 4) now
renders NamedDashboardPage instead of 404ing.
Backend: GET /api/dashboards/slug/:slug resolves a dashboard by slug
(404 when not found). The store method existed from slice 3; only the
router endpoint was missing.
NamedDashboardPage: renders a named dashboard's payload -- an ordered
list of inline items with a type discriminator. This slice ships
'link' items (PinnedServiceLink -> navigates to /services/:type/:id).
Full widget composition on named dashboards is a follow-up; the main
Dashboard keeps the rich WidgetConfigDialog.
PinnedServiceLink: card component (lucide icon + label) navigating to
a service page or specific tab.
Dashboard management UI on the Services page (DashboardManagementCard):
list existing dashboards with reorder/delete, create via dialog
(label -> auto-slug), add pinned service links per dashboard (label +
enabled-service dropdown). Placed on Services (the admin hub) rather
than Settings to avoid an extra nav trip.
Dashboard payload model: inline items ({ items: [{ type: 'link', label,
target }] }) -- self-contained, no separate widget-instance fetch
needed. The type discriminator allows future widget items without
breaking existing payloads.
Tests: NamedDashboardPage (renders links, not-found state),
PinnedServiceLink (renders + navigates). 112 frontend tests pass (+6);
271 backend tests pass (no regression); lint/build green both sides.
Refs openspec/changes/services-as-hub-ia/ (spec R5, tasks slice 10).
This commit is contained in:
@@ -17,6 +17,14 @@ def list_dashboards(store: SettingsStore = Depends(get_settings_store)) -> list[
|
|||||||
return [NamedDashboard(**row) for row in rows]
|
return [NamedDashboard(**row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/slug/{slug}")
|
||||||
|
def get_dashboard_by_slug(slug: str, store: SettingsStore = Depends(get_settings_store)) -> NamedDashboard:
|
||||||
|
row = store.get_dashboard_by_slug(slug)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Dashboard not found")
|
||||||
|
return NamedDashboard(**row)
|
||||||
|
|
||||||
|
|
||||||
@router.post("")
|
@router.post("")
|
||||||
def create_dashboard(body: NamedDashboardInput, store: SettingsStore = Depends(get_settings_store)) -> NamedDashboard:
|
def create_dashboard(body: NamedDashboardInput, store: SettingsStore = Depends(get_settings_store)) -> NamedDashboard:
|
||||||
row = store.upsert_dashboard(body.model_dump())
|
row = store.upsert_dashboard(body.model_dump())
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { AuthProvider, useAuth } from "react-oidc-context";
|
import { AuthProvider, useAuth } from "react-oidc-context";
|
||||||
import { Dashboard } from "./pages/Dashboard";
|
import { Dashboard } from "./pages/Dashboard";
|
||||||
|
import { NamedDashboardPage } from "./pages/NamedDashboardPage";
|
||||||
import { Settings } from "./pages/Settings";
|
import { Settings } from "./pages/Settings";
|
||||||
import { ServicePage } from "./pages/ServicePage";
|
import { ServicePage } from "./pages/ServicePage";
|
||||||
import { ServiceTypePage } from "./pages/ServiceTypePage";
|
import { ServiceTypePage } from "./pages/ServiceTypePage";
|
||||||
@@ -474,6 +475,7 @@ function AppInner() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<AuthenticatedApp />}>
|
<Route element={<AuthenticatedApp />}>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/d/:slug" element={<NamedDashboardPage />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
<Route path="/services" element={<ServicesPage />} />
|
<Route path="/services" element={<ServicesPage />} />
|
||||||
<Route
|
<Route
|
||||||
@@ -501,6 +503,7 @@ function AppInner() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/d/:slug" element={<NamedDashboardPage />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
<Route path="/services" element={<ServicesPage />} />
|
<Route path="/services" element={<ServicesPage />} />
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ export async function fetchDashboards(): Promise<NamedDashboard[]> {
|
|||||||
return get<NamedDashboard[]>("/api/dashboards");
|
return get<NamedDashboard[]>("/api/dashboards");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchDashboardBySlug(
|
||||||
|
slug: string,
|
||||||
|
): Promise<NamedDashboard> {
|
||||||
|
return get<NamedDashboard>(
|
||||||
|
`/api/dashboards/slug/${encodeURIComponent(slug)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function createDashboard(
|
export async function createDashboard(
|
||||||
input: NamedDashboardInput,
|
input: NamedDashboardInput,
|
||||||
): Promise<NamedDashboard> {
|
): Promise<NamedDashboard> {
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Boxes, ChevronRight, type LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pinned service link rendered on named dashboards. A card-shaped shortcut
|
||||||
|
* that navigates to a service page (or a specific tab via query param).
|
||||||
|
*
|
||||||
|
* The `target` is a route path like `/services/jellyfin/svc-1` or
|
||||||
|
* `/services/ssh_tasks/svc-2?tab=Files`.
|
||||||
|
*/
|
||||||
|
export interface PinnedServiceLinkProps {
|
||||||
|
label: string;
|
||||||
|
target: string;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PinnedServiceLink({
|
||||||
|
label,
|
||||||
|
target,
|
||||||
|
icon: Icon = Boxes,
|
||||||
|
className,
|
||||||
|
}: PinnedServiceLinkProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(target)}
|
||||||
|
className={cn(
|
||||||
|
"mobile-touch-target group flex min-h-16 w-full items-center justify-between rounded-lg border border-border bg-card p-4 text-left transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Icon className="size-5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium text-foreground">{label}</span>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="size-4 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static helper: build a target path for a pinned service link.
|
||||||
|
* Returns `/services/:type/:id` or with a `?tab=` suffix when provided.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
|
export function serviceLinkTarget(
|
||||||
|
serviceType: string,
|
||||||
|
serviceId: string,
|
||||||
|
tab?: string,
|
||||||
|
): string {
|
||||||
|
const base = `/services/${serviceType}/${serviceId}`;
|
||||||
|
return tab ? `${base}?tab=${tab}` : base;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { MemoryRouter, Routes, Route } from "react-router-dom";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { PinnedServiceLink } from "../PinnedServiceLink";
|
||||||
|
|
||||||
|
function renderLink() {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter initialEntries={["/"]}>
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<PinnedServiceLink
|
||||||
|
label="My Jellyfin"
|
||||||
|
target="/services/jellyfin/svc-1"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/services/jellyfin/svc-1"
|
||||||
|
element={<div>target page</div>}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PinnedServiceLink", () => {
|
||||||
|
it("renders the label", () => {
|
||||||
|
renderLink();
|
||||||
|
expect(screen.getByText("My Jellyfin")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("navigates to the target on click", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderLink();
|
||||||
|
await user.click(screen.getByText("My Jellyfin"));
|
||||||
|
expect(screen.getByText("target page")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import {
|
import {
|
||||||
createDashboard,
|
createDashboard,
|
||||||
deleteDashboard,
|
deleteDashboard,
|
||||||
|
fetchDashboardBySlug,
|
||||||
fetchDashboards,
|
fetchDashboards,
|
||||||
updateDashboard,
|
updateDashboard,
|
||||||
type NamedDashboardInput,
|
type NamedDashboardInput,
|
||||||
@@ -15,6 +16,15 @@ export function useDashboards() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useDashboardBySlug(slug: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["dashboards", "slug", slug],
|
||||||
|
queryFn: () => fetchDashboardBySlug(slug!),
|
||||||
|
enabled: !!slug,
|
||||||
|
staleTime: 30 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useSaveDashboard() {
|
export function useSaveDashboard() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { Boxes } from "lucide-react";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { useDashboardBySlug } from "../hooks/useDashboards";
|
||||||
|
import { PinnedServiceLink } from "../components/PinnedServiceLink";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload model for named dashboards (design choice: inline items, not widget
|
||||||
|
* instance ids). The payload stores an ordered list of items:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* { items: DashboardItem[] }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Where `DashboardItem` is either a pinned service link (this slice) or a
|
||||||
|
* future widget reference (follow-up). Widget composition on named dashboards
|
||||||
|
* is deferred — the main Dashboard already has the rich widget config dialog.
|
||||||
|
*/
|
||||||
|
interface LinkItem {
|
||||||
|
type: "link";
|
||||||
|
label: string;
|
||||||
|
target: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DashboardItem = LinkItem;
|
||||||
|
|
||||||
|
function parseItems(payload: Record<string, unknown>): DashboardItem[] {
|
||||||
|
const items = payload.items;
|
||||||
|
if (!Array.isArray(items)) return [];
|
||||||
|
return items.filter(
|
||||||
|
(item): item is LinkItem =>
|
||||||
|
typeof item === "object" &&
|
||||||
|
item !== null &&
|
||||||
|
item.type === "link" &&
|
||||||
|
typeof item.label === "string" &&
|
||||||
|
typeof item.target === "string",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NamedDashboardPage() {
|
||||||
|
const { slug = "" } = useParams<{ slug: string }>();
|
||||||
|
const { data: dashboard, isLoading, isError } = useDashboardBySlug(slug);
|
||||||
|
|
||||||
|
const items = useMemo(
|
||||||
|
() => parseItems(dashboard?.payload ?? {}),
|
||||||
|
[dashboard?.payload],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <Skeleton className="h-32 w-full" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !dashboard) {
|
||||||
|
return (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
Dashboard not found. It may have been deleted or the link is invalid.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">{dashboard.label}</h2>
|
||||||
|
</div>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
This dashboard has no shortcuts yet. Add pinned service links from
|
||||||
|
the dashboard management panel on the Services page.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<PinnedServiceLink
|
||||||
|
key={`${item.target}-${index}`}
|
||||||
|
label={item.label}
|
||||||
|
target={item.target}
|
||||||
|
icon={Boxes}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,13 +12,31 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { ExternalLink, Plus, Trash2 } from "lucide-react";
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
ExternalLink,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
useDeleteServiceInstance,
|
useDeleteServiceInstance,
|
||||||
useSaveServiceInstance,
|
useSaveServiceInstance,
|
||||||
useServiceInstances,
|
useServiceInstances,
|
||||||
} from "../hooks/useServices";
|
} from "../hooks/useServices";
|
||||||
import { useServiceTypes } from "../hooks/useServices";
|
import { useServiceTypes } from "../hooks/useServices";
|
||||||
|
import {
|
||||||
|
useDashboards,
|
||||||
|
useDeleteDashboard,
|
||||||
|
useSaveDashboard,
|
||||||
|
} from "../hooks/useDashboards";
|
||||||
import type {
|
import type {
|
||||||
SecretFieldInfo,
|
SecretFieldInfo,
|
||||||
ServiceInstance,
|
ServiceInstance,
|
||||||
@@ -29,6 +47,8 @@ import { SectionCard } from "../components/SectionCard";
|
|||||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||||
import { DialogFooter } from "../components/DialogFooter";
|
import { DialogFooter } from "../components/DialogFooter";
|
||||||
import { getServiceBinding } from "../integrations/registry";
|
import { getServiceBinding } from "../integrations/registry";
|
||||||
|
import { serviceLinkTarget } from "../components/PinnedServiceLink";
|
||||||
|
import type { NamedDashboardInput } from "../api/dashboards";
|
||||||
|
|
||||||
interface CreateDraft {
|
interface CreateDraft {
|
||||||
serviceType: string;
|
serviceType: string;
|
||||||
@@ -257,6 +277,239 @@ function CreateServiceDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Named dashboards management (Slice 10.3) ---
|
||||||
|
|
||||||
|
function DashboardManagementCard() {
|
||||||
|
const { data: dashboards = [] } = useDashboards();
|
||||||
|
const saveDashboard = useSaveDashboard();
|
||||||
|
const deleteDashboard = useDeleteDashboard();
|
||||||
|
const { data: services = [] } = useServiceInstances();
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [newLabel, setNewLabel] = useState("");
|
||||||
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||||
|
const [linkDashId, setLinkDashId] = useState<string | null>(null);
|
||||||
|
const [linkLabel, setLinkLabel] = useState("");
|
||||||
|
const [linkTarget, setLinkTarget] = useState("");
|
||||||
|
|
||||||
|
const enabledServices = useMemo(
|
||||||
|
() => services.filter((s) => s.enabled),
|
||||||
|
[services],
|
||||||
|
);
|
||||||
|
|
||||||
|
function createDashboard() {
|
||||||
|
if (!newLabel.trim()) return;
|
||||||
|
const input: NamedDashboardInput = {
|
||||||
|
label: newLabel.trim(),
|
||||||
|
sort_order: dashboards.length,
|
||||||
|
payload: { items: [] },
|
||||||
|
};
|
||||||
|
saveDashboard.mutate(input);
|
||||||
|
setNewLabel("");
|
||||||
|
setCreateOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reorder(dashId: string, direction: -1 | 1) {
|
||||||
|
const sorted = [...dashboards].sort((a, b) => a.sort_order - b.sort_order);
|
||||||
|
const idx = sorted.findIndex((d) => d.id === dashId);
|
||||||
|
const swapIdx = idx + direction;
|
||||||
|
if (swapIdx < 0 || swapIdx >= sorted.length) return;
|
||||||
|
const a = sorted[idx];
|
||||||
|
const b = sorted[swapIdx];
|
||||||
|
saveDashboard.mutate({
|
||||||
|
...a,
|
||||||
|
sort_order: b.sort_order,
|
||||||
|
payload: a.payload,
|
||||||
|
});
|
||||||
|
saveDashboard.mutate({
|
||||||
|
...b,
|
||||||
|
sort_order: a.sort_order,
|
||||||
|
payload: b.payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPinnedLink() {
|
||||||
|
if (!linkDashId || !linkLabel.trim() || !linkTarget.trim()) return;
|
||||||
|
const dash = dashboards.find((d) => d.id === linkDashId);
|
||||||
|
if (!dash) return;
|
||||||
|
const items = Array.isArray(dash.payload.items)
|
||||||
|
? (dash.payload.items as unknown[])
|
||||||
|
: [];
|
||||||
|
items.push({ type: "link", label: linkLabel.trim(), target: linkTarget });
|
||||||
|
saveDashboard.mutate({
|
||||||
|
id: dash.id,
|
||||||
|
label: dash.label,
|
||||||
|
sort_order: dash.sort_order,
|
||||||
|
payload: { items },
|
||||||
|
});
|
||||||
|
setLinkLabel("");
|
||||||
|
setLinkTarget("");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionCard
|
||||||
|
title="Dashboards"
|
||||||
|
description="Named dashboards appear in the top nav. Compose them from pinned service links."
|
||||||
|
action={
|
||||||
|
<Button variant="outline" onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
New dashboard
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{dashboards.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No named dashboards yet. Create one to add pinned service links.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{[...dashboards]
|
||||||
|
.sort((a, b) => a.sort_order - b.sort_order)
|
||||||
|
.map((d, idx, arr) => (
|
||||||
|
<div key={d.id} className="rounded border p-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{d.label}</span>
|
||||||
|
<Badge variant="outline">/{d.slug}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
disabled={idx === 0}
|
||||||
|
onClick={() => reorder(d.id, -1)}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
disabled={idx === arr.length - 1}
|
||||||
|
onClick={() => reorder(d.id, 1)}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-destructive"
|
||||||
|
onClick={() => setDeleteId(d.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
{Array.isArray(d.payload.items) &&
|
||||||
|
(d.payload.items as unknown[]).length > 0 ? (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{(d.payload.items as unknown[]).length} pinned link(s)
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
No links yet
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap items-end gap-2">
|
||||||
|
<Field label="Link label" htmlFor={`link-label-${d.id}`}>
|
||||||
|
<Input
|
||||||
|
id={`link-label-${d.id}`}
|
||||||
|
className="w-40"
|
||||||
|
placeholder="My Jellyfin"
|
||||||
|
value={linkDashId === d.id ? linkLabel : ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
setLinkDashId(d.id);
|
||||||
|
setLinkLabel(e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor={`link-target-${d.id}`}>Service</Label>
|
||||||
|
<Select
|
||||||
|
value={linkDashId === d.id ? linkTarget : ""}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setLinkDashId(d.id);
|
||||||
|
setLinkTarget(v);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
id={`link-target-${d.id}`}
|
||||||
|
className="w-56"
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="Pick a service" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{enabledServices.map((s) => (
|
||||||
|
<SelectItem
|
||||||
|
key={s.id}
|
||||||
|
value={serviceLinkTarget(s.service_type, s.id)}
|
||||||
|
>
|
||||||
|
{s.name} ({s.service_type})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={
|
||||||
|
linkDashId !== d.id ||
|
||||||
|
!linkLabel.trim() ||
|
||||||
|
!linkTarget.trim()
|
||||||
|
}
|
||||||
|
onClick={addPinnedLink}
|
||||||
|
>
|
||||||
|
Add link
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>New dashboard</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<Field label="Label" htmlFor="dash-label">
|
||||||
|
<Input
|
||||||
|
id="dash-label"
|
||||||
|
placeholder="Storage overview"
|
||||||
|
value={newLabel}
|
||||||
|
onChange={(e) => setNewLabel(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") createDashboard();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<DialogFooter
|
||||||
|
onCancel={() => setCreateOpen(false)}
|
||||||
|
onConfirm={createDashboard}
|
||||||
|
confirmLabel="Create"
|
||||||
|
confirmDisabled={!newLabel.trim() || saveDashboard.isPending}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(deleteId)}
|
||||||
|
title="Delete dashboard?"
|
||||||
|
message="This removes the named dashboard and its pinned links."
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onCancel={() => setDeleteId(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (deleteId) deleteDashboard.mutate(deleteId);
|
||||||
|
setDeleteId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ServicesPage() {
|
export function ServicesPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: services = [] } = useServiceInstances();
|
const { data: services = [] } = useServiceInstances();
|
||||||
@@ -352,6 +605,8 @@ export function ServicesPage() {
|
|||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
|
<DashboardManagementCard />
|
||||||
|
|
||||||
<CreateServiceDialog
|
<CreateServiceDialog
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
onClose={() => setCreateOpen(false)}
|
onClose={() => setCreateOpen(false)}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
|
import { NamedDashboardPage } from "../NamedDashboardPage";
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useDashboards", () => ({
|
||||||
|
useDashboardBySlug: vi.fn(() => ({ data: undefined, isLoading: true })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useDashboardBySlug } from "../../hooks/useDashboards";
|
||||||
|
|
||||||
|
function renderPage(slug: string) {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter initialEntries={[`/d/${slug}`]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/d/:slug" element={<NamedDashboardPage />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("NamedDashboardPage", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders loading state", () => {
|
||||||
|
vi.mocked(useDashboardBySlug).mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
isLoading: true,
|
||||||
|
isError: false,
|
||||||
|
} as never);
|
||||||
|
renderPage("storage");
|
||||||
|
// Skeleton renders during load.
|
||||||
|
expect(document.querySelector(".h-32")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders 404 when dashboard not found", () => {
|
||||||
|
vi.mocked(useDashboardBySlug).mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
isLoading: false,
|
||||||
|
isError: true,
|
||||||
|
} as never);
|
||||||
|
renderPage("nonexistent");
|
||||||
|
expect(screen.getByText(/Dashboard not found/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders pinned links for a known dashboard", () => {
|
||||||
|
vi.mocked(useDashboardBySlug).mockReturnValue({
|
||||||
|
data: {
|
||||||
|
id: "d1",
|
||||||
|
label: "Storage",
|
||||||
|
slug: "storage",
|
||||||
|
sort_order: 0,
|
||||||
|
payload: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
type: "link",
|
||||||
|
label: "My Jellyfin",
|
||||||
|
target: "/services/jellyfin/svc-1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
created_at: 1,
|
||||||
|
updated_at: 1,
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
} as never);
|
||||||
|
renderPage("storage");
|
||||||
|
expect(screen.getByText("Storage")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("My Jellyfin")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders empty state when dashboard has no items", () => {
|
||||||
|
vi.mocked(useDashboardBySlug).mockReturnValue({
|
||||||
|
data: {
|
||||||
|
id: "d2",
|
||||||
|
label: "Empty",
|
||||||
|
slug: "empty",
|
||||||
|
sort_order: 0,
|
||||||
|
payload: {},
|
||||||
|
created_at: 1,
|
||||||
|
updated_at: 1,
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
} as never);
|
||||||
|
renderPage("empty");
|
||||||
|
expect(screen.getByText("Empty")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/no shortcuts yet/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user