@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.
@@ -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
- const fetchRecord = useCallback(async () => {
50
- setLoading(true);
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
- setRecord(await fetchDetail(adapter, resource, id, resourceConfig));
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={fetchRecord}
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
- {showFields.map((fieldName, idx) => {
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
- /** 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 */
@@ -231,8 +321,30 @@ export interface ResourceConfig {
231
321
  detailVia?: 'rpc' | 'rest';
232
322
  /** Optional observability-style dashboard configuration */
233
323
  dashboard?: ResourceDashboardConfig;
234
- /** RPC-backed record actions shown on the show view */
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 query */
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
+ }