@everystack/admin 0.4.2 → 0.4.4

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 (41) hide show
  1. package/CHANGELOG.md +96 -1
  2. package/package.json +46 -12
  3. package/src/adapters/EverystackAdapter.ts +127 -12
  4. package/src/adapters/types.ts +22 -5
  5. package/src/components/AdminLink.tsx +59 -0
  6. package/src/components/CellText.tsx +18 -0
  7. package/src/components/DeletePreview.tsx +109 -0
  8. package/src/components/FieldColumns.tsx +86 -0
  9. package/src/components/FormActionBar.tsx +45 -0
  10. package/src/components/InlineEnumCell.tsx +98 -0
  11. package/src/components/RelationFieldInput.tsx +159 -0
  12. package/src/components/RelationValue.tsx +26 -0
  13. package/src/components/RowActionButton.tsx +24 -9
  14. package/src/components/TagsDisplay.tsx +22 -0
  15. package/src/components/TagsFieldInput.tsx +95 -0
  16. package/src/editors/rich-html-extensions.ts +63 -0
  17. package/src/editors/tiptap.tsx +121 -20
  18. package/src/editors/unsupported-markup.ts +150 -0
  19. package/src/hooks/useAdminData.ts +108 -24
  20. package/src/hooks/useAdminNavigation.ts +19 -0
  21. package/src/hooks/useListQuery.ts +47 -0
  22. package/src/hooks/useRelationLabels.ts +131 -0
  23. package/src/hooks/useScopeCounts.ts +77 -0
  24. package/src/hooks/useScrollToFirstError.ts +74 -0
  25. package/src/index.ts +17 -0
  26. package/src/screens/AdminRouter.tsx +30 -4
  27. package/src/screens/ResourceCreate.tsx +87 -37
  28. package/src/screens/ResourceEdit.tsx +93 -42
  29. package/src/screens/ResourceList.tsx +241 -53
  30. package/src/screens/ResourceShow.tsx +130 -22
  31. package/src/types.ts +197 -4
  32. package/src/utils/format-cell-value.ts +5 -0
  33. package/src/utils/list-query.ts +202 -0
  34. package/src/utils/required.ts +10 -0
  35. package/src/utils/resolve-columns.ts +107 -0
  36. package/src/utils/row-actions.ts +28 -0
  37. package/src/utils/scope-counts.ts +65 -0
  38. package/src/utils/scopes.ts +38 -0
  39. package/src/utils/standard-actions.ts +41 -5
  40. package/src/utils/tags.ts +40 -0
  41. package/src/utils/view-filters.ts +4 -2
package/src/types.ts CHANGED
@@ -38,7 +38,11 @@ export interface ActionContext {
38
38
  adapter: DataAdapter;
39
39
  /** Re-fetch the current view after a mutation. */
40
40
  refetch: () => Promise<void> | void;
41
- /** Navigate to an admin path, when the route layer provides it. */
41
+ /**
42
+ * Go to an admin path. Optional: RowActionButton passes it through when its
43
+ * caller supplies one, but the built-in ResourceList and ResourceShow do not,
44
+ * so it is undefined there. Check it before you call it.
45
+ */
42
46
  navigate?: (path: string) => void;
43
47
  }
44
48
 
@@ -53,6 +57,11 @@ export interface InputContext {
53
57
  field: FieldDefinition;
54
58
  record?: DataRecord;
55
59
  error?: string;
60
+ /**
61
+ * The whole form's current values, keyed by field name (what Save would
62
+ * send). Read another field here to render a block that depends on it.
63
+ */
64
+ values: Record<string, unknown>;
56
65
  }
57
66
 
58
67
  /**
@@ -100,7 +109,18 @@ export type FieldType =
100
109
  | 'enum'
101
110
  | 'relation'
102
111
  | 'image'
103
- | 'file';
112
+ | 'file'
113
+ | 'tags';
114
+
115
+ /**
116
+ * One group of options for a `tags` field. `label` is shown as a small heading
117
+ * above the group's pills; leave it out for a flat set. An option's label
118
+ * defaults to its value.
119
+ */
120
+ export interface TagGroup {
121
+ label?: string;
122
+ options: Array<{ value: string; label?: string }>;
123
+ }
104
124
 
