@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
@@ -3,19 +3,41 @@
3
3
  *
4
4
  * List view for a resource with sortable table, search/filter, and pagination.
5
5
  * Wires useAdminData() to DataTable, FilterBar, and PaginationBar components.
6
+ *
7
+ * The list's search, sort, page and page size live in the URL (useListQuery),
8
+ * so a list view is a link. The search box waits for a pause in typing before writing it.
6
9
  */
7
10
 
8
- import React, { useCallback, useMemo } from 'react';
11
+ /** How long the search box waits after the last keystroke before it searches. */
12
+ const SEARCH_DEBOUNCE_MS = 300;
13
+
14
+ /** Rows per page when the resource names no `perPage`. */
15
+ const DEFAULT_PER_PAGE = 20;
16
+ /** Sizes the page bar always offers; the resource's own size is added when missing. */
17
+ const PAGE_SIZES = [20, 50, 100];
18
+
19
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
9
20
  import { View, Text } from 'react-native';
10
21
  import { useAdminConfig } from '../hooks/useAdminConfig';
11
22
  import { useAdminData, useDataAdapter } from '../hooks/useAdminData';
12
- import { DataTable, FilterBar, PaginationBar, LoadingState, ErrorState, EmptyState, Button, type DataTableColumn } from '@everystack/ui';
23
+ import { useListQuery } from '../hooks/useListQuery';
24
+ import { useAdminNavigation } from '../hooks/useAdminNavigation';
25
+ import { adminHref } from '../utils/admin-routing';
26
+ import { buildListParams, mergeFilters, nextSort, normalizeSearchText, pageForSize, parseListQuery, resolvePageSize } from '../utils/list-query';
27
+ import { resolveScope } from '../utils/scopes';
28
+ import { useScopeCounts } from '../hooks/useScopeCounts';
29
+ import { RESERVED_PARAMS } from '../utils/view-filters';
30
+ import { DataTable, FilterBar, PaginationBar, ScopeTabs, LoadingState, ErrorState, EmptyState, Button, type DataTableColumn } from '@everystack/ui';
13
31
  import { formatCellValue } from '../utils/format-cell-value';
14
32
  import { cellRenderKind } from '../utils/render-resolution';
15
- import { visibleRowActions } from '../utils/row-actions';
33
+ import { estimateActionsWidth, visibleRowActions } from '../utils/row-actions';
16
34
  import { standardRowActions } from '../utils/standard-actions';
17
35
  import { ImageDisplay } from '../components/ImageDisplay';
18
36
  import { RowActionButton } from '../components/RowActionButton';
37
+ import { InlineEnumCell } from '../components/InlineEnumCell';
38
+ import { wrapBareText } from '../components/CellText';
39
+ import { RelationValue } from '../components/RelationValue';
40
+ import { useRelationLabels, type RelationLabels } from '../hooks/useRelationLabels';
19
41
  import type { DataAdapter } from '../adapters/types';
20
42
  import type { ResourceConfig, FieldDefinition, DataRecord, RowActionConfig } from '../types';
21
43
 
