@zerotal/admin 1.4.0 → 1.5.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.
package/CHANGELOG.md CHANGED
@@ -4,10 +4,44 @@ All notable changes to this package are documented here. The format is
4
4
  based on [Keep a Changelog](https://keepachangelog.com/); this package
5
5
  follows the Zerotal monorepo's unified versioning.
6
6
 
7
- **Maturity: `experimental`**
7
+ **Maturity: `stable`**
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.5.0] — 2026-08-15
12
+
13
+ ### Changed
14
+
15
+ - **The public surface is now an explicit decision rather than whatever happened to be
16
+ exported.** 36 exports are marked `@internal` and leave the compatibility promise:
17
+ layout plumbing (`applyLayout`, `reconcile`, `moveKey`), schema normalisation
18
+ (`toFormLayout`, `toFormSections`, `flattenFields`, `flattenActions`), rule-tree
19
+ internals (`parseRuleTree`, `describeRuleTree`, `ruleTreeIsEmpty`), predicates
20
+ (`isImage`, `isUpload`, `isFormSection`), and the session/storage keys and defaults
21
+ (`IMPERSONATOR_KEY`, `THEME_STORAGE_KEY`, `VIEW_PARAMS`, `DEFAULT_ADMIN_CONFIG`, …).
22
+
23
+ None of these were ever meant to be API; they were exported because something else
24
+ needed them. Deciding that **now** matters, because after `stable` narrowing the
25
+ surface is itself a breaking change. The promise drops from 182 callables to 146.
26
+
27
+ - **Every remaining export is documented.** The reference gained sections for
28
+ configuration and middleware, the row and bulk actions the guide had not reached
29
+ (`restoreAction`, `forceDeleteAction`, `bulkEditAction`, `bulkRestoreAction`,
30
+ `bulkForceDeleteAction`, `textFilter`), impersonation, the media helpers behind
31
+ `mediaPicker`, import/export, history and permissions — each with its real signature
32
+ read off the API surface rather than paraphrased. Coverage of the promised surface
33
+ went from 58% to 100%.
34
+
35
+ - **Maturity is now `stable`** — the public API follows SemVer strictly for the rest of
36
+ the 1.x line. Every gate is closed: `@zerotal/flow` and `@zerotal/flow-ui` are stable
37
+ underneath it, the internal/public boundary is explicit, and the documented surface is
38
+ complete. The guard and ability middleware — the panel's security boundary — were
39
+ already covered by tests, alongside 222 tests overall.
40
+
41
+ This remains the largest surface in the monorepo at 146 callables, which is exactly
42
+ why the `@internal` triage came first: freezing a smaller, deliberate surface is a
43
+ promise worth making, where freezing everything that happened to be exported is not.
44
+
11
45
  ## [1.0.3] — 2026-08-07
12
46
 
13
47
  ### Changed
package/README.md CHANGED
@@ -12,7 +12,9 @@ The default UI ships with **light + dark mode out of the box**. Styling currentl
12
12
  loads Tailwind via the Play CDN (configured in `theme.ts`); swapping to a real
13
13
  Tailwind build later only touches that one file.
14
14
 
15
- > **Status:** experimental. You get navigation, list pages (search / sort /
15
+ > **Status:** stable the public API follows SemVer strictly for the rest of the 1.x
16
+ > line. Anything importable without an `@internal` marker is covered, and the surface is
17
+ > snapshotted in `api-surface.md`, which CI diffs on every change. You get navigation, list pages (search / sort /
16
18
  > paginate), a read-only **View** page (infolists), reactive **Create / Edit**
17
19
  > forms with validation, a **Delete** row action, and a configurable auth
18
20
  > **guard**. The previous config-driven admin has been retired; it remains
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@zerotal/admin",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "license": "MIT",
5
- "maturity": "experimental",
5
+ "maturity": "stable",
6
6
  "private": false,
7
7
  "type": "module",
8
8
  "main": "./src/index.ts",
@@ -31,16 +31,16 @@
31
31
  "typecheck": "tsc --noEmit"
32
32
  },
