@everystack/admin 0.4.2 → 0.4.3
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 +73 -1
- package/package.json +45 -11
- package/src/adapters/EverystackAdapter.ts +98 -4
- package/src/adapters/types.ts +7 -1
- package/src/components/CellText.tsx +18 -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/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 +104 -24
- package/src/hooks/useListQuery.ts +45 -0
- package/src/hooks/useScopeCounts.ts +72 -0
- package/src/hooks/useScrollToFirstError.ts +74 -0
- package/src/index.ts +7 -0
- package/src/screens/AdminRouter.tsx +11 -0
- package/src/screens/ResourceCreate.tsx +74 -37
- package/src/screens/ResourceEdit.tsx +77 -40
- package/src/screens/ResourceList.tsx +204 -52
- package/src/screens/ResourceShow.tsx +57 -9
- package/src/types.ts +140 -4
- package/src/utils/format-cell-value.ts +5 -0
- package/src/utils/list-query.ts +199 -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 +63 -0
- package/src/utils/scopes.ts +38 -0
- package/src/utils/tags.ts +40 -0
- package/src/utils/view-filters.ts +1 -1
|
@@ -5,13 +5,18 @@
|
|
|
5
5
|
* Fetches via adapter.fetchOne() and displays field values.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import React, { useState, useEffect, useCallback } from 'react';
|
|
8
|
+
import React, { useState, useEffect, useLayoutEffect, useCallback, useRef } from 'react';
|
|
9
9
|
import { View, Text, ScrollView } from 'react-native';
|
|
10
10
|
import { useAdminConfig } from '../hooks/useAdminConfig';
|
|
11
11
|
import { useDataAdapter } from '../hooks/useAdminData';
|
|
12
12
|
import { LoadingState, ErrorState, Button, Card, Divider } from '@everystack/ui';
|
|
13
13
|
import { ImageDisplay } from '../components/ImageDisplay';
|
|
14
|
+
import { TagsDisplay } from '../components/TagsDisplay';
|
|
14
15
|
import { RowActionButton } from '../components/RowActionButton';
|
|
16
|
+
import { FieldColumns } from '../components/FieldColumns';
|
|
17
|
+
import { useEditors } from '../hooks/useEditors';
|
|
18
|
+
import { resolveColumns, screenLayout } from '../utils/resolve-columns';
|
|
19
|
+
import { wrapBareText } from '../components/CellText';
|
|
15
20
|
import { visibleRowActions } from '../utils/row-actions';
|
|
16
21
|
import { toCamelCase } from '../utils/naming';
|
|
17
22
|
import { formatDateOnly } from '../utils/format-cell-value';
|
|
@@ -41,23 +46,46 @@ export function ResourceShow({
|
|
|
41
46
|
const config = useAdminConfig();
|
|
42
47
|
const adapter = useDataAdapter();
|
|
43
48
|
const resourceConfig = config.resources.find(r => r.name === resource);
|
|
49
|
+
const editors = useEditors();
|
|
44
50
|
|
|
45
51
|
const [record, setRecord] = useState<DataRecord | null>(null);
|
|
46
52
|
const [loading, setLoading] = useState(true);
|
|
47
53
|
const [error, setError] = useState<Error | null>(null);
|
|
48
54
|
|
|
49
|
-
|
|
50
|
-
|
|
55
|
+
// Latest-request guard: only the most recent load may write state, so an older
|
|
56
|
+
// id's or an older request's response can't land over a newer one.
|
|
57
|
+
const seqRef = useRef(0);
|
|
58
|
+
// The id on screen now. A load captured for an older id (a row action's refetch
|
|
59
|
+
// after it navigated away) writes nothing.
|
|
60
|
+
const idRef = useRef(id);
|
|
61
|
+
// Set when the id commits, not during render: a render React throws away must not move it.
|
|
62
|
+
// Declared before the fetch effect, so a layout effect always runs first.
|
|
63
|
+
useLayoutEffect(() => { idRef.current = id; }, [id]);
|
|
64
|
+
|
|
65
|
+
// A quiet load keeps the screen (and a row action's error) mounted; it still
|
|
66
|
+
// ends in the ErrorState when the fetch throws, so stale data never stays.
|
|
67
|
+
// A superseded load still resolves; it just writes nothing.
|
|
68
|
+
const loadRecord = useCallback(async (quiet: boolean): Promise<void> => {
|
|
69
|
+
// A call from an older id's closure takes no turn, so it cannot supersede the current load.
|
|
70
|
+
if (id !== idRef.current) return;
|
|
71
|
+
const seq = ++seqRef.current;
|
|
72
|
+
// An id change starts a new load (a new seq), so the seq alone drops an older id's result.
|
|
73
|
+
const latest = (): boolean => seq === seqRef.current;
|
|
74
|
+
if (!quiet) setLoading(true);
|
|
51
75
|
setError(null);
|
|
52
76
|
try {
|
|
53
|
-
|
|
77
|
+
const next = await fetchDetail(adapter, resource, id, resourceConfig);
|
|
78
|
+
if (latest()) setRecord(next);
|
|
54
79
|
} catch (err) {
|
|
55
|
-
setError(err instanceof Error ? err : new Error(String(err)));
|
|
80
|
+
if (latest()) setError(err instanceof Error ? err : new Error(String(err)));
|
|
56
81
|
} finally {
|
|
57
|
-
setLoading(false);
|
|
82
|
+
if (latest()) setLoading(false);
|
|
58
83
|
}
|
|
59
84
|
}, [adapter, resource, id, resourceConfig]);
|
|
60
85
|
|
|
86
|
+
const fetchRecord = useCallback((): Promise<void> => loadRecord(false), [loadRecord]);
|
|
87
|
+
const refetchQuietly = useCallback((): Promise<void> => loadRecord(true), [loadRecord]);
|
|
88
|
+
|
|
61
89
|
useEffect(() => {
|
|
62
90
|
fetchRecord();
|
|
63
91
|
}, [fetchRecord]);
|
|
@@ -84,6 +112,14 @@ export function ResourceShow({
|
|
|
84
112
|
const canEdit = resourceConfig.actions?.edit !== false;
|
|
85
113
|
const canDelete = resourceConfig.actions?.delete !== false;
|
|
86
114
|
const rowActions = visibleRowActions(resourceConfig.rowActions, record);
|
|
115
|
+
const layout = screenLayout(resourceConfig, 'show');
|
|
116
|
+
const columns = layout
|
|
117
|
+
? resolveColumns(
|
|
118
|
+
showFields.map(name => fieldMap.get(name)).filter((f): f is FieldDefinition => f != null),
|
|
119
|
+
layout,
|
|
120
|
+
editors,
|
|
121
|
+
)
|
|
122
|
+
: null;
|
|
87
123
|
|
|
88
124
|
return (
|
|
89
125
|
<ScrollView className="flex-1">
|
|
@@ -105,7 +141,7 @@ export function ResourceShow({
|
|
|
105
141
|
record={record}
|
|
106
142
|
recordId={id}
|
|
107
143
|
adapter={adapter}
|
|
108
|
-
refetch={
|
|
144
|
+
refetch={refetchQuietly}
|
|
109
145
|
/>
|
|
110
146
|
))}
|
|
111
147
|
{canEdit && onEditPress && (
|
|
@@ -119,7 +155,17 @@ export function ResourceShow({
|
|
|
119
155
|
|
|
120
156
|
{/* Fields */}
|
|
121
157
|
<Card style={{ marginHorizontal: 16, marginBottom: 16 }}>
|
|
122
|
-
{
|
|
158
|
+
{columns ? (
|
|
159
|
+
<FieldColumns
|
|
160
|
+
columns={columns}
|
|
161
|
+
renderField={(fieldDef, idx) => (
|
|
162
|
+
<>
|
|
163
|
+
{idx > 0 && <Divider />}
|
|
164
|
+
<FieldRow field={fieldDef} value={record[fieldDef.name]} record={record} />
|
|
165
|
+
</>
|
|
166
|
+
)}
|
|
167
|
+
/>
|
|
168
|
+
) : showFields.map((fieldName, idx) => {
|
|
123
169
|
const fieldDef = fieldMap.get(fieldName);
|
|
124
170
|
if (!fieldDef) return null;
|
|
125
171
|
const value = record[fieldName];
|
|
@@ -178,9 +224,11 @@ function FieldRow({ field, value, record }: FieldRowProps): React.ReactElement {
|
|
|
178
224
|
{field.label ?? field.name}
|
|
179
225
|
</Text>
|
|
180
226
|
{field.render ? (
|
|
181
|
-
<>{field.render(value, record, { view: 'show' })}</>
|
|
227
|
+
<>{wrapBareText(field.render(value, record, { view: 'show' }))}</>
|
|
182
228
|
) : field.type === 'image' ? (
|
|
183
229
|
<ImageDisplay field={field} value={value} view="show" />
|
|
230
|
+
) : field.type === 'tags' ? (
|
|
231
|
+
<TagsDisplay field={field} value={value} />
|
|
184
232
|
) : (
|
|
185
233
|
<Text className="text-sm text-gray-900">{formatFieldValue(value, field)}</Text>
|
|
186
234
|
)}
|
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 */
|
|
@@ -231,8 +321,30 @@ export interface ResourceConfig {
|
|
|
231
321
|
detailVia?: 'rpc' | 'rest';
|
|
232
322
|
/** Optional observability-style dashboard configuration */
|
|
233
323
|
dashboard?: ResourceDashboardConfig;
|
|
234
|
-
/**
|
|
324
|
+
/**
|
|
325
|
+
* Custom record actions (rpc, onPress or render). They show on the show view
|
|
326
|
+
* and in the list's Actions column, after the built-in Show/Edit/Delete.
|
|
327
|
+
*/
|
|
235
328
|
rowActions?: RowActionConfig[];
|
|
329
|
+
/** Tabs above the list, each a named filter. Without this the list shows no tabs. */
|
|
330
|
+
scopes?: ScopeConfig[];
|
|
331
|
+
/**
|
|
332
|
+
* Show a row count on each tab. Off unless set: counts cost queries. The values of one
|
|
333
|
+
* enum column share one grouped query; every other tab costs one count. Counts follow the
|
|
334
|
+
* search, and need an adapter with `aggregate`.
|
|
335
|
+
*/
|
|
336
|
+
scopeCounts?: boolean;
|
|
337
|
+
/**
|
|
338
|
+
* Field columns for Show, Edit and Create. Without any layout, each screen
|
|
339
|
+
* shows one column of fields, as before.
|
|
340
|
+
*/
|
|
341
|
+
layout?: ScreenLayout;
|
|
342
|
+
/** Show only: wins over `layout`. */
|
|
343
|
+
showLayout?: ScreenLayout;
|
|
344
|
+
/** Edit (and Create, unless `createLayout` is set): wins over `layout`. */
|
|
345
|
+
editLayout?: ScreenLayout;
|
|
346
|
+
/** Create only: wins over `editLayout` and `layout`. */
|
|
347
|
+
createLayout?: ScreenLayout;
|
|
236
348
|
}
|
|
237
349
|
|
|
238
350
|
// =============================================================================
|
|
@@ -452,6 +564,20 @@ export interface AdminConfig {
|
|
|
452
564
|
// Data Types
|
|
453
565
|
// =============================================================================
|
|
454
566
|
|
|
567
|
+
/**
|
|
568
|
+
* A named filter shown as a tab above a list ("Current", "Draft"). Scopes are
|
|
569
|
+
* ANDed with the search and any column filters.
|
|
570
|
+
*/
|
|
571
|
+
export interface ScopeConfig {
|
|
572
|
+
/** Stable id; this is what the URL carries (`?tab=draft`). */
|
|
573
|
+
id: string;
|
|
574
|
+
label: string;
|
|
575
|
+
/** Filter applied while this scope is active, in the adapter's filter syntax. None means all rows. */
|
|
576
|
+
filter?: Record<string, unknown>;
|
|
577
|
+
/** The scope active when the URL names none. If none is marked, the first is. */
|
|
578
|
+
default?: boolean;
|
|
579
|
+
}
|
|
580
|
+
|
|
455
581
|
export interface ListParams {
|
|
456
582
|
/** Page number (1-indexed) */
|
|
457
583
|
page?: number;
|
|
@@ -463,8 +589,14 @@ export interface ListParams {
|
|
|
463
589
|
order?: 'asc' | 'desc';
|
|
464
590
|
/** Filter conditions */
|
|
465
591
|
filter?: Record<string, unknown>;
|
|
466
|
-
/** Search
|
|
592
|
+
/** Search text, matched (ILIKE, case-insensitive) in any of `searchFields`. */
|
|
467
593
|
search?: string;
|
|
594
|
+
/**
|
|
595
|
+
* Columns `search` looks in. Required with `search`: the adapter does not know
|
|
596
|
+
* your schema, so it refuses search text without them rather than silently
|
|
597
|
+
* returning unfiltered rows. Plain column names only.
|
|
598
|
+
*/
|
|
599
|
+
searchFields?: string[];
|
|
468
600
|
}
|
|
469
601
|
|
|
470
602
|
export interface ListResult<T = Record<string, unknown>> {
|
|
@@ -507,6 +639,10 @@ export interface AggregateParams {
|
|
|
507
639
|
groupBy?: string[];
|
|
508
640
|
/** Filter conditions */
|
|
509
641
|
filter?: Record<string, unknown>;
|
|
642
|
+
/** Search text, matched in any of `searchFields`, exactly as `ListParams.search`. */
|
|
643
|
+
search?: string;
|
|
644
|
+
/** Columns `search` looks in. Required with `search`. Plain column names only. */
|
|
645
|
+
searchFields?: string[];
|
|
510
646
|
}
|
|
511
647
|
|
|
512
648
|
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,199 @@
|
|
|
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 { 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`) are not written.
|
|
90
|
+
*/
|
|
91
|
+
export function listQueryToParams(
|
|
92
|
+
query: ListQuery,
|
|
93
|
+
options: { defaultPerPage?: number } = {},
|
|
94
|
+
): Record<string, string | undefined> {
|
|
95
|
+
return {
|
|
96
|
+
q: query.search || undefined,
|
|
97
|
+
tab: 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
|
+
/** Columns a user may sort by. A sort outside this set is dropped. */
|
|
149
|
+
sortableFields?: ReadonlySet<string>;
|
|
150
|
+
/** Used when the query has no valid sort of its own. */
|
|
151
|
+
defaultSort?: { field: string; order?: 'asc' | 'desc' };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** List state → the adapter's ListParams. */
|
|
155
|
+
export function buildListParams(query: ListQuery, options: BuildListParamsOptions): ListParams {
|
|
156
|
+
const sortAllowed = query.sort && (!options.sortableFields || options.sortableFields.has(query.sort));
|
|
157
|
+
|
|
158
|
+
let sort: string | undefined;
|
|
159
|
+
let order: 'asc' | 'desc' | undefined;
|
|
160
|
+
if (sortAllowed) {
|
|
161
|
+
sort = query.sort;
|
|
162
|
+
order = query.order ?? 'asc';
|
|
163
|
+
} else if (options.defaultSort && (!options.sortableFields || options.sortableFields.has(options.defaultSort.field))) {
|
|
164
|
+
sort = options.defaultSort.field;
|
|
165
|
+
order = options.defaultSort.order ?? 'asc';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
page: query.page ?? 1,
|
|
170
|
+
perPage: query.perPage ?? options.perPage,
|
|
171
|
+
search: query.search,
|
|
172
|
+
sort,
|
|
173
|
+
order,
|
|
174
|
+
filter: mergeFilters(options.baseFilter, options.scopeFilter),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** What the list is sorted by right now, defaults included. */
|
|
179
|
+
export interface EffectiveSort {
|
|
180
|
+
sort?: string;
|
|
181
|
+
order?: 'asc' | 'desc';
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The next sort after a click on a column header: ascending, then descending,
|
|
186
|
+
* then off (back to the default sort). On the default sort's own column "off"
|
|
187
|
+
* would be the same view, so it flips between ascending and descending instead.
|
|
188
|
+
* `off` is `{ sort: undefined, order: undefined }`, which clears both from the URL.
|
|
189
|
+
*/
|
|
190
|
+
export function nextSort(
|
|
191
|
+
current: EffectiveSort,
|
|
192
|
+
key: string,
|
|
193
|
+
defaultSort?: { field: string; order?: 'asc' | 'desc' },
|
|
194
|
+
): EffectiveSort {
|
|
195
|
+
if (current.sort !== key) return { sort: key, order: 'asc' };
|
|
196
|
+
if ((current.order ?? 'asc') === 'asc') return { sort: key, order: 'desc' };
|
|
197
|
+
if (defaultSort?.field === key) return { sort: key, order: 'asc' };
|
|
198
|
+
return { sort: undefined, order: undefined };
|
|
199
|
+
}
|
|
@@ -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
|
+
}
|