@@ -41,61 +63,151 @@ export function ResourceList({ resource, onRowPress, onEditPress, onCreatePress,
41
63
  const resourceConfig = config.resources.find(r => r.name === resource);
42
64
 
43
65
  const defaultSort = resourceConfig?.defaultSort;
66
+ const defaultPerPage = resolvePageSize(resourceConfig?.perPage, DEFAULT_PER_PAGE);
67
+ const pageSizes = useMemo(
68
+ () => [...new Set([...PAGE_SIZES, defaultPerPage])].sort((a, b) => a - b),
69
+ [defaultPerPage],
70
+ );
71
+ const defaultScope = resolveScope(resourceConfig?.scopes, undefined)?.id;
72
+ const { query, update } = useListQuery({ defaultPerPage, defaultScope });
73
+
74
+ const searchFields = useMemo(
75
+ () => resourceConfig?.fields.filter(f => f.searchable).map(f => f.name) ?? [],
76
+ [resourceConfig],
77
+ );
78
+ const sortableFields = useMemo(
79
+ () => new Set(resourceConfig?.fields.filter(f => f.sortable).map(f => f.name) ?? []),
80
+ [resourceConfig],
81
+ );
82
+ const activeScope = useMemo(
83
+ () => resolveScope(resourceConfig?.scopes, query.scope),
84
+ [resourceConfig, query.scope],
85
+ );
86
+ const listParams = useMemo(
87
+ () => buildListParams(query, {
88
+ perPage: defaultPerPage, baseFilter, scopeFilter: activeScope?.filter, scopeDeleted: activeScope?.deleted, sortableFields, defaultSort,
89
+ }),
90
+ [query, defaultPerPage, baseFilter, activeScope, sortableFields, defaultSort],
91
+ );
92
+
93
+ const searchParams = adapter.supportsSearch === true && searchFields.length > 0
94
+ ? { search: listParams.search, searchFields }
95
+ : {};
96
+ const legacySearchFilter = useMemo(() => {
97
+ const text = normalizeSearchText(query.search);
98
+ return adapter.supportsSearch !== true && text && searchFields[0]
99
+ ? { [searchFields[0]]: `ilike.*${text}*` }
100
+ : undefined;
101
+ }, [adapter.supportsSearch, query.search, searchFields]);
102
+
103
+ // Bumped after an inline edit, so the tab counts follow the change.
104
+ const [countsToken, setCountsToken] = useState(0);
105
+ const scopeCounts = useScopeCounts({
106
+ resource,
107
+ scopes: resourceConfig?.scopes,
108
+ enabled: Boolean(resourceConfig?.scopeCounts),
109
+ baseFilter: mergeFilters(baseFilter, legacySearchFilter),
110
+ ...searchParams,
111
+ refreshToken: countsToken,
112
+ });
113
+
44
114
  const {
45
115
  data,
46
116
  total,
47
117
  loading,
118
+ refreshing,
48
119
  error,
49
120
  refetch,
50
121
  page,
51
122
  perPage,
52
- setPage,
53
- setSort,
54
- setFilter,
55
123
  } = useAdminData(resource, {
56
- sort: defaultSort?.field,
57
- order: defaultSort?.order,
58
- baseFilter,
124
+ controlled: true,
125
+ page: listParams.page,
126
+ perPage: listParams.perPage,
127
+ sort: listParams.sort,
128
+ order: listParams.order,
129
+ ...searchParams,
130
+ select: resourceConfig?.select,
131
+ deleted: listParams.deleted,
132
+ baseFilter: mergeFilters(listParams.filter, legacySearchFilter),
59
133
  });
60
134
 
135
+ // A column named like a list URL param cannot be filtered from the URL: say so while developing.
136
+ useEffect(() => {
137
+ if (process.env.NODE_ENV === 'production') return;
138
+ const clashes = (resourceConfig?.fields ?? []).map(f => f.name).filter(name => RESERVED_PARAMS.has(name));
139
+ if (clashes.length === 0) return;
140
+ // eslint-disable-next-line no-console
141
+ console.warn(
142
+ `[admin] resource "${resource}" has ${clashes.map(c => `"${c}"`).join(', ')}, a name the list reserves for its URL ` +
143
+ `(reserved: ${[...RESERVED_PARAMS].join(', ')}). That column cannot be filtered with ?${clashes[0]}=... in the URL.`,
144
+ );
145
+ }, [resourceConfig, resource]);
146
+
147
+ // The box shows what the user typed; the URL gets it after a pause. When the
148
+ // URL changes by itself (back button, a link), the box follows.
149
+ const [searchText, setSearchText] = useState(query.search ?? '');
150
+ const lastWritten = useRef(query.search);
151
+ useEffect(() => {
152
+ if (query.search === lastWritten.current) return;
153
+ lastWritten.current = query.search;
154
+ setSearchText(query.search ?? '');
155
+ }, [query.search]);
156
+ useEffect(() => {
157
+ const wanted = parseListQuery({ q: searchText }).search;
158
+ // Against what we last wrote, not the URL's current value: the URL may not have caught up.
159
+ if (wanted === lastWritten.current) return;
160
+ const timer = setTimeout(() => {
161
+ lastWritten.current = wanted;
162
+ update({ search: wanted });
163
+ }, SEARCH_DEBOUNCE_MS);
164
+ return () => clearTimeout(timer);
165
+ // `query.search` is read, not watched: re-running on it would restart the pause.
166
+ // eslint-disable-next-line react-hooks/exhaustive-deps
167
+ }, [searchText]);
168
+
169
+ // Relation cells print the related row's displayField, one request per related resource per page.
170
+ const relationFields = useMemo(() => {
171
+ if (!resourceConfig) return [];
172
+ const listed = new Set(resourceConfig.listFields ?? resourceConfig.fields.map(f => f.name));
173
+ return resourceConfig.fields.filter(f => listed.has(f.name) && f.type === 'relation' && !f.render);
174
+ }, [resourceConfig]);
175
+ const relationLabels = useRelationLabels(adapter, relationFields, data);
176
+
61
177
  // Build columns from field definitions, plus an actions column combining the
62
178
  // built-in Show/Edit/Delete (gated by actions.*) with any custom rowActions.
179
+ // Returns the refetch, so the cell can re-sync once the new rows are in.
180
+ const afterInlineEdit = useCallback((): Promise<void> => {
181
+ setCountsToken(t => t + 1);
182
+ return refetch();
183
+ }, [refetch]);
184
+
185
+ const deletedOnly = listParams.deleted === 'only';
186
+ // Under AdminRouter, Delete on a resource with associations opens Show with its delete preview.
187
+ const navigation = useAdminNavigation();
63
188
  const columns = useMemo(() => {
64
189
  if (!resourceConfig) return [];
65
- const cols = fieldsToColumns(resourceConfig);
190
+ const cols = fieldsToColumns(resourceConfig, adapter, afterInlineEdit, relationLabels, deletedOnly);
191
+ const onConfirmDelete = navigation
192
+ ? (record: DataRecord) => {
193
+ const id = (record[resourceConfig.primaryKey ?? 'id'] as string | number) ?? record.id;
194
+ navigation.push(`${adminHref(navigation.basePath, { resource, id })}?confirmDelete=1`);
195
+ }
196
+ : undefined;
66
197
  const actions = [
67
- ...standardRowActions(resourceConfig, { onShow: onRowPress, onEdit: onEditPress }),
198
+ ...standardRowActions(resourceConfig, { onShow: onRowPress, onEdit: onEditPress, onConfirmDelete }, { deletedOnly }),
68
199
  ...(resourceConfig.rowActions ?? []),
69
200
  ];
70
201
  if (actions.length) {
71
202
  cols.push(actionsColumn(resourceConfig, actions, adapter, refetch));
72
203
  }
73
204
  return cols;
74
- }, [resourceConfig, adapter, refetch, onRowPress, onEditPress]);
75
-
76
- // Build filter chips from filterable fields
77
- const filterChips = useMemo(() => {
78
- if (!resourceConfig) return [];
79
- return resourceConfig.fields
80
- .filter(f => f.filterable)
81
- .map(f => ({ key: f.name, label: f.label ?? f.name }));
82
- }, [resourceConfig]);
205
+ }, [resourceConfig, adapter, refetch, afterInlineEdit, onRowPress, onEditPress, relationLabels, deletedOnly, navigation, resource]);
83
206
 
