217 lines
10 KiB
TypeScript
217 lines
10 KiB
TypeScript
// Dashboard view model (todo §1): the gated "/dashboard" app-shell "People" list. Pure — turns a
|
|
// request URL into the data the building-block partials render, wiring the §1 helpers end-to-end:
|
|
// parseListQuery → filter/sort/paginate a mock dataset → composeNav. Mock data stands in for
|
|
// upstream until §4; the filter form, sortable headers and pager all round-trip the URL (zero-JS).
|
|
|
|
import { adminSection } from "./admin-nav.ts";
|
|
import type { User } from "./context.ts";
|
|
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
|
|
import { composeNav, type NavNode, type NavOverride } from "./nav.ts";
|
|
import type { Plugin } from "./plugin.ts";
|
|
import { parseListQuery } from "./list-query.ts";
|
|
import { paginate } from "./paginate.ts";
|
|
import { buildShellContext } from "./shell-context.ts";
|
|
|
|
interface Person {
|
|
id: string;
|
|
email: string;
|
|
initials: string;
|
|
lastActive: string;
|
|
name: string;
|
|
role: string;
|
|
status: string;
|
|
team: string;
|
|
}
|
|
|
|
const FIRST = ["Avery", "Blair", "Casey", "Devon", "Emerson", "Finley", "Gray", "Harper", "Iris", "Jordan", "Kai", "Logan", "Morgan", "Noor", "Oakley", "Parker", "Quinn", "Riley", "Sage", "Tatum", "Uma", "Vance", "Wren", "Yuki", "Zarah", "Aria", "Beau", "Cleo", "Dane", "Esme"];
|
|
const LAST = ["Kline", "Mora", "Nguyen", "Patel", "Rossi", "Stone", "Vega", "Wu", "Ahmed", "Boyd", "Cruz", "Diaz", "Engel", "Frost", "Gomez", "Hale", "Ito", "Jain", "Khan", "Lund", "Marsh", "Novak", "Ortiz", "Pace", "Reed", "Sato", "Tran", "Udall", "Voss", "Webb"];
|
|
const ROLES = ["Admin", "Member", "Viewer"];
|
|
const TEAMS = ["Engineering", "Design", "Sales", "Support"];
|
|
const STATUSES = ["active", "invited", "suspended"];
|
|
const ACTIVE = ["2m ago", "1h ago", "3h ago", "Yesterday", "2d ago", "Last week"];
|
|
const TONE: Record<string, string> = { active: "pos", invited: "info", suspended: "warn" };
|
|
|
|
// Cycle a fixed, non-empty list by index (parallel mock arrays — always in range).
|
|
const at = <T>(arr: T[], i: number): T => arr[i % arr.length] as T;
|
|
|
|
const PEOPLE: Person[] = FIRST.map((first, i) => {
|
|
const last = LAST[i] as string;
|
|
return {
|
|
id: `${first}-${last}`.toLowerCase(),
|
|
email: `${first}.${last}`.toLowerCase() + "@example.com",
|
|
initials: first.charAt(0) + last.charAt(0),
|
|
lastActive: at(ACTIVE, i),
|
|
name: `${first} ${last}`,
|
|
role: at(ROLES, i),
|
|
status: at(STATUSES, i),
|
|
team: at(TEAMS, i),
|
|
};
|
|
});
|
|
|
|
const DEFAULT_PAGE_SIZE = 12;
|
|
const PAGE_SIZES = [12, 25, 50];
|
|
// Sortable columns → the value to compare on (also gates `?sort=` to known fields).
|
|
const SORT: Record<string, (p: Person) => string> = {
|
|
email: (p) => p.email, name: (p) => p.name, role: (p) => p.role, status: (p) => p.status, team: (p) => p.team,
|
|
};
|
|
const COLUMNS: { key: string; label: string; sortable?: boolean }[] = [
|
|
{ key: "name", label: "Name", sortable: true },
|
|
{ key: "email", label: "Email", sortable: true },
|
|
{ key: "role", label: "Role", sortable: true },
|
|
{ key: "team", label: "Team", sortable: true },
|
|
{ key: "status", label: "Status", sortable: true },
|
|
{ key: "lastActive", label: "Last active" },
|
|
];
|
|
|
|
interface State { page: number; pageSize: number; q: string; sort: string | null; status: string; team: string; }
|
|
|
|
const cap = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1);
|
|
|
|
// Canonical list URL from the current state plus per-link overrides; omits defaults so links stay tidy.
|
|
function href(state: State, overrides: Partial<State> = {}): string {
|
|
const s = { ...state, ...overrides };
|
|
const p = new URLSearchParams();
|
|
if (s.q) p.set("q", s.q);
|
|
if (s.status && s.status !== "all") p.set("status", s.status);
|
|
if (s.team) p.set("team", s.team);
|
|
if (s.sort) p.set("sort", s.sort);
|
|
if (s.page > 1) p.set("page", String(s.page));
|
|
if (s.pageSize !== DEFAULT_PAGE_SIZE) p.set("pageSize", String(s.pageSize));
|
|
const qs = p.toString();
|
|
return qs ? `?${qs}` : "?";
|
|
}
|
|
|
|
export function buildDashboardModel(url: URL | URLSearchParams | string, roles: string[] = [], menu: MenuConfig = DEFAULT_MENU, csrfToken = "", user: User | null = null, plugins: Plugin[] = []) {
|
|
const query = parseListQuery(url, { defaultPageSize: DEFAULT_PAGE_SIZE });
|
|
const status = query.filters.status?.[0] ?? "all";
|
|
const team = query.filters.team?.[0] ?? "";
|
|
const sort = query.sort && SORT[query.sort.field] ? query.sort : null; // ignore unknown fields
|
|
const sortToken = sort ? (sort.dir === "desc" ? `-${sort.field}` : sort.field) : null;
|
|
const needle = query.q.toLowerCase();
|
|
|
|
let list = PEOPLE.filter((p) =>
|
|
(!needle || p.name.toLowerCase().includes(needle) || p.email.toLowerCase().includes(needle)) &&
|
|
(status === "all" || p.status === status) &&
|
|
(!team || p.team === team));
|
|
if (sort) {
|
|
const get = SORT[sort.field] as (p: Person) => string; // gated to known fields above
|
|
const dir = sort.dir === "desc" ? -1 : 1;
|
|
list = [...list].sort((a, b) => get(a).localeCompare(get(b)) * dir);
|
|
}
|
|
|
|
const page = paginate(list.length, query.page, query.pageSize, { boundaries: 1, siblings: 1 });
|
|
const start = (page.page - 1) * page.pageSize;
|
|
const rows = list.slice(start, start + page.pageSize);
|
|
const state: State = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken, status, team };
|
|
|
|
return {
|
|
filterBar: filterBar(state),
|
|
nav: nav(roles, menu.override, plugins),
|
|
pagination: pagination(state, page),
|
|
shell: buildShellContext({
|
|
breadcrumbs: [{ href: "?", label: "Directory" }, { label: "People" }],
|
|
csrfToken, // hidden field for the shell's Sign-out POST form (§4)
|
|
menu,
|
|
title: "People",
|
|
user, // real signed-in identity (§4); anonymous ⇒ Guest
|
|
}),
|
|
table: table(rows, state, sort),
|
|
};
|
|
}
|
|
|
|
export type DashboardModel = ReturnType<typeof buildDashboardModel>;
|
|
|
|
// Sidebar: the demo "Directory" fragment, then each discovered plugin's own nav fragment (so a
|
|
// plugin is reachable from "/"; gated nodes stay invisible to non-admins), then the gated admin
|
|
// section. composeNav applies the central override + per-user role filter.
|
|
function nav(roles: string[], override: NavOverride, plugins: Plugin[]): NavNode[] {
|
|
const pluginFragments = plugins.filter((p) => p.nav?.length).map((p) => p.nav as NavNode[]);
|
|
return composeNav([[
|
|
{ count: PEOPLE.length, current: true, href: "/dashboard", icon: "i-users", id: "people", label: "People" },
|
|
{ href: "#teams", icon: "i-grid", id: "teams", label: "Teams" },
|
|
{ children: [
|
|
{ href: "#activity", id: "activity", label: "Activity" },
|
|
{ href: "#exports", id: "exports", label: "Exports" },
|
|
], icon: "i-chart", id: "reports", label: "Reports", open: true },
|
|
{ href: "#settings", icon: "i-gear", id: "settings", label: "Settings", permission: "admin" },
|
|
], ...pluginFragments, [
|
|
adminSection(), // built-in Users/Groups/Roles screens; gated → invisible to non-admins
|
|
]], override, roles);
|
|
}
|
|
|
|
function table(rows: Person[], state: State, sort: { dir: "asc" | "desc"; field: string } | null) {
|
|
return {
|
|
actions: true,
|
|
caption: "People",
|
|
columns: COLUMNS.map((c) => {
|
|
if (!c.sortable) return { label: c.label };
|
|
const dir = sort && sort.field === c.key ? sort.dir : undefined;
|
|
const next = dir === "asc" ? `-${c.key}` : c.key; // asc→desc, else→asc
|
|
return { href: href(state, { page: 1, sort: next }), label: c.label, sort: dir, sortable: true };
|
|
}),
|
|
rows: rows.map((p) => ({
|
|
cells: [
|
|
{ user: { initials: p.initials, name: p.name } },
|
|
p.email,
|
|
p.role,
|
|
p.team,
|
|
{ badge: { label: cap(p.status), tone: TONE[p.status] } },
|
|
p.lastActive,
|
|
],
|
|
name: p.name,
|
|
actions: [
|
|
{ href: "#", icon: "i-user", label: "View" },
|
|
{ href: "#", icon: "i-edit", label: "Edit" },
|
|
{ danger: true, icon: "i-trash", label: "Deactivate", separatorBefore: true },
|
|
],
|
|
})),
|
|
selectable: true,
|
|
};
|
|
}
|
|
|
|
function filterBar(state: State) {
|
|
const pills: { label: string; remove: string; value: string }[] = [];
|
|
if (state.q) pills.push({ label: "Search", remove: href(state, { page: 1, q: "" }), value: state.q });
|
|
if (state.status !== "all") pills.push({ label: "Status", remove: href(state, { page: 1, status: "all" }), value: cap(state.status) });
|
|
if (state.team) pills.push({ label: "Team", remove: href(state, { page: 1, team: "" }), value: state.team });
|
|
|
|
return {
|
|
applyLabel: "Apply filters",
|
|
clearHref: "?",
|
|
label: "Filter people",
|
|
pills,
|
|
rows: [[
|
|
{ label: "Search people", name: "q", placeholder: "Search people…", type: "search", value: state.q },
|
|
{ legend: "Status", name: "status", options: [
|
|
{ count: PEOPLE.length, label: "All", value: "all" },
|
|
{ label: "Active", value: "active" },
|
|
{ label: "Invited", value: "invited" },
|
|
{ label: "Suspended", value: "suspended" },
|
|
], type: "segmented", value: state.status },
|
|
{ label: "Team", name: "team", options: [{ label: "All teams", value: "" }, ...TEAMS.map((t) => ({ label: t, value: t }))], type: "select", value: state.team },
|
|
{ type: "spacer" },
|
|
]],
|
|
};
|
|
}
|
|
|
|
function pagination(state: State, page: ReturnType<typeof paginate>) {
|
|
// Hidden inputs carry the list state through the rows-per-page GET form (page resets on change).
|
|
const hidden: { name: string; value: string }[] = [];
|
|
if (state.q) hidden.push({ name: "q", value: state.q });
|
|
if (state.status !== "all") hidden.push({ name: "status", value: state.status });
|
|
if (state.team) hidden.push({ name: "team", value: state.team });
|
|
if (state.sort) hidden.push({ name: "sort", value: state.sort });
|
|
|
|
return {
|
|
label: "People pagination",
|
|
next: { href: page.next ? href(state, { page: page.next }) : undefined },
|
|
pages: page.pages.map((p) =>
|
|
p.ellipsis ? { ellipsis: true }
|
|
: p.current ? { current: true, label: String(p.page) }
|
|
: { href: href(state, { page: p.page as number }), label: String(p.page) }),
|
|
prev: { href: page.prev ? href(state, { page: page.prev }) : undefined },
|
|
rows: { hidden, label: "Rows", name: "pageSize", options: PAGE_SIZES, submitLabel: "Go", value: state.pageSize },
|
|
summary: { from: page.from, to: page.to, total: page.total },
|
|
};
|
|
}
|