@everystack/admin 0.2.3 → 0.2.5

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/src/types.ts CHANGED
@@ -5,6 +5,85 @@
5
5
  * admin dashboards declaratively.
6
6
  */
7
7
 
8
+ import type { ReactNode } from 'react';
9
+ import type { DataAdapter } from './adapters/types';
10
+
11
+ // =============================================================================
12
+ // Rendering & Extensibility
13
+ // =============================================================================
14
+ //
15
+ // One resolution chain for every customizable slot:
16
+ // explicit override (your fn) → named renderer → type-based default.
17
+ // The type default is the sane out-of-the-box behavior; the override is the
18
+ // no-patch escape hatch. See docs/plans/admin-extensibility.md.
19
+
20
+ /** Image dimensions. A bare number is a square (width = height). */
21
+ export type ImageSize = number | { width: number; height?: number };
22
+
23
+ /** Passed to a field's custom cell renderer (`field.render`). */
24
+ export interface CellContext {
25
+ /** Which view is rendering the cell. */
26
+ view: 'list' | 'show';
27
+ }
28
+
29
+ /**
30
+ * Passed to a row action's `onPress`/`render`. Carries the capability a custom
31
+ * button needs — call an rpc via the adapter, refresh the view, navigate —
32
+ * rather than reaching for module-level singletons.
33
+ */
34
+ export interface ActionContext {
35
+ /** The record the action operates on. */
36
+ record: DataRecord;
37
+ /** The active data adapter (use `adapter.rpc` for backend calls). */
38
+ adapter: DataAdapter;
39
+ /** Re-fetch the current view after a mutation. */
40
+ refetch: () => Promise<void> | void;
41
+ /** Navigate to an admin path, when the route layer provides it. */
42
+ navigate?: (path: string) => void;
43
+ }
44
+
45
+ /**
46
+ * Passed to a field's custom form input (`field.input`). The custom input owns
47
+ * its element — render the control (and, if you want, the label/error frame via
48
+ * `@everystack/ui` FormField). `error` is the current validation message.
49
+ */
50
+ export interface InputContext {
51
+ value: unknown;
52
+ onChange: (value: unknown) => void;
53
+ field: FieldDefinition;
54
+ record?: DataRecord;
55
+ error?: string;
56
+ }
57
+
58
+ /**
59
+ * Content format an editor reads and writes. The field declares it; the editor
60
+ * adapter serializes accordingly. The render/sanitize pipeline keys on this.
61
+ */
62
+ export type ContentFormat = 'html' | 'markdown' | 'mdx' | 'email' | 'json';
63
+
64
+ /**
65
+ * Props every editor adapter receives. The contract that makes editors
66
+ * swappable — TipTap, MDXEditor, Lexical, a GrapesJS email builder, etc. each
67
+ * implement this and own their own serialization to/from `format`.
68
+ */
69
+ export interface EditorProps {
70
+ value: unknown;
71
+ onChange: (value: unknown) => void;
72
+ field: FieldDefinition;
73
+ format: ContentFormat;
74
+ error?: string;
75
+ }
76
+
77
+ /** A registered editor component (e.g. a TipTap adapter). */
78
+ export type EditorComponent = (props: EditorProps) => ReactNode;
79
+
80
+ /**
81
+ * App-provided map of editor name → component, passed to AdminRoot/AdminShell.
82
+ * A field selects one via `field.editor` (or falls back to `field.type`).
83
+ * Editors are opt-in plugins — the core admin depends on none of them.
84
+ */
85
+ export type EditorRegistry = Record<string, EditorComponent>;
86
+
8
87
  // =============================================================================
9
88
  // Field Types
10
89
  // =============================================================================
@@ -54,6 +133,29 @@ export interface FieldDefinition {
54
133
  };
55
134
  /** Renderer variant for this field type (e.g., 'avatar' for image fields) */
56
135
  renderer?: string;