207
+ // The click cycles from what is shown (the default sort included), not from the URL alone.
84
208
  const handleSort = useCallback((key: string) => {
85
- setSort(key, 'asc');
86
- }, [setSort]);
87
-
88
- const handleSearch = useCallback((query: string) => {
89
- if (!query) {
90
- setFilter({});
91
- return;
92
- }
93
- // Search across searchable fields using ilike
94
- const searchableFields = resourceConfig?.fields.filter(f => f.searchable) ?? [];
95
- if (searchableFields.length > 0) {
96
- setFilter({ [searchableFields[0].name]: `ilike.*${query}*` });
97
- }
98
- }, [resourceConfig, setFilter]);
209
+ update(nextSort({ sort: listParams.sort, order: listParams.order }, key, defaultSort));
210
+ }, [update, listParams.sort, listParams.order, defaultSort]);
99
211
 
100
212
  if (!resourceConfig) {
101
213
  return (
@@ -117,27 +229,51 @@ export function ResourceList({ resource, onRowPress, onEditPress, onCreatePress,
117
229
  </View>
118
230
 
119
231
  {/* Filter bar */}
120
- <FilterBar
121
- placeholder={`Search ${label.toLowerCase()}...`}
122
- onSearch={handleSearch}
123
- filters={filterChips}
124
- />
232
+ {searchFields.length > 0 ? (
233
+ <FilterBar
234
+ placeholder={`Search ${label.toLowerCase()}...`}
235
+ onSearch={setSearchText}
236
+ value={searchText}
237
+ />
238
+ ) : null}
239
+ {query.search && searchFields.length > 0 && (!loading || refreshing) && !error ? (
240
+ <Text className="px-4 pb-2 text-sm text-gray-500">
241
+ {`${total} ${total === 1 ? 'result' : 'results'}`}
242
+ </Text>
243
+ ) : null}
125
244
 
126
- {/* Content */}
127
- {loading ? (
245
+ {resourceConfig.scopes && resourceConfig.scopes.length > 0 && activeScope ? (
246
+ <ScopeTabs
247
+ tabs={resourceConfig.scopes.map(scope => ({ id: scope.id, label: scope.label, count: scopeCounts?.[scope.id] }))}
248
+ active={activeScope.id}
249
+ onChange={id => update({ scope: id })}
250
+ />
251
+ ) : null}
252
+
253
+ {/* Content. An explicit refetch keeps the table, and its cells, on screen until new rows land. */}
254
+ {loading && !refreshing ? (
128
255
  <LoadingState message={`Loading ${label.toLowerCase()}...`} />
129
256
  ) : error ? (
130
257
  <ErrorState message={error.message} onRetry={refetch} />
131
258
  ) : data.length === 0 ? (
132
- <EmptyState
133
- title={`No ${label.toLowerCase()}`}
134
- message={`No ${label.toLowerCase()} found. ${canCreate ? 'Create one to get started.' : ''}`}
135
- />
259
+ query.search ? (
260
+ <EmptyState title={`No results for \u201c${query.search}\u201d`} message="Try a different search." />
261
+ ) : (
262
+ <EmptyState
263
+ title={`No ${label.toLowerCase()}`}
264
+ message={`No ${label.toLowerCase()} found. ${canCreate ? 'Create one to get started.' : ''}`}
265
+ />
266
+ )
136
267
  ) : (
137
268
  <DataTable
138
269
  columns={columns}
139
270
  data={data}
140
271
  onSort={handleSort}
272
+ sortField={listParams.sort}
273
+ sortOrder={listParams.order}
274
+ // Key rows by the record's own key, so a row's cell state (an inline-enum
275
+ // error, a row action's message) stays with that record across a refetch.
276
+ rowKey={resourceConfig?.primaryKey ?? 'id'}
141
277
  // A list-only resource (actions.show === false) has no detail screen —
142
278
  // don't make rows navigate to a fetchOne that 404s. The row-action menu
143
279
  // already hides "Show" via standardRowActions; this gates the whole-row tap.
@@ -151,7 +287,9 @@ export function ResourceList({ resource, onRowPress, onEditPress, onCreatePress,
151
287
  page={page}
152
288
  perPage={perPage}
153
289
  total={total}
154
- onPageChange={setPage}
290
+ onPageChange={next => update({ page: next })}
291
+ pageSizes={pageSizes}
292
+ onPerPageChange={size => update({ perPage: size, page: pageForSize(page, perPage, size) })}
155
293
  />
156
294
  )}
157
295
  </View>
@@ -162,7 +300,13 @@ export function ResourceList({ resource, onRowPress, onEditPress, onCreatePress,
162
300
  // Helpers
163
301
  // =============================================================================
164
302
 
165
- function fieldsToColumns(resourceConfig: ResourceConfig): DataTableColumn[] {
303
+ function fieldsToColumns(
304
+ resourceConfig: ResourceConfig,
305
+ adapter: DataAdapter,
306
+ onInlineSaved: () => Promise<void>,
307
+ relationLabels: RelationLabels,
308
+ deletedOnly: boolean,
309
+ ): DataTableColumn[] {
166
310
  const fieldNames = resourceConfig.listFields ?? resourceConfig.fields.map(f => f.name);
167
311
  const fieldMap = new Map(resourceConfig.fields.map(f => [f.name, f]));
168
312
 
@@ -173,18 +317,50 @@ function fieldsToColumns(resourceConfig: ResourceConfig): DataTableColumn[] {
173
317
  key: field.name,
174
318
  label: field.label ?? field.name,
175
319
  sortable: field.sortable,
176
- render: cellRenderer(field),
320
+ ...(field.listWidth !== undefined && { width: field.listWidth }),
321
+ ...(field.listPriority !== undefined && { priority: field.listPriority }),
322
+ ...(field.listHideBelow !== undefined && { hideBelow: field.listHideBelow }),
323
+ render: cellRenderer(field, resourceConfig, adapter, onInlineSaved, relationLabels, deletedOnly),
177
324
  }));
178
325
  }
179
326
 
180
327
  /**
181
328
  * Cell rendering precedence: custom render → image thumbnail → type text.
182
- * (See render-resolution.cellRenderKind.)
329
+ * (See render-resolution.cellRenderKind.) An `inlineEdit` enum field shows a
330
+ * picker in place of the type text, unless the resource sets `actions.edit: false`
331
+ * or the tab lists deleted rows only (the save matches live rows only, so it would fail).
332
+ * A relation field links to the related show page, labelled with its displayField.
183
333
  */
184
- function cellRenderer(field: FieldDefinition): DataTableColumn['render'] {
185
- switch (cellRenderKind(field)) {
334
+ function cellRenderer(
335
+ field: FieldDefinition,
336
+ resourceConfig: ResourceConfig,
337
+ adapter: DataAdapter,
338
+ onInlineSaved: () => Promise<void>,
339
+ relationLabels: RelationLabels,
340
+ deletedOnly: boolean,
341
+ ): DataTableColumn['render'] {
342
+ const kind = cellRenderKind(field);
343
+ const relation = field.relation;
344
+ if (kind === 'text' && field.type === 'relation' && relation) {
345
+ return (value) => <RelationValue resource={relation.resource} value={value} labels={relationLabels.get(field.name)} numberOfLines={1} />;
346
+ }
347
+ if (kind === 'text' && field.inlineEdit === true && field.type === 'enum' && resourceConfig.actions?.edit !== false && !deletedOnly) {
348
+ const primaryKey = resourceConfig.primaryKey ?? 'id';
349
+ return (value, record) => (
350
+ <InlineEnumCell
351
+ resource={resourceConfig.name}
352
+ field={field.name}
353
+ recordId={(record[primaryKey] as string | number) ?? (record.id as string | number)}
354
+ value={value}
355
+ enumValues={field.enumValues ?? []}
356
+ adapter={adapter}
357
+ onSaved={onInlineSaved}
358
+ />
359
+ );
360
+ }
361
+ switch (kind) {
186
362
  case 'custom':
187
- return (value, record) => field.render!(value, record as DataRecord, { view: 'list' });
363
+ return (value, record) => wrapBareText(field.render!(value, record as DataRecord, { view: 'list' }), 1);
188
364
  case 'image':
189
365
  return (value) => <ImageDisplay field={field} value={value} view="list" />;
190
366
  default:
@@ -196,6 +372,13 @@ function cellRenderer(field: FieldDefinition): DataTableColumn['render'] {
196
372
  }
197
373
  }
198
374
 
375
+ /** The resource's actionsWidth when it is a finite number above 0, else the estimate from the labels. */
376
+ function actionsColumnWidth(configured: number | undefined, actions: RowActionConfig[]): number {
377
+ return typeof configured === 'number' && Number.isFinite(configured) && configured > 0
378
+ ? configured
379
+ : estimateActionsWidth(actions);
380
+ }
381
+
199
382
  /**
200
383
  * A trailing column of per-row actions, each gated by its `when` clause.
201
384
  * The cell claims the press so tapping an action doesn't also fire the row's
@@ -211,11 +394,16 @@ function actionsColumn(
211
394
  return {
212
395
  key: '__actions',
213
396
  label: 'Actions',
397
+ // Rows cannot share a content-sized width in a flex layout, so the width is a number.
398
+ width: actionsColumnWidth(resourceConfig.actionsWidth, actions),
399
+ // Every row needs its actions, so a narrow list never drops this column.
400
+ // `keep` rather than priority 1, which would turn dropping on for every list with actions.
401
+ keep: true,
214
402
  render: (_value, record) => {
215
403
  const visible = visibleRowActions(actions, record);
216
404
  if (!visible.length) return null;
217
405
  return (
218
- <View className="flex-row gap-2" onStartShouldSetResponder={() => true}>
406
+ <View className="flex-row flex-wrap gap-2" onStartShouldSetResponder={() => true}>
219
407
  {visible.map(action => (
220
408
  <RowActionButton
221
409
  key={action.id}
@@ -5,14 +5,23 @@
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, useMemo, 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
- import { LoadingState, ErrorState, Button, Card, Divider } from '@everystack/ui';
12
+ import { LoadingState, ErrorState, Button, Card, Divider, RichContent } 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 { DeletePreview } from '../components/DeletePreview';
17
+ import { FieldColumns } from '../components/FieldColumns';
18
+ import { RelationValue } from '../components/RelationValue';
19
+ import { useRelationLabels, type RelationLabels } from '../hooks/useRelationLabels';
20
+ import { useEditors } from '../hooks/useEditors';
21
+ import { resolveColumns, screenLayout } from '../utils/resolve-columns';
22
+ import { wrapBareText } from '../components/CellText';
15
23
  import { visibleRowActions } from '../utils/row-actions';
24
+ import { restoreAction, softDeleteColumn } from '../utils/standard-actions';
16
25
  import { toCamelCase } from '../utils/naming';
17
26
  import { formatDateOnly } from '../utils/format-cell-value';
18
27
  import type { FieldDefinition, DataRecord, ResourceConfig } from '../types';
@@ -25,10 +34,15 @@ export interface ResourceShowProps {
25
34
  id: string | number;
26
35
  /** Called when the edit button is pressed */
27
36
  onEditPress?: () => void;
28
- /** Called when the delete button is pressed */
29
- onDeletePress?: () => void;
37
+ /**
38
+ * Called when the delete button is pressed. When the resource declares
39
+ * `associations`, Delete opens the delete preview and its Confirm calls this.
40
+ */
41
+ onDeletePress?: () => void | Promise<void>;
30
42
  /** Called when the back/list button is pressed */
31
43
  onBackPress?: () => void;
44
+ /** Open the delete preview on mount (a resource with `associations` only). */
45
+ confirmDelete?: boolean;
32
46
  }
33
47
 
34
48
  export function ResourceShow({
@@ -37,31 +51,66 @@ export function ResourceShow({
37
51
  onEditPress,
38
52
  onDeletePress,
39
53
  onBackPress,
54
+ confirmDelete,
40
55
  }: ResourceShowProps): React.ReactElement {
41
56
  const config = useAdminConfig();
42
57
  const adapter = useDataAdapter();
43
58
  const resourceConfig = config.resources.find(r => r.name === resource);
59
+ const editors = useEditors();
44
60
 
45
61
  const [record, setRecord] = useState<DataRecord | null>(null);
46
62
  const [loading, setLoading] = useState(true);
47
63
  const [error, setError] = useState<Error | null>(null);
64
+ // Declared associations put a preview of the linked rows between Delete and the delete.
65
+ const [previewOpen, setPreviewOpen] = useState(Boolean(confirmDelete));
48
66
 
49
- const fetchRecord = useCallback(async () => {
50
- setLoading(true);
67
+ // Latest-request guard: only the most recent load may write state, so an older
68
+ // id's or an older request's response can't land over a newer one.
69
+ const seqRef = useRef(0);
70
+ // The id on screen now. A load captured for an older id (a row action's refetch
71
+ // after it navigated away) writes nothing.
72
+ const idRef = useRef(id);
73
+ // Set when the id commits, not during render: a render React throws away must not move it.
74
+ // Declared before the fetch effect, so a layout effect always runs first.
75
+ useLayoutEffect(() => { idRef.current = id; }, [id]);
76
+
77
+ // A quiet load keeps the screen (and a row action's error) mounted; it still
78
+ // ends in the ErrorState when the fetch throws, so stale data never stays.
79
+ // A superseded load still resolves; it just writes nothing.
80
+ const loadRecord = useCallback(async (quiet: boolean): Promise<void> => {
81
+ // A call from an older id's closure takes no turn, so it cannot supersede the current load.
82
+ if (id !== idRef.current) return;
83
+ const seq = ++seqRef.current;
84
+ // An id change starts a new load (a new seq), so the seq alone drops an older id's result.
85
+ const latest = (): boolean => seq === seqRef.current;
86
+ if (!quiet) setLoading(true);
51
87
  setError(null);
52
88
  try {
53
- setRecord(await fetchDetail(adapter, resource, id, resourceConfig));
89
+ const next = await fetchDetail(adapter, resource, id, resourceConfig);
90
+ if (latest()) setRecord(next);
54
91
  } catch (err) {
55
- setError(err instanceof Error ? err : new Error(String(err)));
92
+ if (latest()) setError(err instanceof Error ? err : new Error(String(err)));
56
93
  } finally {
57
- setLoading(false);
94
+ if (latest()) setLoading(false);
58
95
  }
59
96
  }, [adapter, resource, id, resourceConfig]);
60
97
 
98
+ const fetchRecord = useCallback((): Promise<void> => loadRecord(false), [loadRecord]);
99
+ const refetchQuietly = useCallback((): Promise<void> => loadRecord(true), [loadRecord]);
100
+
61
101
  useEffect(() => {
62
102
  fetchRecord();
63
103
  }, [fetchRecord]);
64
104
 
105
+ // Relation fields print the related row's displayField (one request per related resource).
106
+ const relationFields = useMemo(() => {
107
+ if (!resourceConfig) return [];
108
+ const shown = new Set(resourceConfig.showFields ?? resourceConfig.fields.map(f => f.name));
109
+ return resourceConfig.fields.filter(f => shown.has(f.name) && f.type === 'relation' && !f.render);
110
+ }, [resourceConfig]);
111
+ const relationRows = useMemo(() => (record ? [record] : []), [record]);
112
+ const relationLabels = useRelationLabels(adapter, relationFields, relationRows);
113
+
65
114
  if (!resourceConfig) {
66
115
  return <ErrorState message={`Resource "${resource}" not found in admin configuration`} />;
67
116
  }
@@ -81,9 +130,23 @@ export function ResourceShow({
81
130
  const label = resourceConfig.label ?? resourceConfig.name;
82
131
  const showFields = resourceConfig.showFields ?? resourceConfig.fields.map(f => f.name);
83
132
  const fieldMap = new Map(resourceConfig.fields.map(f => [f.name, f]));
84
- const canEdit = resourceConfig.actions?.edit !== false;
85
- const canDelete = resourceConfig.actions?.delete !== false;
133
+ // A soft-deleted row shows a banner with Restore instead of Edit and Delete.
134
+ const deletedColumn = softDeleteColumn(resourceConfig);
135
+ const deletedAt = deletedColumn ? record[deletedColumn] : undefined;
136
+ const isDeleted = deletedAt != null;
137
+ const restore = isDeleted ? restoreAction(resourceConfig) : undefined;
138
+ const canEdit = resourceConfig.actions?.edit !== false && !isDeleted;
139
+ const canDelete = resourceConfig.actions?.delete !== false && !isDeleted;
140
+ const associations = resourceConfig.associations?.length ? resourceConfig.associations : undefined;
86
141
  const rowActions = visibleRowActions(resourceConfig.rowActions, record);
142
+ const layout = screenLayout(resourceConfig, 'show');
143
+ const columns = layout
144
+ ? resolveColumns(
145
+ showFields.map(name => fieldMap.get(name)).filter((f): f is FieldDefinition => f != null),
146
+ layout,
147
+ editors,
148
+ )
149
+ : null;
87
150
 
88
151
  return (
89
152
  <ScrollView className="flex-1">
@@ -105,28 +168,57 @@ export function ResourceShow({
105
168
  record={record}
106
169
  recordId={id}
107
170
  adapter={adapter}
108
- refetch={fetchRecord}
171
+ refetch={refetchQuietly}
109
172
  />
110
173
  ))}
111
174
  {canEdit && onEditPress && (
112
175
  <Button label="Edit" variant="outline" onPress={onEditPress} />
113
176
  )}
114
177
  {canDelete && onDeletePress && (
115
- <Button label="Delete" variant="destructive" onPress={onDeletePress} />
178
+ <Button label="Delete" variant="destructive" onPress={associations ? () => setPreviewOpen(true) : onDeletePress} />
116
179
  )}
117
180
  </View>
118
181
  </View>
119
182
 
183
+ {previewOpen && associations && canDelete && onDeletePress && (
184
+ <DeletePreview
185
+ adapter={adapter}
186
+ label={label}
187
+ id={id}
188
+ associations={associations}
189
+ softDelete={Boolean(deletedColumn)}
190
+ onConfirm={async () => { await onDeletePress(); setPreviewOpen(false); }}
191
+ onCancel={() => setPreviewOpen(false)}
192
+ />
193
+ )}
194
+
195
+ {restore && (
196
+ <View testID="soft-delete-banner" className="flex-row items-center justify-between mx-4 mb-4 p-3 rounded-md bg-red-50">
197
+ <Text className="text-sm text-red-700">{`Deleted ${formatDateTime(deletedAt)}`}</Text>
198
+ <RowActionButton action={restore} record={record} recordId={id} adapter={adapter} refetch={refetchQuietly} />
199
+ </View>
200
+ )}
201
+
120
202
  {/* Fields */}
121
203
  <Card style={{ marginHorizontal: 16, marginBottom: 16 }}>
122
- {showFields.map((fieldName, idx) => {
204
+ {columns ? (
205
+ <FieldColumns
206
+ columns={columns}
207
+ renderField={(fieldDef, idx) => (
208
+ <>
209
+ {idx > 0 && <Divider />}
210
+ <FieldRow field={fieldDef} value={record[fieldDef.name]} record={record} labels={relationLabels} />
211
+ </>
212
+ )}
213
+ />
214
+ ) : showFields.map((fieldName, idx) => {
123
215
  const fieldDef = fieldMap.get(fieldName);
124
216
  if (!fieldDef) return null;
125
217
  const value = record[fieldName];
126
218
  return (
127
219
  <React.Fragment key={fieldName}>
128
220
  {idx > 0 && <Divider />}
129
- <FieldRow field={fieldDef} value={value} record={record} />
221
+ <FieldRow field={fieldDef} value={value} record={record} labels={relationLabels} />
130
222
  </React.Fragment>
131
223
  );
132
224
  })}
@@ -158,7 +250,12 @@ async function fetchDetail(
158
250
  }
159
251
  return (await adapter.rpc(`${toCamelCase(resource)}EventDetail`, { id })) as DataRecord;
160
252
  }
161
- return adapter.fetchOne(resource, id);
253
+ const select = resourceConfig?.select;
254
+ // A soft-delete resource loads the row even when it is deleted, so Show can offer Restore.
255
+ if (resourceConfig?.softDelete) {
256
+ return adapter.fetchOne(resource, id, { ...(select ? { select } : {}), deleted: 'include' });
257
+ }
258
+ return select ? adapter.fetchOne(resource, id, { select }) : adapter.fetchOne(resource, id);
162
259
  }
163
260
 
164
261
  // =============================================================================
@@ -169,18 +266,26 @@ interface FieldRowProps {
169
266
  field: FieldDefinition;
170
267
  value: unknown;
171
268
  record: DataRecord;
269
+ labels: RelationLabels;
172
270
  }
173
271
 
174
- function FieldRow({ field, value, record }: FieldRowProps): React.ReactElement {
272
+ function FieldRow({ field, value, record, labels }: FieldRowProps): React.ReactElement {
175
273
  return (
176
274
  <View className="py-3">
177
275
  <Text className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-1">
178
276
  {field.label ?? field.name}
179
277
  </Text>
180
278
  {field.render ? (
181
- <>{field.render(value, record, { view: 'show' })}</>
279
+ <>{wrapBareText(field.render(value, record, { view: 'show' }))}</>
182
280
  ) : field.type === 'image' ? (
183
281
  <ImageDisplay field={field} value={value} view="show" />
282
+ ) : field.type === 'tags' ? (
283
+ <TagsDisplay field={field} value={value} />
284
+ ) : field.type === 'relation' && field.relation && value != null && value !== '' ? (
285
+ <RelationValue resource={field.relation.resource} value={value} labels={labels.get(field.name)} />
286
+ ) : value != null && (field.format === 'html' || field.format === 'markdown') ? (
287
+ // Stored rich text is sanitised at render by ui RichContent (web-only).
288
+ <RichContent value={value} format={field.format} className="text-sm text-gray-900" />
184
289
  ) : (
185
290
  <Text className="text-sm text-gray-900">{formatFieldValue(value, field)}</Text>
186
291
  )}
@@ -188,6 +293,11 @@ function FieldRow({ field, value, record }: FieldRowProps): React.ReactElement {
188
293
  );
189
294
  }
190
295
 
296
+ function formatDateTime(value: unknown): string {
297
+ const date = new Date(String(value));
298
+ return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString();
299
+ }
300
+
191
301
  function formatFieldValue(value: unknown, field: FieldDefinition): string {
192
302
  if (value == null) return '\u2014';
193
303
 
@@ -196,10 +306,8 @@ function formatFieldValue(value: unknown, field: FieldDefinition): string {
196
306
  return value ? 'Yes' : 'No';
197
307
  case 'date':
198
308
  return formatDateOnly(value);
199
- case 'datetime': {
200
- const date = new Date(String(value));
201
- return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString();
202
- }
309
+ case 'datetime':
310
+ return formatDateTime(value);
203
311
  case 'json':
204
312
  return JSON.stringify(value, null, 2);
205
313
  case 'url':