105
125
  export interface FieldDefinition {
106
126
  /** Field name (matches data key) */
@@ -117,6 +137,19 @@ export interface FieldDefinition {
117
137
  filterable?: boolean;
118
138
  /** For enum types: allowed values */
119
139
  enumValues?: string[];
140
+ /**
141
+ * For enum types: edit the value from the list cell. The cell shows a picker
142
+ * of `enumValues` and saves a pick with `adapter.update(resource, id, { [name]: value })`.
143
+ * Ignored for other types, and when a `render` override is set.
144
+ * Follows `actions.edit`: a resource with `actions.edit: false` shows the plain text cell.
145
+ */
146
+ inlineEdit?: boolean;
147
+ /**
148
+ * For tags types (a text[] column): the options, as pills in groups. A press
149
+ * toggles one value and keeps every other stored value, including values in
150
+ * no group (they show in an "Other" row).
151
+ */
152
+ tagGroups?: TagGroup[];
120
153
  /** For relation types: related resource name */
121
154
  relation?: {
122
155
  resource: string;
@@ -131,6 +164,26 @@ export interface FieldDefinition {
131
164
  pattern?: string;
132
165
  message?: string;
133
166
  };
167
+ /**
168
+ * Fixed width of this field's list column, in px (list view only). The
169
+ * column then neither grows nor shrinks; without it the column shares the
170
+ * remaining space.
171
+ */
172
+ listWidth?: number;
173
+ /**
174
+ * How soon this field's list column drops when the list is too narrow
175
+ * (list view only; the Show page keeps every field). 1 (or less) never
176
+ * drops, a larger number drops sooner, and a field without one drops
177
+ * before every field that has one. Nothing drops until some field sets
178
+ * `listPriority` or `listHideBelow`; the Actions column never drops.
179
+ */
180
+ listPriority?: number;
181
+ /**
182
+ * Drop this field's list column while the list's container is narrower
183
+ * than this many px, even if it would fit (list view only). Ignored on a
184
+ * field that never drops (`listPriority` of 1 or less).
185
+ */
186
+ listHideBelow?: number;
134
187
  /** Renderer variant for this field type (e.g., 'avatar' for image fields) */
135
188
  renderer?: string;
136
189
  /**
@@ -146,6 +199,9 @@ export interface FieldDefinition {
146
199
  /**
147
200
  * Custom form input (create/edit) — wins over the type/renderer input. The
148
201
  * custom input owns its element (control + optional label/error frame).
202
+ * It is rendered as its own component, so hooks inside it are fine. Pass a
203
+ * stable function (declared once, not an inline arrow made on each render),
204
+ * or the input remounts and loses its state every render.
149
205
  */
150
206
  input?: (ctx: InputContext) => ReactNode;
151
207
  /**
@@ -162,6 +218,26 @@ export interface FieldDefinition {
162
218
  // Resource Configuration
163
219
  // =============================================================================
164
220
 
221
+ /**
222
+ * One column of a Show/Edit/Create screen. `width`: 'wide' takes the space
223
+ * left over; 'narrow' is a fixed 320px; a number is a fixed width in px. The
224
+ * default is 'wide' for the first column and 'narrow' for the others.
225
+ */
226
+ export interface LayoutColumn {
227
+ /** Field names, top to bottom. */
228
+ fields: string[];
229
+ width?: 'wide' | 'narrow' | number;
230
+ }
231
+
232
+ /**
233
+ * How a Show/Edit/Create screen places its fields. 'auto' puts long fields
234
+ * (custom render/input, json, image, file, tags, relation, rich or long text)
235
+ * in a wide main column and short ones in a narrow side column. A field no
236
+ * column lists goes at the end of the first column. Columns stack below a
237
+ * 720px container.
238
+ */
239
+ export type ScreenLayout = 'auto' | { columns: LayoutColumn[] };
240
+
165
241
  /**
166
242
  * A declarative action on a single record (delete/restore, retry/cancel a job),
167
243
  * rendered on both the list and show views and gated by the `when` clause.
@@ -214,8 +290,22 @@ export interface ResourceConfig {
214
290
  edit?: boolean;
215
291
  delete?: boolean;
216
292
  };
293
+ /**
294
+ * Rows per page when the list opens. Default 20. The page bar also offers
295
+ * 20, 50 and 100 (and this size, when it is not one of them); the chosen
296
+ * size is kept in the URL as `size`. A value that is not a whole number
297
+ * above 0 is ignored; one above 500 is capped to 500.
298
+ */
299
+ perPage?: number;
217
300
  /** Fields to display in list view */
218
301
  listFields?: string[];
302
+ /**
303
+ * Fixed width of the list's Actions column, in px. Default, or when this is
304
+ * not a finite number above 0: an estimate from the idle labels of the
305
+ * actions the column renders, built-in and rowActions. A longer two-step
306
+ * confirm label wraps to a second line inside the cell.
307
+ */
308
+ actionsWidth?: number;
219
309
  /** Fields to display in detail/show view */
220
310
  showFields?: string[];
221
311
  /** Fields to display in create form */
@@ -224,6 +314,12 @@ export interface ResourceConfig {
224
314
  editFields?: string[];
225
315
  /** Primary key field name */
226
316
  primaryKey?: string;
317
+ /**
318
+ * PostgREST select string for list, show and edit, embeds included, e.g.
319
+ * '*,user:users(name)'. One string for all three. Show an embedded value
320
+ * through a field `render`. RPC-backed resources ignore it.
321
+ */
322
+ select?: string;
227
323
  /** How the show view fetches one record. 'rpc' calls the role-gated
228
324
  * {camelResource}EventDetail RPC (the resource stays off the REST surface);
229
325
  * 'rest' (default) uses adapter.fetchOne. Lets a plain list (no dashboard) —
@@ -231,8 +327,45 @@ export interface ResourceConfig {
231
327
  detailVia?: 'rpc' | 'rest';
232
328
  /** Optional observability-style dashboard configuration */
233
329
  dashboard?: ResourceDashboardConfig;
234
- /** RPC-backed record actions shown on the show view */
330
+ /**
331
+ * Custom record actions (rpc, onPress or render). They show on the show view
332
+ * and in the list's Actions column, after the built-in Show/Edit/Delete.
333
+ */
235
334
  rowActions?: RowActionConfig[];
335
+ /** Tabs above the list, each a named filter. Without this the list shows no tabs. */
336
+ scopes?: ScopeConfig[];
337
+ /**
338
+ * The table soft-deletes. Turns on Restore for deleted rows (in a scope with
339
+ * `deleted: 'only'`, and on Show) and makes Show load a deleted row. Delete
340
+ * stays shown unless `actions.delete` is false.
341
+ */
342
+ softDelete?: {
343
+ /** The soft-delete column, as the API names it. Default 'deletedAt'. */
344
+ column?: string;
345
+ };
346
+ /**
347
+ * Rows in other tables that point at this record. Before a delete, Show lists
348
+ * each one with a count of the linked rows (one request each, at most 12).
349
+ * Without it, Delete works as before.
350
+ */
351
+ associations?: AssociationConfig[];
352
+ /**
353
+ * Show a row count on each tab. Off unless set: counts cost queries. The values of one
354
+ * enum column share one grouped query; every other tab costs one count. Counts follow the
355
+ * search, and need an adapter with `aggregate`.
356
+ */
357
+ scopeCounts?: boolean;
358
+ /**
359
+ * Field columns for Show, Edit and Create. Without any layout, each screen
360
+ * shows one column of fields, as before.
361
+ */
362
+ layout?: ScreenLayout;
363
+ /** Show only: wins over `layout`. */
364
+ showLayout?: ScreenLayout;
365
+ /** Edit (and Create, unless `createLayout` is set): wins over `layout`. */
366
+ editLayout?: ScreenLayout;
367
+ /** Create only: wins over `editLayout` and `layout`. */
368
+ createLayout?: ScreenLayout;
236
369
  }
237
370
 
238
371
  // =============================================================================
@@ -452,6 +585,43 @@ export interface AdminConfig {
452
585
  // Data Types
453
586
  // =============================================================================
454
587
 
588
+ /**
589
+ * A table that links to a record, counted in the delete preview. A polymorphic
590
+ * link (`resource_id` + `resource_type`, often with no foreign key) sets
591
+ * `typeColumn` and `typeValue` as well.
592
+ */
593
+ export interface AssociationConfig {
594
+ /** Shown in the preview, e.g. 'Bookmarks'. */
595
+ label: string;
596
+ /** The related table or resource name, as the adapter knows it. */
597
+ resource: string;
598
+ /** The column on the related table that holds this record's id. */
599
+ foreignKey: string;
600
+ /** The polymorphic type column on the related table, e.g. 'resource_type'. */
601
+ typeColumn?: string;
602
+ /** The value of `typeColumn` that means this resource, e.g. 'Content'. */
603
+ typeValue?: string;
604
+ }
605
+
606
+ /**
607
+ * A named filter shown as a tab above a list ("Current", "Draft"). Scopes are
608
+ * ANDed with the search and any column filters.
609
+ */
610
+ export interface ScopeConfig {
611
+ /** Stable id; this is what the URL carries (`?tab=draft`). */
612
+ id: string;
613
+ label: string;
614
+ /** Filter applied while this scope is active, in the adapter's filter syntax. None means all rows. */
615
+ filter?: Record<string, unknown>;
616
+ /** The scope active when the URL names none. If none is marked, the first is. */
617
+ default?: boolean;
618
+ /** Soft-deleted rows: 'include' adds them, 'only' lists nothing else. See ListParams.deleted. */
619
+ deleted?: DeletedMode;
620
+ }
621
+
622
+ /** Soft-delete reads: 'include' returns live and deleted rows, 'only' deleted rows. */
623
+ export type DeletedMode = 'include' | 'only';
624
+
455
625
  export interface ListParams {
456
626
  /** Page number (1-indexed) */
457
627
  page?: number;
@@ -463,8 +633,25 @@ export interface ListParams {
463
633
  order?: 'asc' | 'desc';
464
634
  /** Filter conditions */
465
635
  filter?: Record<string, unknown>;
466
- /** Search query */
636
+ /** Search text, matched (ILIKE, case-insensitive) in any of `searchFields`. */
467
637
  search?: string;
638
+ /**
639
+ * Columns `search` looks in. Required with `search`: the adapter does not know
640
+ * your schema, so it refuses search text without them rather than silently
641
+ * returning unfiltered rows. Plain column names only.
642
+ */
643
+ searchFields?: string[];
644
+ /**
645
+ * PostgREST select string, embeds included, e.g. '*,user:users(name)'.
646
+ * Passed verbatim to the query builder. Ignored by RPC-backed resources
647
+ * (listRpcs); an RPC decides its own shape.
648
+ */
649
+ select?: string;
650
+ /**
651
+ * Soft-deleted rows, sent as `?deleted=` to the API. RPC-backed resources
652
+ * get it in the RPC body, and the RPC owner decides.
653
+ */
654
+ deleted?: DeletedMode;
468
655
  }
469
656
 
470
657
  export interface ListResult<T = Record<string, unknown>> {
@@ -507,6 +694,12 @@ export interface AggregateParams {
507
694
  groupBy?: string[];
508
695
  /** Filter conditions */
509
696
  filter?: Record<string, unknown>;
697
+ /** Search text, matched in any of `searchFields`, exactly as `ListParams.search`. */
698
+ search?: string;
699
+ /** Columns `search` looks in. Required with `search`. Plain column names only. */
700
+ searchFields?: string[];
701
+ /** Soft-deleted rows, exactly as `ListParams.deleted`, so a Deleted tab counts deleted rows. */
702
+ deleted?: DeletedMode;
510
703
  }
511
704
 
512
705
  export interface AggregateResult {
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { FieldDefinition } from '../types';
10
+ import { tagLabel, tagValues } from './tags';
10
11
 
11
12
  /**
12
13
  * A date-only ISO string has no time-of-day; `new Date(str)` parses it as UTC midnight, and
@@ -63,6 +64,10 @@ export function formatCellValue(field: FieldDefinition, value: unknown): string
63
64
  return value ? 'Yes' : 'No';
64
65
  case 'json':
65
66
  return typeof value === 'string' ? value : JSON.stringify(value);
67
+ case 'tags': {
68
+ const tags = tagValues(value);
69
+ return tags.length === 0 ? '—' : tags.map(tag => tagLabel(field, tag)).join(', ');
70
+ }
66
71
  default:
67
72
  return typeof value === 'object' ? JSON.stringify(value) : String(value);
68
73
  }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Pure model of a list's query state: search text, scope tab, sort and page.
3
+ *
4
+ * The URL is the source of truth (see useViewState), so every list view is a
5
+ * link. This file holds the rules and nothing else: parse the URL, write it
6
+ * back, apply a change, and turn the state into the adapter's ListParams. It
7
+ * knows no React and no resource schema, which keeps it testable on its own.
8
+ *
9
+ * URL names: `q`, `tab`, `sort`, `dir`, `page`, `size`. They are reserved (see view-filters.ts), so a
10
+ * resource column with one of those names cannot be drilled into from the URL. `tab` and
11
+ * `dir`, not `scope` and `order`, because those are common column names.
12
+ *
13
+ * Column filters from the URL (`?state=draft`) are a separate mechanism
14
+ * (view-filters.ts) and are left alone here.
15
+ */
16
+
17
+ import type { ViewParams } from '../hooks/useViewState';
18
+ import type { DeletedMode, ListParams } from '../types';
19
+
20
+ /** Longest search text kept. A pasted paragraph is not a search. */
21
+ export const MAX_SEARCH_LENGTH = 200;
22
+
23
+ /** Search text only: PostgreSQL text values cannot contain NUL. */
24
+ export function normalizeSearchText(text?: string): string | undefined {
25
+ return text?.replace(/\u0000/g, '').trim().slice(0, MAX_SEARCH_LENGTH) || undefined;
26
+ }
27
+
28
+ export interface ListQuery {
29
+ search?: string;
30
+ scope?: string;
31
+ sort?: string;
32
+ order?: 'asc' | 'desc';
33
+ /** 1-based. Absent means page 1. */
34
+ page?: number;
35
+ /** Rows per page (`size` in the URL). Absent means the resource's default. */
36
+ perPage?: number;
37
+ }
38
+
39
+ /** Largest page size the list fetches. A larger `size` in the URL is capped to it. */
40
+ export const MAX_PAGE_SIZE = 500;
41
+
42
+ /**
43
+ * The page size to use for a resource's configured `perPage`: a whole number
44
+ * above 0, capped to MAX_PAGE_SIZE. Anything else gives `fallback`, so a bad
45
+ * config never reaches the adapter or the page bar.
46
+ */
47
+ export function resolvePageSize(configured: number | undefined, fallback = 20): number {
48
+ if (configured === undefined || !Number.isInteger(configured) || configured <= 0) return fallback;
49
+ return Math.min(configured, MAX_PAGE_SIZE);
50
+ }
51
+
52
+ /**
53
+ * The page that holds the first row of `page` once the list shows `newSize`
54
+ * rows a page, so changing the size keeps that row in view.
55
+ */
56
+ export function pageForSize(page: number, oldSize: number, newSize: number): number {
57
+ return Math.floor(((page - 1) * oldSize) / newSize) + 1;
58
+ }
59
+
60
+ const POSITIVE_INTEGER = /^[1-9]\d*$/;
61
+ /** No real list has more pages. A larger number is not a page: sent on, it is read as an exponent. */
62
+ const MAX_PAGE = 1_000_000;
63
+
64
+ /** URL query → list state. Anything malformed is ignored, never thrown. */
65
+ export function parseListQuery(params: ViewParams): ListQuery {
66
+ const query: ListQuery = {};
67
+
68
+ const search = normalizeSearchText(params.q);
69
+ if (search) query.search = search;
70
+
71
+ const scope = params.tab?.trim();
72
+ if (scope) query.scope = scope;
73
+
74
+ const sort = params.sort?.trim();
75
+ if (sort) query.sort = sort;
76
+
77
+ if (params.dir === 'asc' || params.dir === 'desc') query.order = params.dir;
78
+
79
+ if (params.page && POSITIVE_INTEGER.test(params.page) && Number(params.page) <= MAX_PAGE) query.page = Number(params.page);
80
+
81
+ if (params.size && POSITIVE_INTEGER.test(params.size)) query.perPage = Math.min(Number(params.size), MAX_PAGE_SIZE);
82
+
83
+ return query;
84
+ }
85
+
86
+ /**
87
+ * List state → a `setParams` delta. Every key is present, so a cleared field
88
+ * removes its key from the URL. Defaults (page 1, no search, the resource's
89
+ * `defaultPerPage` and `defaultScope`) are not written.
90
+ */
91
+ export function listQueryToParams(
92
+ query: ListQuery,
93
+ options: { defaultPerPage?: number; defaultScope?: string } = {},
94
+ ): Record<string, string | undefined> {
95
+ return {
96
+ q: query.search || undefined,
97
+ tab: query.scope && query.scope !== options.defaultScope ? query.scope : undefined,
98
+ sort: query.sort || undefined,
99
+ dir: query.order,
100
+ page: query.page && query.page > 1 ? String(query.page) : undefined,
101
+ size: query.perPage && query.perPage !== options.defaultPerPage ? String(query.perPage) : undefined,
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Apply a change. Changing what the list shows (search, scope, sort) returns to
107
+ * page 1, because the old page number no longer means anything. A patch that
108
+ * names a page keeps it.
109
+ */
110
+ export function updateListQuery(prev: ListQuery, patch: Partial<ListQuery>): ListQuery {
111
+ const next: ListQuery = { ...prev, ...patch };
112
+ const changesView = (['search', 'scope', 'sort', 'order'] as const).some(
113
+ key => key in patch && patch[key] !== prev[key],
114
+ );
115
+ if (changesView && !('page' in patch)) next.page = undefined;
116
+ return next;
117
+ }
118
+
119
+ /**
120
+ * AND two filter objects together. Two conditions on one column are kept as an
121
+ * array (the adapter applies an array as several ANDed conditions), so a scope
122
+ * and a column filter on the same column cannot overwrite each other.
123
+ */
124
+ export function mergeFilters(
125
+ a?: Record<string, unknown>,
126
+ b?: Record<string, unknown>,
127
+ ): Record<string, unknown> | undefined {
128
+ const merged: Record<string, unknown> = {};
129
+ for (const source of [a, b]) {
130
+ if (!source) continue;
131
+ for (const [key, value] of Object.entries(source)) {
132
+ if (value == null) continue;
133
+ merged[key] = key in merged ? [...toArray(merged[key]), ...toArray(value)] : value;
134
+ }
135
+ }
136
+ return Object.keys(merged).length ? merged : undefined;
137
+ }
138
+
139
+ const toArray = (value: unknown): unknown[] => (Array.isArray(value) ? value : [value]);
140
+
141
+ export interface BuildListParamsOptions {
142
+ /** The resource's default page size, used when the query has none. */
143
+ perPage?: number;
144
+ /** Filters from outside the query (analytics header, URL column filters). */
145
+ baseFilter?: Record<string, unknown>;
146
+ /** The active scope's filter, already resolved from the scope id. */
147
+ scopeFilter?: Record<string, unknown>;
148
+ /** The active scope's `deleted`, copied into ListParams beside the filter. */
149
+ scopeDeleted?: DeletedMode;
150
+ /** Columns a user may sort by. A sort outside this set is dropped. */
151
+ sortableFields?: ReadonlySet<string>;
152
+ /** Used when the query has no valid sort of its own. */
153
+ defaultSort?: { field: string; order?: 'asc' | 'desc' };
154
+ }
155
+
156
+ /** List state → the adapter's ListParams. */
157
+ export function buildListParams(query: ListQuery, options: BuildListParamsOptions): ListParams {
158
+ const sortAllowed = query.sort && (!options.sortableFields || options.sortableFields.has(query.sort));
159
+
160
+ let sort: string | undefined;
161
+ let order: 'asc' | 'desc' | undefined;
162
+ if (sortAllowed) {
163
+ sort = query.sort;
164
+ order = query.order ?? 'asc';
165
+ } else if (options.defaultSort && (!options.sortableFields || options.sortableFields.has(options.defaultSort.field))) {
166
+ sort = options.defaultSort.field;
167
+ order = options.defaultSort.order ?? 'asc';
168
+ }
169
+
170
+ return {
171
+ page: query.page ?? 1,
172
+ perPage: query.perPage ?? options.perPage,
173
+ search: query.search,
174
+ sort,
175
+ order,
176
+ filter: mergeFilters(options.baseFilter, options.scopeFilter),
177
+ ...(options.scopeDeleted ? { deleted: options.scopeDeleted } : {}),
178
+ };
179
+ }
180
+
181
+ /** What the list is sorted by right now, defaults included. */
182
+ export interface EffectiveSort {
183
+ sort?: string;
184
+ order?: 'asc' | 'desc';
185
+ }
186
+
187
+ /**
188
+ * The next sort after a click on a column header: ascending, then descending,
189
+ * then off (back to the default sort). On the default sort's own column "off"
190
+ * would be the same view, so it flips between ascending and descending instead.
191
+ * `off` is `{ sort: undefined, order: undefined }`, which clears both from the URL.
192
+ */
193
+ export function nextSort(
194
+ current: EffectiveSort,
195
+ key: string,
196
+ defaultSort?: { field: string; order?: 'asc' | 'desc' },
197
+ ): EffectiveSort {
198
+ if (current.sort !== key) return { sort: key, order: 'asc' };
199
+ if ((current.order ?? 'asc') === 'asc') return { sort: key, order: 'desc' };
200
+ if (defaultSort?.field === key) return { sort: key, order: 'asc' };
201
+ return { sort: undefined, order: undefined };
202
+ }
@@ -0,0 +1,10 @@
1
+ import type { FieldDefinition } from '../types';
2
+
3
+ /**
4
+ * Whether a value fails `validation.required`: null, undefined or ''. For a
5
+ * tags field an empty array fails too.
6
+ */
7
+ export function missingRequired(field: FieldDefinition, value: unknown): boolean {
8
+ if (value == null || value === '') return true;
9
+ return field.type === 'tags' && Array.isArray(value) && value.length === 0;
10
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Field columns for Show, Edit and Create: which field goes in which column,
3
+ * and how wide each column is. Pure; FieldColumns draws the result.
4
+ */
5
+
6
+ import { editorFor } from './render-resolution';
7
+ import type { EditorRegistry, FieldDefinition, LayoutColumn, ResourceConfig, ScreenLayout } from '../types';
8
+
9
+ /** Width of a 'narrow' column, in px. */
10
+ export const NARROW_COLUMN_WIDTH = 320;
11
+
12
+ /** A column ready to draw: 'wide' takes the space left over, a number is fixed px. */
13
+ export interface ResolvedColumn {
14
+ fields: FieldDefinition[];
15
+ width: 'wide' | number;
16
+ }
17
+
18
+ export type LayoutScreen = 'show' | 'edit' | 'create';
19
+
20
+ /**
21
+ * The layout a screen uses: Show `showLayout ?? layout`, Edit
22
+ * `editLayout ?? layout`, Create `createLayout ?? editLayout ?? layout`.
23
+ * Undefined means the screen renders its single column as before.
24
+ */
25
+ export function screenLayout(resource: ResourceConfig, screen: LayoutScreen): ScreenLayout | undefined {
26
+ switch (screen) {
27
+ case 'show':
28
+ return resource.showLayout ?? resource.layout;
29
+ case 'edit':
30
+ return resource.editLayout ?? resource.layout;
31
+ case 'create':
32
+ return resource.createLayout ?? resource.editLayout ?? resource.layout;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * 'auto' puts a field in the main column when it needs room: a custom render
38
+ * or input, json, image, file, tags, relation, rich text, or a string longer
39
+ * than 255 characters.
40
+ */
41
+ export function isMainField(field: FieldDefinition, editors?: EditorRegistry): boolean {
42
+ if (field.render || field.input) return true;
43
+ switch (field.type) {
44
+ case 'json':
45
+ case 'image':
46
+ case 'file':
47
+ case 'tags':
48
+ case 'relation':
49
+ return true;
50
+ }
51
+ if (field.format === 'html' || field.format === 'markdown') return true;
52
+ if (editorFor(field, editors)) return true;
53
+ return field.type === 'string' && (field.validation?.max ?? 0) > 255;
54
+ }
55
+
56
+ /**
57
+ * Place the screen's fields (already filtered and in screen order) into
58
+ * columns. A listed field the screen does not show is skipped; a field listed
59
+ * twice stays in its first column; a field no column lists goes at the end of
60
+ * the first column. Columns left empty are dropped. Default width: the first
61
+ * column wide, the others narrow.
62
+ */
63
+ export function resolveColumns(
64
+ fields: FieldDefinition[],
65
+ layout: ScreenLayout,
66
+ editors?: EditorRegistry,
67
+ ): ResolvedColumn[] {
68
+ const placed: Array<{ fields: FieldDefinition[]; width?: 'wide' | 'narrow' | number }> =
69
+ layout === 'auto' ? autoColumns(fields, editors) : explicitColumns(fields, layout.columns);
70
+
71
+ return placed
72
+ .filter(col => col.fields.length > 0)
73
+ .map((col, index) => ({ fields: col.fields, width: columnWidth(col.width, index) }));
74
+ }
75
+
76
+ function autoColumns(fields: FieldDefinition[], editors?: EditorRegistry) {
77
+ const main = fields.filter(f => isMainField(f, editors));
78
+ const side = fields.filter(f => !isMainField(f, editors));
79
+ return [{ fields: main }, { fields: side }];
80
+ }
81
+
82
+ function explicitColumns(fields: FieldDefinition[], columns: LayoutColumn[]) {
83
+ const byName = new Map(fields.map(f => [f.name, f]));
84
+ const used = new Set<string>();
85
+ // A column that lists no field is ignored, so it never becomes the first.
86
+ const placed = columns.filter(col => col.fields.length > 0).map(col => {
87
+ const picked: FieldDefinition[] = [];
88
+ for (const name of col.fields) {
89
+ const field = byName.get(name);
90
+ if (!field || used.has(name)) continue;
91
+ used.add(name);
92
+ picked.push(field);
93
+ }
94
+ return { fields: picked, width: col.width };
95
+ });
96
+ const unlisted = fields.filter(f => !used.has(f.name));
97
+ if (placed.length === 0) return [{ fields: unlisted }];
98
+ placed[0] = { ...placed[0], fields: [...placed[0].fields, ...unlisted] };
99
+ return placed;
100
+ }
101
+
102
+ function columnWidth(width: 'wide' | 'narrow' | number | undefined, index: number): 'wide' | number {
103
+ if (typeof width === 'number' && Number.isFinite(width) && width > 0) return width;
104
+ if (width === 'wide') return 'wide';
105
+ if (width === 'narrow') return NARROW_COLUMN_WIDTH;
106
+ return index === 0 ? 'wide' : NARROW_COLUMN_WIDTH;
107
+ }
@@ -19,3 +19,31 @@ export function visibleRowActions(
19
19
  return action.when.in.includes(String(value));
20
20
  });
21
21
  }
22
+
23
+ /**
24
+ * Estimated horizontal chrome of one action Button, in px: px-4 padding on both
25
+ * sides (2 x 16) plus a 1px border on both sides. An estimate, not a measurement.
26
+ */
27
+ const ACTION_BUTTON_CHROME = 34;
28
+ /**
29
+ * Estimated average width of one character in the Button's 14px medium label,
30
+ * in px. Measured in a browser on Show, Edit and Delete (70.3, 59.6 and 74.7px
31
+ * wide), which total 220.5px with the gaps; this gives 237px.
32
+ */
33
+ const ACTION_CHAR_WIDTH = 8.5;
34
+ /** Space between action buttons (the cell's gap-2), in px. */
35
+ const ACTION_GAP = 8;
36
+
37
+ /**
38
+ * Estimated width, in px, of a row of action buttons, each sized from its idle
39
+ * label. A two-step `confirm` label is not counted: it replaces the label only
40
+ * after the first press, and the Actions cell wraps if it does not fit. A
41
+ * resource's actionsWidth overrides this estimate.
42
+ */
43
+ export function estimateActionsWidth(actions: RowActionConfig[]): number {
44
+ const buttons = actions.reduce(
45
+ (sum, action) => sum + ACTION_BUTTON_CHROME + Math.ceil(ACTION_CHAR_WIDTH * action.label.length),
46
+ 0,
47
+ );
48
+ return buttons + Math.max(0, actions.length - 1) * ACTION_GAP;
49
+ }