136
+ /**
137
+ * Per-view image dimensions (image fields). Falls back to sane defaults —
138
+ * a square thumbnail in the list, a large image on the detail view.
139
+ */
140
+ image?: { list?: ImageSize; show?: ImageSize };
141
+ /**
142
+ * Custom cell renderer — wins over the type-based default. Receives the cell
143
+ * value, the full record, and the view context (`ctx.view` is 'list' | 'show').
144
+ */
145
+ render?: (value: unknown, record: DataRecord, ctx: CellContext) => ReactNode;
146
+ /**
147
+ * Custom form input (create/edit) — wins over the type/renderer input. The
148
+ * custom input owns its element (control + optional label/error frame).
149
+ */
150
+ input?: (ctx: InputContext) => ReactNode;
151
+ /**
152
+ * Name of a registered editor (see EditorRegistry) to use as this field's
153
+ * form input — e.g. 'tiptap', 'markdown'. Falls back to a registry entry
154
+ * matching `type`. `field.input` still wins over this.
155
+ */
156
+ editor?: string;
157
+ /** Content format the editor reads/writes (default 'html'). */
158
+ format?: ContentFormat;
57
159
  }
58
160
 
59
161
  // =============================================================================
@@ -61,8 +163,13 @@ export interface FieldDefinition {
61
163
  // =============================================================================
62
164
 
63
165
  /**
64
- * A declarative, RPC-backed action on a single record (retry/cancel a job).
65
- * Rendered on the show view; invoked via adapter.rpc(rpc, { id }).
166
+ * A declarative action on a single record (delete/restore, retry/cancel a job),
167
+ * rendered on both the list and show views and gated by the `when` clause.
168
+ *
169
+ * Behavior, in precedence order — provide exactly one:
170
+ * - `render` : a fully custom element (you own the chrome)
171
+ * - `onPress` : custom behavior with the default button chrome
172
+ * - `rpc` : declarative; calls adapter.rpc(rpc, { id: record[primaryKey] })
66
173
  */
67
174
  export interface RowActionConfig {
68
175
  /** Unique action identifier */
@@ -70,8 +177,12 @@ export interface RowActionConfig {
70
177
  /** Button label */
71
178
  label: string;
72
179
  /** RPC function name, called with { id: record[primaryKey] } */
73
- rpc: string;
74
- /** Require a confirming second press; shown as the interim label */
180
+ rpc?: string;
181
+ /** Custom behavior with the default button chrome and confirm flow. */
182
+ onPress?: (ctx: ActionContext) => void | Promise<void>;
183
+ /** Fully custom element; bypasses the default button entirely. */
184
+ render?: (ctx: ActionContext) => ReactNode;
185
+ /** Require a confirming second press; shown as the interim label (rpc/onPress). */
75
186
  confirm?: string;
76
187
  /** Button styling */
77
188
  variant?: 'primary' | 'outline' | 'destructive';
@@ -128,7 +239,11 @@ export type StatDialValue =
128
239
  | { type: 'count' }
129
240
  | { type: 'count_where'; column: string; equals: string }
130
241
  | { type: 'distinct'; column: string }
131
- | { type: 'table_count'; table: string; timestampColumn?: string };
242
+ | { type: 'table_count'; table: string; timestampColumn?: string }
243
+ // Field-fold dials over a rollup 'sum' field (artifact-backed dashboards only,
244
+ // e.g. snapshot memory/cpu averages). The SQL DB path ignores these.
245
+ | { type: 'sum'; column: string }
246
+ | { type: 'avg'; column: string };
132
247
 
133
248
  /** Configuration for a single stat dial in the dashboard header. */
134
249
  export interface StatDialConfig {
@@ -279,8 +394,10 @@ export interface MenuItem {
279
394
  resource?: string;
280
395
  /** Dashboard ID (for dashboard links) */
281
396
  dashboard?: string;
282
- /** Nested menu items */
397
+ /** Nested menu items — renders this item as a collapsible group header. */
283
398
  children?: MenuItem[];
399
+ /** For a group (has children): start expanded. Default true. */
400
+ defaultOpen?: boolean;
284
401
  /** Badge content */
285
402
  badge?: string | number;
286
403
  }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Admin routing — pure path↔route mapping behind AdminRouter.
3
+ *
4
+ * The package owns CRUD dispatch and navigation targets without depending on a
5
+ * router: AdminRouter is handed the current `pathname` and a `{ push }` port by
6
+ * the app's catch-all route. These pure functions turn the path into a screen
7
+ * match and build the hrefs to navigate to. See docs/plans/admin-routing.md.
8
+ */
9
+
10
+ export type AdminScreen = 'dashboard' | 'list' | 'show' | 'create' | 'edit';
11
+
12
+ export interface AdminRouteMatch {
13
+ screen: AdminScreen;
14
+ resource?: string;
15
+ id?: string;
16
+ }
17
+
18
+ const CREATE = 'create';
19
+ const EDIT = 'edit';
20
+
21
+ /** Strip `basePath` from a pathname and return the remaining path segments. */
22
+ export function pathToSegments(pathname: string, basePath: string): string[] {
23
+ const base = basePath.replace(/\/+$/, '');
24
+ let rel = pathname;
25
+ if (base && (pathname === base || pathname.startsWith(`${base}/`))) {
26
+ rel = pathname.slice(base.length);
27
+ }
28
+ return rel.split('/').filter(Boolean);
29
+ }
30
+
31
+ /**
32
+ * Map admin path segments to a screen. `create` is a reserved segment, so a
33
+ * record can't have the literal id "create" (matches the navigation convention).
34
+ */
35
+ export function resolveAdminRoute(segments: string[]): AdminRouteMatch {
36
+ const s = segments.filter(Boolean);
37
+ if (s.length === 0) return { screen: 'dashboard' };
38
+
39
+ const [resource, second, third] = s;
40
+ if (s.length === 1) return { screen: 'list', resource };
41
+ if (second === CREATE) return { screen: CREATE, resource };
42
+ if (s.length >= 3 && third === EDIT) return { screen: EDIT, resource, id: second };
43
+ return { screen: 'show', resource, id: second };
44
+ }
45
+
46
+ export interface AdminHrefParts {
47
+ resource?: string;
48
+ id?: string | number;
49
+ action?: 'create' | 'edit';
50
+ }
51
+
52
+ /** Build an admin path. The inverse of resolveAdminRoute. */
53
+ export function adminHref(basePath: string, parts: AdminHrefParts = {}): string {
54
+ const base = basePath.replace(/\/+$/, '');
55
+ const segs: string[] = [];
56
+ if (parts.resource) {
57
+ segs.push(parts.resource);
58
+ if (parts.action === CREATE) {
59
+ segs.push(CREATE);
60
+ } else if (parts.id != null) {
61
+ segs.push(String(parts.id));
62
+ if (parts.action === EDIT) segs.push(EDIT);
63
+ }
64
+ }
65
+ return [base, ...segs].join('/') || '/';
66
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Breadcrumbs — derive the trail from the current route + admin config.
3
+ *
4
+ * Pure mapping: `(config, match, activeItem) → Crumb[]`. A crumb with an `href`
5
+ * is navigable; the last (current) crumb usually has none. The trail mirrors the
6
+ * nav: Admin → [group] → resource → [record/action].
7
+ */
8
+
9
+ import type { AdminConfig } from '../types';
10
+ import type { AdminRouteMatch } from './admin-routing';
11
+ import { adminHref } from './admin-routing';
12
+
13
+ export interface Crumb {
14
+ label: string;
15
+ /** Admin path to navigate to; omitted for the current (non-link) crumb. */
16
+ href?: string;
17
+ }
18
+
19
+ /** Find the active menu item (and its parent group) by id or linked resource. */
20
+ function locate(
21
+ config: AdminConfig,
22
+ activeItem: string | undefined,
23
+ resource: string | undefined,
24
+ ): { groupLabel?: string; itemLabel?: string; itemPath?: string } {
25
+ for (const top of config.menu ?? []) {
26
+ if (top.children?.length) {
27
+ const child = top.children.find(
28
+ c => c.id === activeItem || (resource != null && c.resource === resource),
29
+ );
30
+ if (child) return { groupLabel: top.label, itemLabel: child.label, itemPath: child.path };
31
+ } else if (top.id === activeItem || (resource != null && top.resource === resource)) {
32
+ return { itemLabel: top.label, itemPath: top.path };
33
+ }
34
+ }
35
+ return {};
36
+ }
37
+
38
+ export function buildBreadcrumbs(
39
+ config: AdminConfig,
40
+ match: AdminRouteMatch,
41
+ activeItem: string | undefined,
42
+ basePath = '/admin',
43
+ ): Crumb[] {
44
+ const home: Crumb = { label: config.title ?? 'Admin', href: basePath };
45
+ if (match.screen === 'dashboard') return [home];
46
+
47
+ const { groupLabel, itemLabel, itemPath } = locate(config, activeItem, match.resource);
48
+ const resourceConfig = match.resource
49
+ ? config.resources.find(r => r.name === match.resource)
50
+ : undefined;
51
+
52
+ const listLabel =
53
+ resourceConfig?.labelPlural ?? resourceConfig?.label ?? itemLabel ?? match.resource ?? '';
54
+ const singular = resourceConfig?.label ?? itemLabel ?? match.resource ?? 'Record';
55
+ const listHref =
56
+ itemPath ?? (match.resource ? adminHref(basePath, { resource: match.resource }) : undefined);
57
+
58
+ const crumbs: Crumb[] = [home];
59
+ if (groupLabel) crumbs.push({ label: groupLabel });
60
+
61
+ if (match.screen === 'list') {
62
+ crumbs.push({ label: listLabel });
63
+ return crumbs;
64
+ }
65
+
66
+ // show / create / edit → the list crumb links back.
67
+ crumbs.push({ label: listLabel, href: listHref });
68
+
69
+ if (match.screen === 'create') {
70
+ crumbs.push({ label: `New ${singular}` });
71
+ } else if (match.screen === 'show') {
72
+ crumbs.push({ label: match.id != null ? `#${match.id}` : singular });
73
+ } else if (match.screen === 'edit') {
74
+ if (match.id != null && match.resource) {
75
+ crumbs.push({ label: `#${match.id}`, href: adminHref(basePath, { resource: match.resource, id: match.id }) });
76
+ }
77
+ crumbs.push({ label: 'Edit' });
78
+ }
79
+
80
+ return crumbs;
81
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Rendering resolution — the precedence behind the admin's extensibility
3
+ * contract. Each customizable slot resolves through:
4
+ *
5
+ * explicit override (your fn) → named renderer → type-based default
6
+ *
7
+ * These are the pure pieces of that chain. The screens consume them; the
8
+ * JSX lives there, the decisions live here. See docs/plans/admin-extensibility.md.
9
+ */
10
+
11
+ import type { ImageTransformOptions } from '@everystack/ui/image-url';
12
+ import type { FieldDefinition, ImageSize, RowActionConfig, EditorRegistry, EditorComponent } from '../types';
13
+
14
+ export type ImageView = 'list' | 'show';
15
+
16
+ export interface ImageRenderSpec {
17
+ /** Rendered width in px. */
18
+ width: number;
19
+ /** Rendered height in px. */
20
+ height: number;
21
+ /** Transform passed to resolveImageUrl (CDN sizing). */
22
+ transform: ImageTransformOptions;
23
+ /** Show the storage-key caption beneath the image. */
24
+ showCaption: boolean;
25
+ /** Render through the circular Avatar component instead of a plain Image. */
26
+ isAvatar: boolean;
27
+ }
28
+
29
+ // Sane defaults: a compact square in the list, a large image (caption-bearing)
30
+ // on the detail view, a fixed square for the avatar variant.
31
+ const SIZE_DEFAULTS: Record<'avatar' | ImageView, { width: number; height: number }> = {
32
+ avatar: { width: 128, height: 128 },
33
+ list: { width: 100, height: 100 },
34
+ show: { width: 448, height: 336 },
35
+ };
36
+
37
+ function normalizeSize(size: ImageSize): { width: number; height: number } {
38
+ if (typeof size === 'number') return { width: size, height: size };
39
+ return { width: size.width, height: size.height ?? size.width };
40
+ }
41
+
42
+ /**
43
+ * Resolve how an image field renders in a given view. `renderer: 'avatar'`
44
+ * wins over per-view sizing; otherwise `field.image[view]` overrides the
45
+ * sane default for that view.
46
+ */
47
+ export function imageRenderSpec(field: FieldDefinition, view: ImageView): ImageRenderSpec {
48
+ if (field.renderer === 'avatar') {
49
+ const { width, height } = SIZE_DEFAULTS.avatar;
50
+ return { width, height, transform: { width, height, fit: 'cover' }, showCaption: false, isAvatar: true };
51
+ }
52
+ const override = field.image?.[view];
53
+ const { width, height } = override ? normalizeSize(override) : SIZE_DEFAULTS[view];
54
+ return {
55
+ width,
56
+ height,
57
+ transform: { width, height, fit: 'cover' },
58
+ showCaption: view === 'show',
59
+ isAvatar: false,
60
+ };
61
+ }
62
+
63
+ /** List/show cell precedence: custom render → image → type text. */
64
+ export function cellRenderKind(field: FieldDefinition): 'custom' | 'image' | 'text' {
65
+ if (field.render) return 'custom';
66
+ if (field.type === 'image') return 'image';
67
+ return 'text';
68
+ }
69
+
70
+ /** Row action precedence: render → onPress → rpc (none if unconfigured). */
71
+ export function actionBehavior(action: RowActionConfig): 'render' | 'onPress' | 'rpc' | 'none' {
72
+ if (action.render) return 'render';
73
+ if (action.onPress) return 'onPress';
74
+ if (action.rpc) return 'rpc';
75
+ return 'none';
76
+ }
77
+
78
+ /**
79
+ * Resolve a registered editor for a field: by explicit `field.editor` name,
80
+ * else by a registry entry matching `field.type`. Returns undefined when none
81
+ * is registered (the form falls back to the built-in type input).
82
+ */
83
+ export function editorFor(
84
+ field: FieldDefinition,
85
+ registry: EditorRegistry | undefined,
86
+ ): EditorComponent | undefined {
87
+ if (!registry) return undefined;
88
+ if (field.editor && registry[field.editor]) return registry[field.editor];
89
+ if (field.type && registry[field.type]) return registry[field.type];
90
+ return undefined;
91
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Standard row actions — the built-in Show / Edit / Delete buttons every CRUD
3
+ * list offers, gated by the resource's `actions.{show,edit,delete}` flags.
4
+ *
5
+ * They are ordinary RowActionConfigs (see RowActionButton), so they render in
6
+ * the same actions column as custom rowActions and share the confirm/busy/error
7
+ * chrome. Show/Edit navigate via the supplied callbacks; Delete self-serves
8
+ * through adapter.delete and lets RowActionButton refetch afterward.
9
+ */
10
+
11
+ import type { ResourceConfig, RowActionConfig, DataRecord } from '../types';
12
+
13
+ export interface StandardActionNav {
14
+ /** Navigate to the record's detail view (also the row-press target). */
15
+ onShow?: (record: DataRecord) => void;
16
+ /** Navigate to the record's edit screen. */
17
+ onEdit?: (record: DataRecord) => void;
18
+ }
19
+
20
+ export function standardRowActions(
21
+ resourceConfig: ResourceConfig,
22
+ nav: StandardActionNav,
23
+ ): RowActionConfig[] {
24
+ const actions = resourceConfig.actions ?? {};
25
+ const primaryKey = resourceConfig.primaryKey ?? 'id';
26
+ const idOf = (record: DataRecord): string | number =>
27
+ (record[primaryKey] as string | number) ?? (record.id as string | number);
28
+
29
+ const out: RowActionConfig[] = [];
30
+
31
+ if (actions.show !== false && nav.onShow) {
32
+ out.push({ id: '__show', label: 'Show', variant: 'outline', onPress: ({ record }) => nav.onShow!(record) });
33
+ }
34
+ if (actions.edit !== false && nav.onEdit) {
35
+ out.push({ id: '__edit', label: 'Edit', variant: 'outline', onPress: ({ record }) => nav.onEdit!(record) });
36
+ }
37
+ if (actions.delete !== false) {
38
+ out.push({
39
+ id: '__delete',
40
+ label: 'Delete',
41
+ variant: 'destructive',
42
+ confirm: 'Confirm delete',
43
+ onPress: ({ record, adapter }) => adapter.delete(resourceConfig.name, idOf(record)),
44
+ });
45
+ }
46
+
47
+ return out;
48
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Pure mappers between URL query params and analytics/table filters.
3
+ *
4
+ * Kept React-free so they're trivially testable and reusable: the URL is the
5
+ * source of truth for a resource's filter state, these translate it to the
6
+ * AnalyticsHeader's DashboardFilters and the CRUD table's PostgREST conditions.
7
+ */
8
+
9
+ import type { ViewParams } from '../hooks/useViewState';
10
+ import type { DimensionConfig, ResourceDashboardConfig } from '../types';
11
+ import type { DashboardFilters } from '../rpc/resource-dashboard-rpc';
12
+
13
+ /** URL query → analytics filters (time range + per-dimension selections). */
14
+ export function paramsToFilters(params: ViewParams, dimensions: DimensionConfig[]): DashboardFilters {
15
+ const filters: DashboardFilters = {};
16
+ if (params.from && params.to) filters.time = [params.from, params.to];
17
+ for (const dim of dimensions) {
18
+ const v = params[dim.column];
19
+ if (v) filters[dim.column] = v.split(',');
20
+ }
21
+ return filters;
22
+ }
23
+
24
+ /** Analytics filters → a query-param delta that clears then sets controlled keys. */
25
+ export function filtersToParams(filters: DashboardFilters, dimensions: DimensionConfig[]): Record<string, string | undefined> {
26
+ const update: Record<string, string | undefined> = { from: undefined, to: undefined };
27
+ for (const dim of dimensions) update[dim.column] = undefined;
28
+ if (filters.time && filters.time[0] && filters.time[1]) {
29
+ update.from = filters.time[0];
30
+ update.to = filters.time[1];
31
+ }
32
+ for (const dim of dimensions) {
33
+ const v = filters[dim.column] as string[] | undefined;
34
+ if (v && v.length) update[dim.column] = v.join(',');
35
+ }
36
+ return update;
37
+ }
38
+
39
+ /** Query params reserved for view state (not column filters). */
40
+ const RESERVED_PARAMS = new Set(['from', 'to', 'sort', 'order', 'page', 'q']);
41
+
42
+ /**
43
+ * Pass-through column filters: any query param that names a real resource field
44
+ * (and isn't reserved or already a dimension) becomes a table filter. Powers
45
+ * arbitrary drill-downs like `?traceId=abc` or `?group_id=<fingerprint>`. Values
46
+ * are passed as-is — the adapter wraps a plain value with `eq.`.
47
+ */
48
+ export function paramsToColumnFilters(
49
+ params: ViewParams,
50
+ fieldNames: Set<string>,
51
+ exclude: Set<string> = new Set(),
52
+ ): Record<string, unknown> {
53
+ const f: Record<string, unknown> = {};
54
+ for (const [k, v] of Object.entries(params)) {
55
+ if (RESERVED_PARAMS.has(k) || exclude.has(k)) continue;
56
+ if (fieldNames.has(k) && v) f[k] = v;
57
+ }
58
+ return f;
59
+ }
60
+
61
+ /** Analytics filters → the table's baseFilter (PostgREST conditions). */
62
+ export function filtersToBaseFilter(
63
+ filters: DashboardFilters,
64
+ dashboard: ResourceDashboardConfig,
65
+ ): Record<string, unknown> | undefined {
66
+ const f: Record<string, unknown> = {};
67
+ if (filters.time && filters.time[0] && filters.time[1]) {
68
+ f[dashboard.timestampColumn] = [`gte.${filters.time[0]}`, `lte.${filters.time[1]}`];
69
+ }
70
+ for (const dim of dashboard.dimensions) {
71
+ const vals = filters[dim.column] as string[] | undefined;
72
+ if (vals && vals.length) f[dim.column] = `in.(${vals.join(',')})`;
73
+ }
74
+ return Object.keys(f).length ? f : undefined;
75
+ }