33
33
  "dependencies": {
34
- "@zerotal/cache": "1.4.0",
35
- "@zerotal/core": "1.4.0",
36
- "@zerotal/flow": "1.4.0",
37
- "@zerotal/flow-ui": "1.4.0",
38
- "@zerotal/validator": "1.4.0"
34
+ "@zerotal/cache": "1.5.0",
35
+ "@zerotal/core": "1.5.0",
36
+ "@zerotal/flow": "1.5.0",
37
+ "@zerotal/flow-ui": "1.5.0",
38
+ "@zerotal/validator": "1.5.0"
39
39
  },
40
40
  "devDependencies": {
41
- "@zerotal/orm": "1.4.0",
42
- "@zerotal/auth": "1.4.0",
43
- "@zerotal/queue": "1.4.0"
41
+ "@zerotal/orm": "1.5.0",
42
+ "@zerotal/auth": "1.5.0",
43
+ "@zerotal/queue": "1.5.0"
44
44
  },
45
45
  "peerDependencies": {
46
46
  "@zerotal/orm": "^1.0.0",
package/src/Panel.ts CHANGED
@@ -47,7 +47,10 @@ import type {
47
47
  export { PanelInstance } from "./PanelInstance.ts";
48
48
  export type { ResourceClass, NavItem, NavGroup, PanelPage } from "./PanelInstance.ts";
49
49
 
50
- /** The id given to the panel every application starts with. */
50
+ /** The id given to the panel every application starts with.
51
+ *
52
+ * @internal
53
+ */
51
54
  export const DEFAULT_PANEL_ID = "admin";
52
55
 
53
56
  export class Panel {
@@ -366,7 +366,10 @@ export function actionGroup(actions: Action[]): ActionGroup {
366
366
  /** Either a single action or a group of them — what a resource may return. */
367
367
  export type ActionItem = Action | ActionGroup;
368
368
 
369
- /** Flatten groups so callers that need plain actions (dispatch, lookup) get them. */
369
+ /** Flatten groups so callers that need plain actions (dispatch, lookup) get them.
370
+ *
371
+ * @internal
372
+ */
370
373
  export function flattenActions(items: ActionItem[]): Action[] {
371
374
  return items.flatMap((item) => (item instanceof ActionGroup ? item._actions : [item]));
372
375
  }
@@ -45,7 +45,7 @@ export function toCsv(rows: Record<string, unknown>[], columns: Column[]): strin
45
45
  * newlines. Returns an empty array for empty input rather than a phantom row.
46
46
  */
47
47
  export function parseCsv(text: string): string[][] {
48
- const source = text.replace(/^/, ""); // strip a spreadsheet's byte-order mark
48
+ const source = text.replace(/^\uFEFF/, ""); // strip a spreadsheet's byte-order mark
49
49
  if (source.trim() === "") return [];
50
50
 
51
51
  const rows: string[][] = [];
@@ -102,6 +102,8 @@ export function parseCsv(text: string): string[][] {
102
102
  * Matching is forgiving about the ways a label and a key differ in practice —
103
103
  * case, spaces, underscores, hyphens — so a file exported from the panel
104
104
  * re-imports without the user mapping a single column by hand.
105
+ *
106
+ * @internal
105
107
  */
106
108
  export function guessColumnMapping(
107
109
  headers: string[],
@@ -29,6 +29,8 @@ import { DEFAULT_PANEL_ID } from "../Panel.ts";
29
29
  * A synchronous import holds a WebSocket round-trip open, so a hundred-thousand
30
30
  * row file would look like a hang and time out half-written. Files beyond this
31
31
  * belong on a queue; the action says so rather than trying and failing.
32
+ *
33
+ * @internal
32
34
  */
33
35
  export const IMPORT_ROW_LIMIT = 2000;
34
36
 
@@ -202,7 +204,10 @@ export async function importCsv(
202
204
  return result;
203
205
  }
204
206
 
205
- /** Field key a mapping select writes to, for CSV column `index`. */
207
+ /** Field key a mapping select writes to, for CSV column `index`.
208
+ *
209
+ * @internal
210
+ */
206
211
  export const MAPPING_FIELD_PREFIX = "map_";
207
212
 
208
213
  /**
package/src/config.ts CHANGED
@@ -99,6 +99,12 @@ export interface AdminConfigShape {
99
99
  plugins?: Record<string, boolean>;
100
100
  }
101
101
 
102
+ /**
103
+ * The raw defaults `AdminConfig()` merges onto. Apps configure the panel through
104
+ * that factory rather than reaching for this object.
105
+ *
106
+ * @internal
107
+ */
102
108
  export const DEFAULT_ADMIN_CONFIG: AdminConfigShape = {
103
109
  path: "/admin",
104
110
  brand: "Zerotal",
@@ -39,7 +39,10 @@ export interface DashboardLayoutStore {
39
39
  save(layout: DashboardLayout): Promise<void> | void;
40
40
  }
41
41
 
42
- /** An empty layout — the state before anyone has arranged anything. */
42
+ /** An empty layout — the state before anyone has arranged anything.
43
+ *
44
+ * @internal
45
+ */
43
46
  export const EMPTY_LAYOUT: DashboardLayout = { order: [], hidden: [] };
44
47
 
45
48
  /**
@@ -49,6 +52,8 @@ export const EMPTY_LAYOUT: DashboardLayout = { order: [], hidden: [] };
49
52
  * only says how to arrange them. So a widget added since the layout was saved
50
53
  * still appears (at the end, where it is noticeable rather than lost), and a
51
54
  * widget since removed does not resurrect because a stale key mentions it.
55
+ *
56
+ * @internal
52
57
  */
53
58
  export function applyLayout<T>(
54
59
  widgets: T[],
@@ -75,7 +80,10 @@ export function applyLayout<T>(
75
80
  };
76
81
  }
77
82
 
78
- /** Move one key up or down, returning the new order. */
83
+ /** Move one key up or down, returning the new order.
84
+ *
85
+ * @internal
86
+ */
79
87
  export function moveKey(order: string[], key: string, direction: -1 | 1): string[] {
80
88
  const from = order.indexOf(key);
81
89
  if (from === -1) return order;
@@ -92,6 +100,8 @@ export function moveKey(order: string[], key: string, direction: -1 | 1): string
92
100
  *
93
101
  * A layout saved when there were three widgets cannot reorder a fourth, so the
94
102
  * order is reconciled against the current keys before anything is moved.
103
+ *
104
+ * @internal
95
105
  */
96
106
  export function reconcile(layout: DashboardLayout | null, keys: string[]): DashboardLayout {
97
107
  const known = new Set(keys);
@@ -18,6 +18,7 @@ import { type FormComponent, flattenFields } from "./Section.ts";
18
18
 
19
19
  export type ResourceFormClass = new () => Form & Record<string, unknown>;
20
20
 
21
+ /** @internal */
21
22
  export function makeResourceForm(
22
23
  schema: FormComponent[],
23
24
  mode: FieldMode,
@@ -271,11 +271,15 @@ export function formSection(heading?: string): FormSection {
271
271
  return new FormSection(heading);
272
272
  }
273
273
 
274
+ /** @internal */
274
275
  export function isFormSection(c: FormComponent): c is FormSection {
275
276
  return c instanceof FormSection;
276
277
  }
277
278
 
278
- /** Collect every field across sections, tab groups, + loose fields, in order. */
279
+ /** Collect every field across sections, tab groups, + loose fields, in order.
280
+ *
281
+ * @internal
282
+ */
279
283
  export function flattenFields(components: FormComponent[]): Field[] {
280
284
  const out: Field[] = [];
281
285
  for (const c of components) {
@@ -295,7 +299,10 @@ export function flattenFields(components: FormComponent[]): Field[] {
295
299
  return out;
296
300
  }
297
301
 
298
- /** Normalize components into sections, wrapping loose fields in a default section. */
302
+ /** Normalize components into sections, wrapping loose fields in a default section.
303
+ *
304
+ * @internal
305
+ */
299
306
  export function toFormSections(components: FormComponent[]): FormSection[] {
300
307
  const sections: FormSection[] = [];
301
308
  let loose: Field[] = [];
@@ -326,7 +333,10 @@ export type FormBlock =
326
333
  | { kind: "callout"; callout: Callout }
327
334
  | { kind: "prime"; prime: Prime };
328
335
 
329
- /** Normalize the schema into ordered layout blocks (loose fields → a section). */
336
+ /** Normalize the schema into ordered layout blocks (loose fields → a section).
337
+ *
338
+ * @internal
339
+ */
330
340
  export function toFormLayout(components: FormComponent[]): FormBlock[] {
331
341
  const blocks: FormBlock[] = [];
332
342
  let loose: Field[] = [];
package/src/history.ts CHANGED
@@ -136,7 +136,10 @@ function diff(
136
136
  return changes;
137
137
  }
138
138
 
139
- /** The values that would put a record back to how it was before an entry. */
139
+ /** The values that would put a record back to how it was before an entry.
140
+ *
141
+ * @internal
142
+ */
140
143
  export function revertPayload(entry: HistoryEntry): Record<string, unknown> {
141
144
  const payload: Record<string, unknown> = {};
142
145
  for (const change of entry.changes) payload[change.field] = change.from;
@@ -19,7 +19,10 @@
19
19
  */
20
20
  import { frameworkLog } from "@zerotal/core/logger";
21
21
 
22
- /** Session key holding the original user's id while impersonating. */
22
+ /** Session key holding the original user's id while impersonating.
23
+ *
24
+ * @internal
25
+ */
23
26
  export const IMPERSONATOR_KEY = "admin.impersonator";
24
27
 
25
28
  /** The session surface this needs — kept structural so `@zerotal/session` stays optional. */
package/src/media.ts CHANGED
@@ -64,6 +64,7 @@ export interface MediaProvider {
64
64
  /** Image types the picker shows a thumbnail for rather than an icon. */
65
65
  const IMAGE_TYPES = /^image\/(png|jpe?g|gif|webp|avif|svg\+xml)$/i;
66
66
 
67
+ /** @internal */
67
68
  export function isImage(item: MediaItem): boolean {
68
69
  return IMAGE_TYPES.test(item.mime);
69
70
  }
@@ -203,7 +204,10 @@ export interface UploadedFileLike {
203
204
  store(directory: string, disk?: string, filename?: string): Promise<string>;
204
205
  }
205
206
 
206
- /** Whether a bound value is an upload rather than an already-stored path. */
207
+ /** Whether a bound value is an upload rather than an already-stored path.
208
+ *
209
+ * @internal
210
+ */
207
211
  export function isUpload(value: unknown): value is UploadedFileLike {
208
212
  return (
209
213
  value != null &&
@@ -82,6 +82,19 @@ const COLS_CLASS = [
82
82
  ];
83
83
  const SPAN_CLASS = ["", "", "sm:col-span-2", "sm:col-span-3", "sm:col-span-4"];
84
84
 
85
+ /**
86
+ * The pivot object a BelongsToMany relation method returns when called without
87
+ * eager-loading. Reached by name off a model instance, so the methods are
88
+ * optional: the page checks each one before calling it, and a relation that is
89
+ * not many-to-many has none of them.
90
+ */
91
+ interface PivotProxy {
92
+ attach?: (id: unknown) => unknown;
93
+ detach?: (id: unknown) => unknown;
94
+ get?: () => unknown;
95
+ all?: () => unknown;
96
+ }
97
+
85
98
  export class RecordViewPage extends Component {
86
99
  static layout = AdminLayout;
87
100
  /** Set by each generated subclass. */
@@ -459,9 +472,7 @@ export class RecordViewPage extends Component {
459
472
  @expose attachDraft: Record<string, string> = {};
460
473
 
461
474
  /** Resolve a parent model *instance* and its relation object by method name. */
462
- private async _relation(
463
- relationName: string,
464
- ): Promise<{ attach?: Function; detach?: Function; get?: Function; all?: Function } | null> {
475
+ private async _relation(relationName: string): Promise<PivotProxy | null> {
465
476
  const model = this._resource.model as unknown as {
466
477
  find?: (id: unknown) => Promise<Record<string, unknown> | null>;
467
478
  };
@@ -469,12 +480,7 @@ export class RecordViewPage extends Component {
469
480
  const parent = (await model.find(this.recordId)) as Record<string, unknown> | null;
470
481
  const fn = parent && (parent as Record<string, unknown>)[relationName];
471
482
  if (typeof fn !== "function") return null;
472
- return (fn as () => unknown).call(parent) as {
473
- attach?: Function;
474
- detach?: Function;
475
- get?: Function;
476
- all?: Function;
477
- };
483
+ return (fn as () => unknown).call(parent) as PivotProxy;
478
484
  }
479
485
 
480
486
  /** Load the rows currently attached through a BelongsToMany relation. */
@@ -22,7 +22,6 @@ import type { ResourceClass } from "../Panel.ts";
22
22
  import { Panel } from "../Panel.ts";
23
23
  import type { PanelInstance } from "../PanelInstance.ts";
24
24
  import type { Column, BadgeTone } from "../table/Column.ts";
25
- import type { Group } from "../table/Group.ts";
26
25
  import type { Filter, QueryRule } from "../table/Filter.ts";
27
26
  import {
28
27
  parseRuleTree,
@@ -1824,7 +1823,6 @@ export class ResourceListPage extends Component {
1824
1823
 
1825
1824
  override async render(): Promise<HtmlNode> {
1826
1825
  const R = this._resource;
1827
- const base = this._panel.base();
1828
1826
  const currentPage = Math.max(1, parseInt(this.page, 10) || 1);
1829
1827
 
1830
1828
  // Resolve the active tab (defaults to the first) and its query scope.
@@ -68,6 +68,8 @@ export type RenderHook = (context: RenderHookContext) => HtmlNode | string | nul
68
68
  *
69
69
  * A hook is decoration: it must not be able to take down the page it decorates,
70
70
  * so a throwing hook is logged and skipped rather than propagated.
71
+ *
72
+ * @internal
71
73
  */
72
74
  export function resolveRenderHooks(
73
75
  hooks: RenderHook[],
package/src/roles.ts CHANGED
@@ -151,7 +151,10 @@ export function panelPermissions(panel: PanelInstance): Permission[] {
151
151
  return out;
152
152
  }
153
153
 
154
- /** Group a flat permission list for rendering, preserving order. */
154
+ /** Group a flat permission list for rendering, preserving order.
155
+ *
156
+ * @internal
157
+ */
155
158
  export function groupPermissions(
156
159
  permissions: Permission[],
157
160
  ): { group: string; items: Permission[] }[] {
package/src/savedViews.ts CHANGED
@@ -48,6 +48,8 @@ export interface SavedViewProvider {
48
48
  * Deliberately explicit rather than "everything in the URL": a saved view should
49
49
  * restore how the list was *shaped*, not which page of it happened to be open,
50
50
  * so `page` is excluded and a restored view always starts at the top.
51
+ *
52
+ * @internal
51
53
  */
52
54
  export const VIEW_PARAMS = [
53
55
  "search",
@@ -62,7 +64,10 @@ export const VIEW_PARAMS = [
62
64
  "group",
63
65
  ] as const;
64
66
 
65
- /** Reduce a full query string to just the parts a view restores. */
67
+ /** Reduce a full query string to just the parts a view restores.
68
+ *
69
+ * @internal
70
+ */
66
71
  export function viewQuery(params: URLSearchParams | string): string {
67
72
  const source = typeof params === "string" ? new URLSearchParams(params) : params;
68
73
  const out = new URLSearchParams();
@@ -73,7 +78,10 @@ export function viewQuery(params: URLSearchParams | string): string {
73
78
  return out.toString();
74
79
  }
75
80
 
76
- /** Whether a view matches the list's current state — used to mark the active one. */
81
+ /** Whether a view matches the list's current state — used to mark the active one.
82
+ *
83
+ * @internal
84
+ */
77
85
  export function viewIsActive(view: SavedView, current: URLSearchParams | string): boolean {
78
86
  return viewQuery(view.query) === viewQuery(current);
79
87
  }
@@ -22,7 +22,10 @@ export type QueryRule =
22
22
  | { type: "rule"; constraint: string; operator: string; value?: string }
23
23
  | { type: "group"; operator: Conjunction; rules: QueryRule[] };
24
24
 
25
- /** Parse the JSON a query-builder filter stores in the URL. Invalid input filters nothing. */
25
+ /** Parse the JSON a query-builder filter stores in the URL. Invalid input filters nothing.
26
+ *
27
+ * @internal
28
+ */
26
29
  export function parseRuleTree(value: string): QueryRule | null {
27
30
  if (!value) return null;
28
31
  try {
@@ -48,6 +51,8 @@ function isRule(node: unknown): node is QueryRule {
48
51
  * "Advanced filter: 3 rules" tells someone their list is narrowed and by roughly
49
52
  * how much; the builder itself shows the detail. One rule gets named outright,
50
53
  * since that is the common case and the label fits.
54
+ *
55
+ * @internal
51
56
  */
52
57
  export function describeRuleTree(
53
58
  node: QueryRule | null,
@@ -73,7 +78,10 @@ export function describeRuleTree(
73
78
  return `${rules.length} rules`;
74
79
  }
75
80
 
76
- /** Does this tree actually constrain anything? An empty group is a no-op. */
81
+ /** Does this tree actually constrain anything? An empty group is a no-op.
82
+ *
83
+ * @internal
84
+ */
77
85
  export function ruleTreeIsEmpty(node: QueryRule | null): boolean {
78
86
  if (!node) return true;
79
87
  if (node.type === "rule") return false;
package/src/theme.ts CHANGED
@@ -20,7 +20,10 @@ import {
20
20
  THEME_TOGGLE_SCRIPT as FLOW_THEME_TOGGLE_SCRIPT,
21
21
  } from "@zerotal/flow-ui";
22
22
 
23
- /** localStorage key the toggle and the no-flash script share. */
23
+ /** localStorage key the toggle and the no-flash script share.
24
+ *
25
+ * @internal
26
+ */
24
27
  export const THEME_STORAGE_KEY = FLOW_THEME_STORAGE_KEY;
25
28
 
26
29
  /** Client helpers (theme toggle + copy-to-clipboard), eval-free. */
@@ -226,6 +226,8 @@ export type DashboardWidget = StatsWidget | ChartWidget | TableWidget;
226
226
  /**
227
227
  * The shortest poll interval among these widgets, or `undefined` when none
228
228
  * polls. The dashboard polls as one unit, so the keenest widget sets the pace.
229
+ *
230
+ * @internal
229
231
  */
230
232
  export function widgetPollInterval(widgets: DashboardWidget[]): string | undefined {
231
233
  const intervals = widgets.map((w) => w._poll).filter((i): i is string => Boolean(i));