@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/package.json +16 -1
- package/src/AdminRoot.tsx +8 -3
- package/src/adapters/EverystackAdapter.ts +110 -31
- package/src/components/AnalyticsHeader.tsx +134 -0
- package/src/components/ImageDisplay.tsx +14 -10
- package/src/components/RowActionButton.tsx +109 -0
- package/src/editors/tiptap.tsx +69 -0
- package/src/hooks/useAdminData.ts +58 -20
- package/src/hooks/useEditors.ts +17 -0
- package/src/hooks/useViewState.ts +51 -0
- package/src/index.ts +35 -0
- package/src/presets/twitter.ts +4 -4
- package/src/screens/AdminLayout.tsx +166 -23
- package/src/screens/AdminRouter.tsx +137 -0
- package/src/screens/AdminShell.tsx +66 -0
- package/src/screens/ResourceCreate.tsx +13 -0
- package/src/screens/ResourceEdit.tsx +16 -1
- package/src/screens/ResourceList.tsx +86 -15
- package/src/screens/ResourceListView.tsx +82 -0
- package/src/screens/ResourceShow.tsx +13 -84
- package/src/screens/index.ts +6 -0
- package/src/types.ts +123 -6
- package/src/utils/admin-routing.ts +66 -0
- package/src/utils/breadcrumbs.ts +81 -0
- package/src/utils/render-resolution.ts +91 -0
- package/src/utils/standard-actions.ts +48 -0
- package/src/utils/view-filters.ts +75 -0
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
|
|
65
|
-
*
|
|
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
|
|
74
|
-
/**
|
|
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
|
+
}
|