@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.
- package/CHANGELOG.md +96 -1
- package/package.json +46 -12
- package/src/adapters/EverystackAdapter.ts +127 -12
- package/src/adapters/types.ts +22 -5
- package/src/components/AdminLink.tsx +59 -0
- package/src/components/CellText.tsx +18 -0
- package/src/components/DeletePreview.tsx +109 -0
- package/src/components/FieldColumns.tsx +86 -0
- package/src/components/FormActionBar.tsx +45 -0
- package/src/components/InlineEnumCell.tsx +98 -0
- package/src/components/RelationFieldInput.tsx +159 -0
- package/src/components/RelationValue.tsx +26 -0
- package/src/components/RowActionButton.tsx +24 -9
- package/src/components/TagsDisplay.tsx +22 -0
- package/src/components/TagsFieldInput.tsx +95 -0
- package/src/editors/rich-html-extensions.ts +63 -0
- package/src/editors/tiptap.tsx +121 -20
- package/src/editors/unsupported-markup.ts +150 -0
- package/src/hooks/useAdminData.ts +108 -24
- package/src/hooks/useAdminNavigation.ts +19 -0
- package/src/hooks/useListQuery.ts +47 -0
- package/src/hooks/useRelationLabels.ts +131 -0
- package/src/hooks/useScopeCounts.ts +77 -0
- package/src/hooks/useScrollToFirstError.ts +74 -0
- package/src/index.ts +17 -0
- package/src/screens/AdminRouter.tsx +30 -4
- package/src/screens/ResourceCreate.tsx +87 -37
- package/src/screens/ResourceEdit.tsx +93 -42
- package/src/screens/ResourceList.tsx +241 -53
- package/src/screens/ResourceShow.tsx +130 -22
- package/src/types.ts +197 -4
- package/src/utils/format-cell-value.ts +5 -0
- package/src/utils/list-query.ts +202 -0
- package/src/utils/required.ts +10 -0
- package/src/utils/resolve-columns.ts +107 -0
- package/src/utils/row-actions.ts +28 -0
- package/src/utils/scope-counts.ts +65 -0
- package/src/utils/scopes.ts +38 -0
- package/src/utils/standard-actions.ts +41 -5
- package/src/utils/tags.ts +40 -0
- 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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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
|
+
}
|
package/src/utils/row-actions.ts
CHANGED
|
@@ -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
|
+
}
|