@zerotal/admin 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/LICENSE +21 -0
  3. package/README.md +344 -0
  4. package/package.json +78 -0
  5. package/src/Cluster.ts +50 -0
  6. package/src/Panel.ts +288 -0
  7. package/src/PanelInstance.ts +644 -0
  8. package/src/Resource.ts +918 -0
  9. package/src/actions/Action.ts +607 -0
  10. package/src/actions/ImportRecordsJob.ts +108 -0
  11. package/src/actions/csv.ts +123 -0
  12. package/src/actions/index.ts +39 -0
  13. package/src/actions/render.tsx +181 -0
  14. package/src/actions/transfer.ts +307 -0
  15. package/src/actions/xlsx.ts +304 -0
  16. package/src/auth/AuthLayout.tsx +34 -0
  17. package/src/auth/index.ts +13 -0
  18. package/src/auth/pages/ForgotPasswordPage.tsx +87 -0
  19. package/src/auth/pages/LoginPage.tsx +121 -0
  20. package/src/auth/pages/ProfilePage.tsx +216 -0
  21. package/src/auth/pages/ResetPasswordPage.tsx +103 -0
  22. package/src/auth/pages/VerifyEmailPage.tsx +68 -0
  23. package/src/auth/register.ts +44 -0
  24. package/src/authRoles.ts +141 -0
  25. package/src/commands/MakeAdminResourceCommand.ts +181 -0
  26. package/src/config.ts +128 -0
  27. package/src/dashboardLayout.ts +101 -0
  28. package/src/databaseMedia.ts +148 -0
  29. package/src/databaseNotifications.ts +169 -0
  30. package/src/form/Field.ts +928 -0
  31. package/src/form/ResourceForm.ts +48 -0
  32. package/src/form/Section.ts +364 -0
  33. package/src/form/editors.ts +43 -0
  34. package/src/form/index.ts +59 -0
  35. package/src/history.ts +151 -0
  36. package/src/impersonation.ts +126 -0
  37. package/src/index.ts +380 -0
  38. package/src/infolist/Entry.ts +537 -0
  39. package/src/infolist/Section.ts +99 -0
  40. package/src/infolist/index.ts +38 -0
  41. package/src/media.ts +297 -0
  42. package/src/notifications.ts +65 -0
  43. package/src/pages/AdminPage.ts +100 -0
  44. package/src/pages/ConsolePage.tsx +324 -0
  45. package/src/pages/DashboardPage.tsx +264 -0
  46. package/src/pages/MediaPage.tsx +346 -0
  47. package/src/pages/NotificationsPage.tsx +155 -0
  48. package/src/pages/RecordViewPage.tsx +951 -0
  49. package/src/pages/ResourceFormPage.tsx +1856 -0
  50. package/src/pages/ResourceListPage.tsx +2552 -0
  51. package/src/pages/RolesPage.tsx +325 -0
  52. package/src/pages/SearchPage.tsx +169 -0
  53. package/src/plugin.ts +283 -0
  54. package/src/provider/AdminAbilityMiddleware.ts +25 -0
  55. package/src/provider/AdminGuardMiddleware.ts +29 -0
  56. package/src/provider/AdminProvider.ts +334 -0
  57. package/src/relations/RelationManager.ts +114 -0
  58. package/src/renderHooks.ts +86 -0
  59. package/src/roles.ts +175 -0
  60. package/src/savedViews.ts +79 -0
  61. package/src/support/ability.ts +73 -0
  62. package/src/support/authorize.ts +105 -0
  63. package/src/support/countCache.ts +37 -0
  64. package/src/support/hostPage.ts +30 -0
  65. package/src/table/Column.ts +353 -0
  66. package/src/table/Constraint.ts +238 -0
  67. package/src/table/Filter.ts +275 -0
  68. package/src/table/Group.ts +73 -0
  69. package/src/table/Tab.ts +77 -0
  70. package/src/testing.ts +121 -0
  71. package/src/theme.ts +70 -0
  72. package/src/ui/AdminLayout.tsx +355 -0
  73. package/src/ui/Breadcrumbs.tsx +84 -0
  74. package/src/ui/environmentIndicator.tsx +63 -0
  75. package/src/ui/icons.tsx +124 -0
  76. package/src/widgets/Widget.ts +251 -0
  77. package/src/widgets/render.tsx +154 -0
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The roles page, driven by the RBAC that `@zerotal/auth` already provides.
3
+ *
4
+ * {@link RoleProvider} is deliberately open, because where roles live is the
5
+ * app's decision. When the answer is the framework's own role-based access
6
+ * control — roles, permissions, and the `role_permissions` pivot between them —
7
+ * this builds the provider for you:
8
+ *
9
+ * import { authRoles } from "@zerotal/admin";
10
+ *
11
+ * Panel.roles(authRoles());
12
+ *
13
+ * The result is a matrix over the real permissions, not a copy of them: ticking
14
+ * a box calls `syncPermissions`, so the same checks that already guard the app
15
+ * start passing immediately.
16
+ *
17
+ * `@zerotal/auth` is resolved lazily, so it stays an optional peer.
18
+ */
19
+ import { frameworkLog } from "@zerotal/core/logger";
20
+ import type { Role, RoleProvider } from "./roles.ts";
21
+
22
+ export interface AuthRolesOptions {
23
+ /**
24
+ * Guard the roles belong to. Defaults to `"web"`, matching the framework's
25
+ * own default, so most apps need not think about it.
26
+ */
27
+ guard?: string;
28
+ /**
29
+ * Role names that hold every permission.
30
+ *
31
+ * Named rather than inferred, because "administrator" is a convention and not
32
+ * something the data can tell you. A superuser role is shown as holding
33
+ * everything and cannot be edited or deleted from the panel.
34
+ */
35
+ superusers?: string[];
36
+ }
37
+
38
+ /** The role surface this needs, kept structural so `auth` stays optional. */
39
+ interface RoleModelLike {
40
+ id: unknown;
41
+ name: string;
42
+ label?: string | null;
43
+ permissionNames(): Promise<string[]>;
44
+ syncPermissions(permissions: string[]): Promise<unknown>;
45
+ delete(): Promise<unknown>;
46
+ }
47
+
48
+ interface RoleStatics {
49
+ query(): {
50
+ where(
51
+ column: string,
52
+ value: unknown,
53
+ ): {
54
+ orderBy(column: string, direction: string): { get(): Promise<RoleModelLike[]> };
55
+ first<T>(): Promise<T | null>;
56
+ };
57
+ };
58
+ find(id: unknown): Promise<RoleModelLike | null>;
59
+ resolve(name: string, guard?: string): Promise<RoleModelLike>;
60
+ }
61
+
62
+ async function roleModel(): Promise<RoleStatics | null> {
63
+ try {
64
+ const mod = (await import(/* @vite-ignore */ "@zerotal/auth" as string)) as {
65
+ Role?: RoleStatics;
66
+ };
67
+ return mod.Role ?? null;
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Build a {@link RoleProvider} over the framework's roles and permissions.
75
+ *
76
+ * Listing fails soft — an unconfigured database or a missing table yields an
77
+ * empty page rather than a broken panel — while writes report their failures,
78
+ * because silently not saving a permission change is the worst outcome here.
79
+ */
80
+ export function authRoles(options: AuthRolesOptions = {}): RoleProvider {
81
+ const guard = options.guard ?? "web";
82
+ const superusers = new Set(
83
+ (options.superusers ?? ["admin", "administrator"]).map((n) => n.toLowerCase()),
84
+ );
85
+
86
+ const isSuper = (name: string): boolean => superusers.has(name.toLowerCase());
87
+
88
+ return {
89
+ async list(): Promise<Role[]> {
90
+ try {
91
+ const Role = await roleModel();
92
+ if (!Role) return [];
93
+ const rows = await Role.query().where("guard", guard).orderBy("name", "asc").get();
94
+ return rows.map((row) => ({
95
+ id: String(row.id),
96
+ name: row.label || row.name,
97
+ superuser: isSuper(row.name),
98
+ }));
99
+ } catch (error) {
100
+ frameworkLog("admin").warn("Roles unavailable", { guard }, error);
101
+ return [];
102
+ }
103
+ },
104
+
105
+ async permissionsFor(roleId): Promise<string[]> {
106
+ try {
107
+ const Role = await roleModel();
108
+ const role = await Role?.find(roleId);
109
+ return (await role?.permissionNames()) ?? [];
110
+ } catch (error) {
111
+ frameworkLog("admin").warn("Could not read a role's permissions", { roleId }, error);
112
+ return [];
113
+ }
114
+ },
115
+
116
+ async setPermissions(roleId, keys): Promise<void> {
117
+ const Role = await roleModel();
118
+ if (!Role) throw new Error("Roles need @zerotal/auth.");
119
+ const role = await Role.find(roleId);
120
+ if (!role) return;
121
+ // Names that do not exist yet are created by `syncPermissions`, which is
122
+ // what lets the matrix offer a permission before anything has used it.
123
+ await role.syncPermissions(keys);
124
+ },
125
+
126
+ async create(role): Promise<void> {
127
+ const Role = await roleModel();
128
+ if (!Role) throw new Error("Roles need @zerotal/auth.");
129
+ await Role.resolve(role.name, guard);
130
+ },
131
+
132
+ async remove(roleId): Promise<void> {
133
+ const Role = await roleModel();
134
+ if (!Role) throw new Error("Roles need @zerotal/auth.");
135
+ const role = await Role.find(roleId);
136
+ // A superuser role is the one nobody should be able to delete themselves
137
+ // out of, so it is refused here rather than only hidden in the UI.
138
+ if (role && !isSuper(role.name)) await role.delete();
139
+ },
140
+ };
141
+ }
@@ -0,0 +1,181 @@
1
+ import { Command } from "@zerotal/core";
2
+ import type { FlagDef } from "@zerotal/core";
3
+ import { pluralize } from "@zerotal/core/helpers";
4
+
5
+ /**
6
+ * Scaffolds an admin resource (`bun zt make:admin-resource`).
7
+ *
8
+ * Named `make:admin-resource` rather than `make:resource` because that name
9
+ * already belongs to the API transformer generator — two different things that
10
+ * would otherwise collide.
11
+ *
12
+ * @example
13
+ * ```bash
14
+ * bun zt make:admin-resource Product
15
+ * bun zt make:admin-resource Comment --parent=Post --foreign-key=post_id
16
+ * bun zt make:admin-resource Setting --singular
17
+ * ```
18
+ *
19
+ * @category Scaffolding (make:*)
20
+ */
21
+ export class MakeAdminResourceCommand extends Command {
22
+ static commandName = "make:admin-resource";
23
+ static description = "Create an admin panel resource for a model";
24
+ static needsApp = false;
25
+ static args = [{ name: "name", required: true, description: "Model name (e.g. Product)" }];
26
+ static flags: FlagDef[] = [
27
+ {
28
+ name: "cluster",
29
+ type: "string",
30
+ description: "Cluster class to file the resource under (e.g. ShopCluster)",
31
+ },
32
+ {
33
+ name: "parent",
34
+ type: "string",
35
+ description: "Parent resource's model name, for a nested resource",
36
+ },
37
+ {
38
+ name: "foreign-key",
39
+ type: "string",
40
+ description: "Foreign key linking to the parent (e.g. post_id)",
41
+ },
42
+ {
43
+ name: "singular",
44
+ type: "boolean",
45
+ description: "Back a single row — no list, no create page",
46
+ default: false,
47
+ },
48
+ ];
49
+
50
+ async run(): Promise<void> {
51
+ const name = this.args["name"]!;
52
+ const path = `app/admin/${name}Resource.ts`;
53
+
54
+ if (await Bun.file(path).exists()) {
55
+ this.error(`File already exists: ${path}`);
56
+ return;
57
+ }
58
+
59
+ const cluster = this.flags["cluster"] as string | undefined;
60
+ const parent = this.flags["parent"] as string | undefined;
61
+ const singular = Boolean(this.flags["singular"]);
62
+ const foreignKey =
63
+ (this.flags["foreign-key"] as string | undefined) ?? defaultForeignKey(parent);
64
+
65
+ if (parent && !foreignKey) {
66
+ this.error("A nested resource needs --foreign-key (e.g. --foreign-key=post_id).");
67
+ return;
68
+ }
69
+
70
+ await Bun.write(path, adminResourceStub({ name, cluster, parent, foreignKey, singular }));
71
+ this.info(`Created: ${path}`);
72
+ this.dim(`Register it in app/admin/index.ts: Panel.register(${name}Resource)`);
73
+ if (parent || cluster) {
74
+ // The stub guesses flat paths; a panel split into folders needs them fixed.
75
+ this.dim("Check the imports at the top if your panel is organised into folders.");
76
+ }
77
+ }
78
+ }
79
+
80
+ /** `Post` → `post_id`, the conventional foreign key naming. */
81
+ function defaultForeignKey(parent: string | undefined): string | undefined {
82
+ if (!parent) return undefined;
83
+ return `${parent.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase()}_id`;
84
+ }
85
+
86
+ export interface AdminResourceStubOptions {
87
+ name: string;
88
+ cluster?: string | undefined;
89
+ parent?: string | undefined;
90
+ foreignKey?: string | undefined;
91
+ singular?: boolean | undefined;
92
+ }
93
+
94
+ /** Returns the source text of an admin resource for `name`. */
95
+ export function adminResourceStub(options: AdminResourceStubOptions): string {
96
+ const { name, cluster, parent, foreignKey, singular } = options;
97
+
98
+ // A singular resource has no list, so it needs neither the read-only view
99
+ // schema nor an empty state — and importing what it doesn't use would leave
100
+ // every generated file with a lint warning to clean up.
101
+ const imports = ["Resource", "text", "textInput", "formSection"];
102
+ if (!singular) imports.push("section", "textEntry", "createAction");
103
+
104
+ const statics: string[] = [` static override model = ${name};`];
105
+ const extraImports: string[] = [`import { ${name} } from "@app/models/${name}";`];
106
+
107
+ if (cluster) {
108
+ statics.push(` static override cluster = ${cluster};`);
109
+ extraImports.push(`import { ${cluster} } from "@app/admin/clusters";`);
110
+ }
111
+ if (parent) {
112
+ // The parent is named by a function: the two resources reference each other,
113
+ // and a direct reference would be undefined on one side of the cycle.
114
+ statics.push(
115
+ ` static override parent = { resource: () => ${parent}Resource, foreignKey: "${foreignKey}" };`,
116
+ );
117
+ extraImports.push(`import { ${parent}Resource } from "@app/admin/${parent}Resource";`);
118
+ }
119
+ if (singular) {
120
+ statics.push(" static override singular = true;");
121
+ }
122
+ statics.push(' static override navigationIcon = "collection";');
123
+ statics.push(' static override recordTitleAttribute = "name";');
124
+
125
+ const listBits = singular
126
+ ? ""
127
+ : `
128
+ static override emptyState() {
129
+ return {
130
+ heading: "No ${pluralize(name).toLowerCase()} yet",
131
+ description: "Describe what will fill this list, and how.",
132
+ icon: "inbox",
133
+ actions: [createAction()],
134
+ };
135
+ }
136
+ `;
137
+
138
+ const infolist = singular
139
+ ? ""
140
+ : `
141
+ static override infolist() {
142
+ return [
143
+ section("${name}")
144
+ .columns(2)
145
+ .schema([textEntry("name").weight("semibold").size("lg")]),
146
+ ];
147
+ }
148
+ `;
149
+
150
+ return `import {
151
+ ${imports.map((i) => ` ${i},`).join("\n")}
152
+ } from "@zerotal/admin";
153
+ ${extraImports.join("\n")}
154
+
155
+ /**
156
+ * The admin interface for {@link ${name}}.
157
+ *
158
+ * Register it in \`app/admin/index.ts\` and its pages exist: a list, a view, and
159
+ * create/edit forms built from \`form()\`.
160
+ */
161
+ export class ${name}Resource extends Resource {
162
+ ${statics.join("\n")}
163
+
164
+ static override columns() {
165
+ return [
166
+ text("id").sortable(),
167
+ text("name").searchable().sortable(),
168
+ text("createdAt").label("Created").sortable(),
169
+ ];
170
+ }
171
+
172
+ static override form() {
173
+ return [
174
+ formSection("${name}")
175
+ .columns(2)
176
+ .schema([textInput("name").required().maxLength(120).columnSpan(2)]),
177
+ ];
178
+ }
179
+ ${infolist}${listBits}}
180
+ `;
181
+ }
package/src/config.ts ADDED
@@ -0,0 +1,128 @@
1
+ import { deepMerge } from "@zerotal/core";
2
+ import type { MiddlewareClass } from "@zerotal/core";
3
+ import type { AdminThemeConfig } from "./theme.ts";
4
+ import type { AdminAuthorizer } from "./support/ability.ts";
5
+
6
+ /** A single entry in the top-bar user menu. */
7
+ export interface UserMenuItem {
8
+ label: string;
9
+ href: string;
10
+ icon?: string;
11
+ }
12
+
13
+ /** Top-bar user menu (avatar/identity dropdown). */
14
+ export interface UserMenu {
15
+ /** Heading shown at the top of the menu (e.g. the signed-in user's name). */
16
+ label?: string;
17
+ items: UserMenuItem[];
18
+ }
19
+
20
+ /**
21
+ * Auth-pages configuration. When `enabled`, the panel mounts a login screen
22
+ * (using `@zerotal/auth`'s `Auth.attempt`) and an in-panel profile page (update
23
+ * details + change password + sign out). Password-reset and email-verification
24
+ * pages are mounted when their broker hooks are supplied (the admin owns the UI;
25
+ * the app supplies the data — same split as the notification provider).
26
+ */
27
+ export interface AdminAuthConfig {
28
+ /** Mount the auth pages. */
29
+ enabled?: boolean;
30
+ /** Login route (relative to the panel path). Default `/login`. */
31
+ loginPath?: string;
32
+ /** Profile route (relative to the panel path). Default `/profile`. */
33
+ profilePath?: string;
34
+ /** Where to send the user after a successful login. Default the panel root. */
35
+ redirectTo?: string;
36
+ /** The credential column users log in with. Default `"email"`. */
37
+ identifier?: string;
38
+ /** Show a "remember me" checkbox. Default `true`. */
39
+ remember?: boolean;
40
+ /** Heading on the auth screens. Defaults to the panel brand. */
41
+ heading?: string;
42
+ /** Extra gate after the password check (e.g. "is the account active?"). */
43
+ authenticateWhen?: (user: unknown) => boolean | Promise<boolean>;
44
+ /** Persist profile edits (name/email). Defaults to `user.fill(data); user.save()`. */
45
+ updateProfile?: (user: unknown, data: Record<string, unknown>) => Promise<void> | void;
46
+ /** Middleware guarding the guest screens (login/forgot/reset). Default none. */
47
+ guestMiddleware?: MiddlewareClass[];
48
+ /** Middleware guarding the profile/verify screens. Defaults to the panel guard. */
49
+ authMiddleware?: MiddlewareClass[];
50
+ /** Password-reset broker — mounting the forgot/reset pages when provided. */
51
+ passwordReset?: {
52
+ sendResetLink(email: string): Promise<boolean> | boolean;
53
+ reset(input: { email: string; token: string; password: string }): Promise<boolean> | boolean;
54
+ };
55
+ /** Email-verification hooks — mounting the verify page when provided. */
56
+ emailVerification?: {
57
+ isVerified(user: unknown): boolean;
58
+ resend(user: unknown): Promise<void> | void;
59
+ };
60
+ }
61
+
62
+ /** Admin panel configuration (read from `config/admin.ts`, with defaults). */
63
+ export interface AdminConfigShape {
64
+ /** URL prefix the panel mounts under. */
65
+ path: string;
66
+ /** Brand name shown in the sidebar. */
67
+ brand: string;
68
+ /** Optional short tagline under the brand. */
69
+ tagline?: string;
70
+ /**
71
+ * Middleware guarding every panel route. When left empty, the panel is
72
+ * default-denied in production-like environments (fail closed) and open only
73
+ * for local exploration (`APP_ENV=development|local|test`). Set an
74
+ * auth/authorization middleware before shipping to production, e.g.
75
+ * `[AuthMiddleware.with({ ... })]`. To deliberately expose it without auth,
76
+ * pass an explicit pass-through middleware.
77
+ */
78
+ middleware?: MiddlewareClass[];
79
+ /** Optional top-bar user menu (Profile / Logout / …). */
80
+ userMenu?: UserMenu;
81
+ /** Styling source — Tailwind Play CDN (default) or a prebuilt stylesheet. */
82
+ theme?: AdminThemeConfig;
83
+ /** Auth pages (login / profile / password-reset / email-verification). */
84
+ auth?: AdminAuthConfig;
85
+ /**
86
+ * Decide the abilities named by pages, widgets, nav entries and search
87
+ * providers. Set this when the app models permissions itself; leave it unset to
88
+ * resolve through `@zerotal/auth`'s Gate when that package is installed.
89
+ *
90
+ * With neither configured, every ability is denied outside a development
91
+ * environment — a panel with no authorization wired stays closed in production.
92
+ */
93
+ authorize?: AdminAuthorizer;
94
+ /**
95
+ * Switch contributing packages off by id — `{ monitor: false }` keeps the
96
+ * monitor provider installed but drops its pages, widgets and nav entries from
97
+ * the panel. Anything absent here is on.
98
+ */
99
+ plugins?: Record<string, boolean>;
100
+ }
101
+
102
+ export const DEFAULT_ADMIN_CONFIG: AdminConfigShape = {
103
+ path: "/admin",
104
+ brand: "Zerotal",
105
+ tagline: "Admin",
106
+ middleware: [],
107
+ };
108
+
109
+ /**
110
+ * Create a typed admin configuration object with defaults. Function-valued
111
+ * fields (middleware classes, `authorize`, the auth hooks) pass through by
112
+ * reference — deepMerge treats them as atomic values.
113
+ *
114
+ * @example
115
+ * // config/admin.ts
116
+ * import { AdminConfig } from '@zerotal/admin';
117
+ * export default AdminConfig({ path: '/admin', brand: 'Acme', middleware: [AuthMiddleware] });
118
+ */
119
+ export function AdminConfig(options: Partial<AdminConfigShape> = {}): AdminConfigShape {
120
+ return deepMerge(DEFAULT_ADMIN_CONFIG, options);
121
+ }
122
+
123
+ // Register this package's config namespace for typed config() dot-paths.
124
+ declare module "@zerotal/core" {
125
+ interface ConfigRegistry {
126
+ admin: AdminConfigShape;
127
+ }
128
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * A dashboard each person can arrange for themselves.
3
+ *
4
+ * The dashboard is the page most people look at most often, and what belongs at
5
+ * the top of it differs by role: the finance lead wants revenue first, support
6
+ * wants the open-tickets table, and neither wants to scroll past the other's
7
+ * widget every morning. So the order and visibility of widgets are a per-user
8
+ * preference, not a code change.
9
+ *
10
+ * Deliberately order and visibility, and not a free-form canvas. Dragging boxes
11
+ * around a grid is a large amount of machinery to maintain, and it mostly
12
+ * produces layouts that break at the next screen width. Reordering and hiding
13
+ * covers what people actually ask for, and it stays responsive by construction.
14
+ *
15
+ * Storage is the app's, for the same reason the saved views' is:
16
+ *
17
+ * Panel.dashboardLayout({
18
+ * async load() { return Auth.user()?.dashboard ?? null; },
19
+ * async save(layout) { await Auth.user()?.update({ dashboard: layout }); },
20
+ * });
21
+ *
22
+ * With no store configured the dashboard renders in declaration order and the
23
+ * arrange controls do not appear.
24
+ */
25
+
26
+ /** One person's arrangement of the dashboard. */
27
+ export interface DashboardLayout {
28
+ /** Widget keys in the order they should render. */
29
+ order: string[];
30
+ /** Widget keys this person has hidden. */
31
+ hidden: string[];
32
+ }
33
+
34
+ /** Where a per-user layout is read from and written to. */
35
+ export interface DashboardLayoutStore {
36
+ /** The current user's layout, or null when they have never arranged it. */
37
+ load(): Promise<DashboardLayout | null> | DashboardLayout | null;
38
+ /** Persist the current user's layout. */
39
+ save(layout: DashboardLayout): Promise<void> | void;
40
+ }
41
+
42
+ /** An empty layout — the state before anyone has arranged anything. */
43
+ export const EMPTY_LAYOUT: DashboardLayout = { order: [], hidden: [] };
44
+
45
+ /**
46
+ * Apply a layout to the widgets a panel declares.
47
+ *
48
+ * The declaration is the source of truth for *which* widgets exist; the layout
49
+ * only says how to arrange them. So a widget added since the layout was saved
50
+ * still appears (at the end, where it is noticeable rather than lost), and a
51
+ * widget since removed does not resurrect because a stale key mentions it.
52
+ */
53
+ export function applyLayout<T>(
54
+ widgets: T[],
55
+ keyOf: (widget: T, index: number) => string,
56
+ layout: DashboardLayout | null,
57
+ ): { visible: T[]; hidden: { key: string; widget: T }[] } {
58
+ const keyed = widgets.map((widget, index) => ({ widget, key: keyOf(widget, index) }));
59
+ if (!layout) return { visible: widgets, hidden: [] };
60
+
61
+ const hiddenKeys = new Set(layout.hidden);
62
+ const position = new Map(layout.order.map((key, index) => [key, index]));
63
+
64
+ const ordered = [...keyed].sort((a, b) => {
65
+ // Anything the layout does not mention is new, and sorts after everything
66
+ // it does — so a widget added today is visible without being intrusive.
67
+ const ai = position.get(a.key) ?? Number.MAX_SAFE_INTEGER;
68
+ const bi = position.get(b.key) ?? Number.MAX_SAFE_INTEGER;
69
+ return ai - bi;
70
+ });
71
+
72
+ return {
73
+ visible: ordered.filter((k) => !hiddenKeys.has(k.key)).map((k) => k.widget),
74
+ hidden: ordered.filter((k) => hiddenKeys.has(k.key)),
75
+ };
76
+ }
77
+
78
+ /** Move one key up or down, returning the new order. */
79
+ export function moveKey(order: string[], key: string, direction: -1 | 1): string[] {
80
+ const from = order.indexOf(key);
81
+ if (from === -1) return order;
82
+ const to = from + direction;
83
+ if (to < 0 || to >= order.length) return order;
84
+ const next = [...order];
85
+ const [moved] = next.splice(from, 1);
86
+ next.splice(to, 0, moved!);
87
+ return next;
88
+ }
89
+
90
+ /**
91
+ * Fill in a layout's order from the widgets that actually exist.
92
+ *
93
+ * A layout saved when there were three widgets cannot reorder a fourth, so the
94
+ * order is reconciled against the current keys before anything is moved.
95
+ */
96
+ export function reconcile(layout: DashboardLayout | null, keys: string[]): DashboardLayout {
97
+ const known = new Set(keys);
98
+ const order = (layout?.order ?? []).filter((k) => known.has(k));
99
+ for (const key of keys) if (!order.includes(key)) order.push(key);
100
+ return { order, hidden: (layout?.hidden ?? []).filter((k) => known.has(k)) };
101
+ }