@everystack/admin 0.4.3 → 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.
@@ -0,0 +1,131 @@
1
+ /**
2
+ * useRelationLabels — the display names of relation fields, one batched
3
+ * request per related `resource.field`.
4
+ *
5
+ * Fields that point at the same `resource.field` share one fetchList: the
6
+ * unique ids of the rows go in one `in.(...)` filter, and the select names the
7
+ * matched column plus every displayField. Only ids not seen before are
8
+ * requested, so paging back costs nothing. An id outside /^[A-Za-z0-9_-]+$/
9
+ * never goes in the filter; it keeps showing raw.
10
+ *
11
+ * A failed request is swallowed: the labels stay missing and the cell keeps
12
+ * the id. The list never waits on this hook. Keys are always String(value),
13
+ * so a number in the row matches a string in the related row.
14
+ */
15
+
16
+ import { useEffect, useMemo, useRef, useState } from 'react';
17
+ import type { DataAdapter } from '../adapters/types';
18
+ import type { FieldDefinition } from '../types';
19
+
20
+ const RELATION_CHUNK = 100;
21
+
22
+ /** field name → String(stored value) → label. */
23
+ export type RelationLabels = Map<string, Map<string, string>>;
24
+
25
+ type Row = Record<string, unknown>;
26
+
27
+ interface RelationGroup {
28
+ /** `${resource}.${field}`, the cache key prefix. */
29
+ key: string;
30
+ resource: string;
31
+ field: string;
32
+ fields: FieldDefinition[];
33
+ displayFields: string[];
34
+ }
35
+
36
+ const SAFE_ID = /^[A-Za-z0-9_-]+$/;
37
+
38
+ /** String(value) when it may go in a filter and be a map key; null for null, undefined, '' or an unsafe id. */
39
+ export function safeRelationId(value: unknown): string | null {
40
+ if (value == null) return null;
41
+ const id = String(value);
42
+ return id !== '' && SAFE_ID.test(id) ? id : null;
43
+ }
44
+
45
+ function groupRelationFields(fields: FieldDefinition[]): RelationGroup[] {
46
+ const groups = new Map<string, RelationGroup>();
47
+ for (const field of fields) {
48
+ const relation = field.relation;
49
+ if (field.type !== 'relation' || !relation) continue;
50
+ const key = `${relation.resource}.${relation.field}`;
51
+ let group = groups.get(key);
52
+ if (!group) {
53
+ group = { key, resource: relation.resource, field: relation.field, fields: [], displayFields: [] };
54
+ groups.set(key, group);
55
+ }
56
+ group.fields.push(field);
57
+ if (relation.displayField && !group.displayFields.includes(relation.displayField)) {
58
+ group.displayFields.push(relation.displayField);
59
+ }
60
+ }
61
+ return [...groups.values()];
62
+ }
63
+
64
+ export function useRelationLabels(
65
+ adapter: DataAdapter,
66
+ fields: FieldDefinition[],
67
+ rows: ReadonlyArray<Row>,
68
+ ): RelationLabels {
69
+ // `${resource}.${field}:${id}` → the related row, for the component's lifetime.
70
+ const cache = useRef(new Map<string, Row>());
71
+ // Ids asked for (in flight or answered), so a re-render never asks twice.
72
+ const requested = useRef(new Set<string>());
73
+ const mounted = useRef(true);
74
+ const [version, setVersion] = useState(0);
75
+ const groups = useMemo(() => groupRelationFields(fields), [fields]);
76
+
77
+ useEffect(() => {
78
+ mounted.current = true;
79
+ return () => { mounted.current = false; };
80
+ }, []);
81
+
82
+ useEffect(() => {
83
+ for (const group of groups) {
84
+ if (group.displayFields.length === 0) continue;
85
+ const ids = [...new Set(
86
+ rows.flatMap(row => group.fields.map(f => safeRelationId(row[f.name]))),
87
+ )].filter((id): id is string => id !== null && !requested.current.has(`${group.key}:${id}`));
88
+ if (ids.length === 0) continue;
89
+ for (const id of ids) requested.current.add(`${group.key}:${id}`);
90
+
91
+ // 100 ids per request keeps the URL short (a page may hold 500 UUIDs).
92
+ for (let start = 0; start < ids.length; start += RELATION_CHUNK) {
93
+ const chunk = ids.slice(start, start + RELATION_CHUNK);
94
+ adapter.fetchList(group.resource, {
95
+ page: 1,
96
+ perPage: chunk.length,
97
+ filter: { [group.field]: `in.(${chunk.join(',')})` },
98
+ select: `${group.field},${group.displayFields.join(',')}`,
99
+ }).then(result => {
100
+ for (const related of result.data) {
101
+ cache.current.set(`${group.key}:${String(related[group.field])}`, related);
102
+ }
103
+ if (mounted.current) setVersion(v => v + 1);
104
+ }).catch(() => {
105
+ // The cell keeps the id. Forget the ask, so a later page may retry it.
106
+ for (const id of chunk) requested.current.delete(`${group.key}:${id}`);
107
+ });
108
+ }
109
+ }
110
+ }, [adapter, groups, rows]);
111
+
112
+ return useMemo(() => {
113
+ const labels: RelationLabels = new Map();
114
+ for (const group of groups) {
115
+ for (const field of group.fields) {
116
+ const byId = new Map<string, string>();
117
+ const displayField = field.relation?.displayField;
118
+ for (const row of rows) {
119
+ const id = safeRelationId(row[field.name]);
120
+ if (id === null || !displayField) continue;
121
+ const label = cache.current.get(`${group.key}:${id}`)?.[displayField];
122
+ if (label != null) byId.set(id, String(label));
123
+ }
124
+ labels.set(field.name, byId);
125
+ }
126
+ }
127
+ return labels;
128
+ // `version` is the signal that the cache grew.
129
+ // eslint-disable-next-line react-hooks/exhaustive-deps
130
+ }, [groups, rows, version]);
131
+ }
@@ -30,7 +30,7 @@ export function useScopeCounts(options: UseScopeCountsOptions): Record<string, n
30
30
  const { resource, scopes, enabled, baseFilter, search, searchFields, refreshToken } = options;
31
31
 
32
32
  // The inputs by value: a new object with the same content must not ask again.
33
- const key = JSON.stringify([resource, scopes?.map(s => [s.id, s.filter]), baseFilter, search, searchFields]);
33
+ const key = JSON.stringify([resource, scopes?.map(s => [s.id, s.filter, s.deleted]), baseFilter, search, searchFields]);
34
34
 
35
35
  useEffect(() => {
36
36
  const aggregate = adapter.aggregate?.bind(adapter);
@@ -52,7 +52,12 @@ export function useScopeCounts(options: UseScopeCountsOptions): Record<string, n
52
52
  .then(result => countsFromGrouped(result.data, group)),
53
53
  ),
54
54
  ...plan.single.map(scope =>
55
- aggregate(resource, { aggregates: count, filter: mergeFilters(baseFilter, scope.filter), ...shared })
55
+ aggregate(resource, {
56
+ aggregates: count,
57
+ filter: mergeFilters(baseFilter, scope.filter),
58
+ ...shared,
59
+ ...(scope.deleted ? { deleted: scope.deleted } : {}),
60
+ })
56
61
  .then(result => ({ [scope.id]: Number(result.data[0]?.count ?? 0) })),
57
62
  ),
58
63
  ];
package/src/index.ts CHANGED
@@ -31,6 +31,8 @@ export type {
31
31
  AdminConfig,
32
32
  ListParams,
33
33
  ScopeConfig,
34
+ AssociationConfig,
35
+ DeletedMode,
34
36
  ListResult,
35
37
  DataRecord,
36
38
  AggregateFunction,
@@ -90,6 +92,12 @@ export {
90
92
  type ViewStateContextValue,
91
93
  } from './hooks/useViewState';
92
94
 
95
+ export {
96
+ AdminNavigationContext,
97
+ useAdminNavigation,
98
+ type AdminNavigationContextValue,
99
+ } from './hooks/useAdminNavigation';
100
+
93
101
  // RPC Factory
94
102
  export {
95
103
  createResourceDashboardRpcs,
@@ -161,8 +169,10 @@ export { useEditors, EditorRegistryContext } from './hooks/useEditors';
161
169
  export { ImageFieldInput } from './components/ImageFieldInput';
162
170
  export { AvatarFieldInput } from './components/AvatarFieldInput';
163
171
  export { TagsFieldInput, type TagsFieldInputProps } from './components/TagsFieldInput';
172
+ export { RelationFieldInput, type RelationFieldInputProps } from './components/RelationFieldInput';
164
173
  export { ImageDisplay } from './components/ImageDisplay';
165
174
  export { RowActionButton, type RowActionButtonProps } from './components/RowActionButton';
175
+ export { AdminLink, type AdminLinkProps } from './components/AdminLink';
166
176
  export { FormActionBar, type FormActionBarProps } from './components/FormActionBar';
167
177
 
168
178
  // Image utilities
@@ -12,6 +12,7 @@ import React, { useMemo } from 'react';
12
12
  import { useAdminConfig } from '../hooks/useAdminConfig';
13
13
  import { useDataAdapter } from '../hooks/useAdminData';
14
14
  import { ViewStateContext, normalizeParams, buildQueryString } from '../hooks/useViewState';
15
+ import { AdminNavigationContext } from '../hooks/useAdminNavigation';
15
16
  import { AdminDashboard } from './AdminDashboard';
16
17
  import { ResourceListView } from './ResourceListView';
17
18
  import { ResourceShow } from './ResourceShow';
@@ -80,10 +81,17 @@ export function AdminRouter({
80
81
  // eslint-disable-next-line react-hooks/exhaustive-deps
81
82
  }, [JSON.stringify(searchParams), pathname, router]);
82
83
 
84
+ const navigation = useMemo(
85
+ () => ({ push: (href: string) => router.push(href), basePath, resources: config.resources.map(r => r.name) }),
86
+ [router, basePath, config.resources],
87
+ );
88
+
83
89
  return (
84
- <ViewStateContext.Provider value={viewState}>
85
- {renderScreen()}
86
- </ViewStateContext.Provider>
90
+ <AdminNavigationContext.Provider value={navigation}>
91
+ <ViewStateContext.Provider value={viewState}>
92
+ {renderScreen()}
93
+ </ViewStateContext.Provider>
94
+ </AdminNavigationContext.Provider>
87
95
  );
88
96
 
89
97
  function renderScreen(): React.ReactElement {
@@ -111,15 +119,22 @@ export function AdminRouter({
111
119
  />
112
120
  );
113
121
 
114
- case 'show':
122
+ case 'show': {
123
+ // Show gets Delete only behind the delete preview, i.e. when the resource declares associations.
124
+ const hasAssociations = Boolean(config.resources.find(r => r.name === match.resource)?.associations?.length);
115
125
  return (
116
126
  <ResourceShow
117
127
  resource={match.resource!}
118
128
  id={match.id!}
119
129
  onEditPress={() => go({ resource: match.resource, id: match.id, action: 'edit' })}
130
+ onDeletePress={hasAssociations
131
+ ? async () => { await adapter.delete(match.resource!, match.id!); go({ resource: match.resource }); }
132
+ : undefined}
120
133
  onBackPress={() => go({ resource: match.resource })}
134
+ confirmDelete={viewState.params.confirmDelete === '1'}
121
135
  />
122
136
  );
137
+ }
123
138
 
124
139
  case 'list':
125
140
  default: {
@@ -17,6 +17,7 @@ import { FormField, TextInput, SelectInput, SwitchInput, DateInput, Button, Erro
17
17
  import { ImageFieldInput } from '../components/ImageFieldInput';
18
18
  import { AvatarFieldInput } from '../components/AvatarFieldInput';
19
19
  import { TagsFieldInput } from '../components/TagsFieldInput';
20
+ import { RelationFieldInput } from '../components/RelationFieldInput';
20
21
  import { FormActionBar } from '../components/FormActionBar';
21
22
  import { FieldColumns } from '../components/FieldColumns';
22
23
  import { useScrollToFirstError } from '../hooks/useScrollToFirstError';
@@ -171,6 +172,7 @@ interface FieldInputProps {
171
172
 
172
173
  function FieldInput({ field, value, error, onChange, values }: FieldInputProps): React.ReactElement {
173
174
  const editors = useEditors();
175
+ const adapter = useDataAdapter();
174
176
 
175
177
  // Resolution: inline input → registered editor → type-based default.
176
178
  if (field.input) {
@@ -293,6 +295,17 @@ function FieldInput({ field, value, error, onChange, values }: FieldInputProps):
293
295
  case 'tags':
294
296
  return <TagsFieldInput field={field} value={value} error={error} onChange={onChange} />;
295
297
 
298
+ case 'relation':
299
+ // Without `relation` there is nothing to search: the plain text input below.
300
+ if (field.relation) {
301
+ return (
302
+ <FormField label={label} error={error} required={required}>
303
+ <RelationFieldInput field={field} value={value} onChange={onChange} adapter={adapter} />
304
+ </FormField>
305
+ );
306
+ }
307
+ // falls through
308
+
296
309
  default:
297
310
  return (
298
311
  <FormField label={label} error={error} required={required}>
@@ -17,6 +17,7 @@ import { FormField, TextInput, SelectInput, SwitchInput, DateInput, Button, Load
17
17
  import { ImageFieldInput } from '../components/ImageFieldInput';
18
18
  import { AvatarFieldInput } from '../components/AvatarFieldInput';
19
19
  import { TagsFieldInput } from '../components/TagsFieldInput';
20
+ import { RelationFieldInput } from '../components/RelationFieldInput';
20
21
  import { FormActionBar } from '../components/FormActionBar';
21
22
  import { FieldColumns } from '../components/FieldColumns';
22
23
  import { useScrollToFirstError } from '../hooks/useScrollToFirstError';
@@ -71,7 +72,8 @@ export function ResourceEdit({ resource, id, onSuccess, onCancel }: ResourceEdit
71
72
  setLoading(true);
72
73
  setFetchError(null);
73
74
  try {
74
- const result = await adapter.fetchOne(resource, id);
75
+ const select = resourceConfig?.select;
76
+ const result = select ? await adapter.fetchOne(resource, id, { select }) : await adapter.fetchOne(resource, id);
75
77
  setRecord(result);
76
78
  // Pre-populate form data with existing values
77
79
  const initial: Record<string, unknown> = {};
@@ -84,7 +86,7 @@ export function ResourceEdit({ resource, id, onSuccess, onCancel }: ResourceEdit
84
86
  } finally {
85
87
  setLoading(false);
86
88
  }
87
- }, [adapter, resource, id, fields]);
89
+ }, [adapter, resource, id, fields, resourceConfig?.select]);
88
90
 
89
91
  useEffect(() => {
90
92
  fetchRecord();
@@ -216,6 +218,7 @@ interface EditFieldInputProps {
216
218
 
217
219
  function EditFieldInput({ field, value, error, onChange, record, values }: EditFieldInputProps): React.ReactElement {
218
220
  const editors = useEditors();
221
+ const adapter = useDataAdapter();
219
222
 
220
223
  // Resolution: inline input → registered editor → type-based default.
221
224
  if (field.input) {
@@ -335,6 +338,17 @@ function EditFieldInput({ field, value, error, onChange, record, values }: EditF
335
338
  case 'tags':
336
339
  return <TagsFieldInput field={field} value={value} error={error} onChange={onChange} />;
337
340
 
341
+ case 'relation':
342
+ // Without `relation` there is nothing to search: the plain text input below.
343
+ if (field.relation) {
344
+ return (
345
+ <FormField label={label} error={error} required={required}>
346
+ <RelationFieldInput field={field} value={value} onChange={onChange} adapter={adapter} />
347
+ </FormField>
348
+ );
349
+ }
350
+ // falls through
351
+
338
352
  default:
339
353
  return (
340
354
  <FormField label={label} error={error} required={required}>
@@ -21,6 +21,8 @@ import { View, Text } from 'react-native';
21
21
  import { useAdminConfig } from '../hooks/useAdminConfig';
22
22
  import { useAdminData, useDataAdapter } from '../hooks/useAdminData';
23
23
  import { useListQuery } from '../hooks/useListQuery';
24
+ import { useAdminNavigation } from '../hooks/useAdminNavigation';
25
+ import { adminHref } from '../utils/admin-routing';
24
26
  import { buildListParams, mergeFilters, nextSort, normalizeSearchText, pageForSize, parseListQuery, resolvePageSize } from '../utils/list-query';
25
27
  import { resolveScope } from '../utils/scopes';
26
28
  import { useScopeCounts } from '../hooks/useScopeCounts';
@@ -34,6 +36,8 @@ import { ImageDisplay } from '../components/ImageDisplay';
34
36
  import { RowActionButton } from '../components/RowActionButton';
35
37
  import { InlineEnumCell } from '../components/InlineEnumCell';
36
38
  import { wrapBareText } from '../components/CellText';
39
+ import { RelationValue } from '../components/RelationValue';
40
+ import { useRelationLabels, type RelationLabels } from '../hooks/useRelationLabels';
37
41
  import type { DataAdapter } from '../adapters/types';
38
42
  import type { ResourceConfig, FieldDefinition, DataRecord, RowActionConfig } from '../types';
39
43
 
@@ -64,7 +68,8 @@ export function ResourceList({ resource, onRowPress, onEditPress, onCreatePress,
64
68
  () => [...new Set([...PAGE_SIZES, defaultPerPage])].sort((a, b) => a - b),
65
69
  [defaultPerPage],
66
70
  );
67
- const { query, update } = useListQuery({ defaultPerPage });
71
+ const defaultScope = resolveScope(resourceConfig?.scopes, undefined)?.id;
72
+ const { query, update } = useListQuery({ defaultPerPage, defaultScope });
68
73
 
69
74
  const searchFields = useMemo(
70
75
  () => resourceConfig?.fields.filter(f => f.searchable).map(f => f.name) ?? [],
@@ -79,7 +84,9 @@ export function ResourceList({ resource, onRowPress, onEditPress, onCreatePress,
79
84
  [resourceConfig, query.scope],
80
85
  );
81
86
  const listParams = useMemo(
82
- () => buildListParams(query, { perPage: defaultPerPage, baseFilter, scopeFilter: activeScope?.filter, sortableFields, defaultSort }),
87
+ () => buildListParams(query, {
88
+ perPage: defaultPerPage, baseFilter, scopeFilter: activeScope?.filter, scopeDeleted: activeScope?.deleted, sortableFields, defaultSort,
89
+ }),
83
90
  [query, defaultPerPage, baseFilter, activeScope, sortableFields, defaultSort],
84
91
  );
85
92
 
@@ -120,6 +127,8 @@ export function ResourceList({ resource, onRowPress, onEditPress, onCreatePress,
120
127
  sort: listParams.sort,
121
128
  order: listParams.order,
122
129
  ...searchParams,
130
+ select: resourceConfig?.select,
131
+ deleted: listParams.deleted,
123
132
  baseFilter: mergeFilters(listParams.filter, legacySearchFilter),
124
133
  });
125
134
 
@@ -157,6 +166,14 @@ export function ResourceList({ resource, onRowPress, onEditPress, onCreatePress,
157
166
  // eslint-disable-next-line react-hooks/exhaustive-deps
158
167
  }, [searchText]);
159
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
+
160
177
  // Build columns from field definitions, plus an actions column combining the
161
178
  // built-in Show/Edit/Delete (gated by actions.*) with any custom rowActions.
162
179
  // Returns the refetch, so the cell can re-sync once the new rows are in.
@@ -165,18 +182,27 @@ export function ResourceList({ resource, onRowPress, onEditPress, onCreatePress,
165
182
  return refetch();
166
183
  }, [refetch]);
167
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();
168
188
  const columns = useMemo(() => {
169
189
  if (!resourceConfig) return [];
170
- const cols = fieldsToColumns(resourceConfig, adapter, afterInlineEdit);
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;
171
197
  const actions = [
172
- ...standardRowActions(resourceConfig, { onShow: onRowPress, onEdit: onEditPress }),
198
+ ...standardRowActions(resourceConfig, { onShow: onRowPress, onEdit: onEditPress, onConfirmDelete }, { deletedOnly }),
173
199
  ...(resourceConfig.rowActions ?? []),
174
200
  ];
175
201
  if (actions.length) {
176
202
  cols.push(actionsColumn(resourceConfig, actions, adapter, refetch));
177
203
  }
178
204
  return cols;
179
- }, [resourceConfig, adapter, refetch, afterInlineEdit, onRowPress, onEditPress]);
205
+ }, [resourceConfig, adapter, refetch, afterInlineEdit, onRowPress, onEditPress, relationLabels, deletedOnly, navigation, resource]);
180
206
 
181
207
  // The click cycles from what is shown (the default sort included), not from the URL alone.
182
208
  const handleSort = useCallback((key: string) => {
@@ -278,6 +304,8 @@ function fieldsToColumns(
278
304
  resourceConfig: ResourceConfig,
279
305
  adapter: DataAdapter,
280
306
  onInlineSaved: () => Promise<void>,
307
+ relationLabels: RelationLabels,
308
+ deletedOnly: boolean,
281
309
  ): DataTableColumn[] {
282
310
  const fieldNames = resourceConfig.listFields ?? resourceConfig.fields.map(f => f.name);
283
311
  const fieldMap = new Map(resourceConfig.fields.map(f => [f.name, f]));
@@ -292,23 +320,31 @@ function fieldsToColumns(
292
320
  ...(field.listWidth !== undefined && { width: field.listWidth }),
293
321
  ...(field.listPriority !== undefined && { priority: field.listPriority }),
294
322
  ...(field.listHideBelow !== undefined && { hideBelow: field.listHideBelow }),
295
- render: cellRenderer(field, resourceConfig, adapter, onInlineSaved),
323
+ render: cellRenderer(field, resourceConfig, adapter, onInlineSaved, relationLabels, deletedOnly),
296
324
  }));
297
325
  }
298
326
 
299
327
  /**
300
328
  * Cell rendering precedence: custom render → image thumbnail → type text.
301
329
  * (See render-resolution.cellRenderKind.) An `inlineEdit` enum field shows a
302
- * picker in place of the type text, unless the resource sets `actions.edit: false`.
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.
303
333
  */
304
334
  function cellRenderer(
305
335
  field: FieldDefinition,
306
336
  resourceConfig: ResourceConfig,
307
337
  adapter: DataAdapter,
308
338
  onInlineSaved: () => Promise<void>,
339
+ relationLabels: RelationLabels,
340
+ deletedOnly: boolean,
309
341
  ): DataTableColumn['render'] {
310
342
  const kind = cellRenderKind(field);
311
- if (kind === 'text' && field.inlineEdit === true && field.type === 'enum' && resourceConfig.actions?.edit !== false) {
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) {
312
348
  const primaryKey = resourceConfig.primaryKey ?? 'id';
313
349
  return (value, record) => (
314
350
  <InlineEnumCell
@@ -5,19 +5,23 @@
5
5
  * Fetches via adapter.fetchOne() and displays field values.
6
6
  */
7
7
 
8
- import React, { useState, useEffect, useLayoutEffect, useCallback, useRef } 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
14
  import { TagsDisplay } from '../components/TagsDisplay';
15
15
  import { RowActionButton } from '../components/RowActionButton';
16
+ import { DeletePreview } from '../components/DeletePreview';
16
17
  import { FieldColumns } from '../components/FieldColumns';
18
+ import { RelationValue } from '../components/RelationValue';
19
+ import { useRelationLabels, type RelationLabels } from '../hooks/useRelationLabels';
17
20
  import { useEditors } from '../hooks/useEditors';
18
21
  import { resolveColumns, screenLayout } from '../utils/resolve-columns';
19
22
  import { wrapBareText } from '../components/CellText';
20
23
  import { visibleRowActions } from '../utils/row-actions';
24
+ import { restoreAction, softDeleteColumn } from '../utils/standard-actions';
21
25
  import { toCamelCase } from '../utils/naming';
22
26
  import { formatDateOnly } from '../utils/format-cell-value';
23
27
  import type { FieldDefinition, DataRecord, ResourceConfig } from '../types';
@@ -30,10 +34,15 @@ export interface ResourceShowProps {
30
34
  id: string | number;
31
35
  /** Called when the edit button is pressed */
32
36
  onEditPress?: () => void;
33
- /** Called when the delete button is pressed */
34
- 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>;
35
42
  /** Called when the back/list button is pressed */
36
43
  onBackPress?: () => void;
44
+ /** Open the delete preview on mount (a resource with `associations` only). */
45
+ confirmDelete?: boolean;
37
46
  }
38
47
 
39
48
  export function ResourceShow({
@@ -42,6 +51,7 @@ export function ResourceShow({
42
51
  onEditPress,
43
52
  onDeletePress,
44
53
  onBackPress,
54
+ confirmDelete,
45
55
  }: ResourceShowProps): React.ReactElement {
46
56
  const config = useAdminConfig();
47
57
  const adapter = useDataAdapter();
@@ -51,6 +61,8 @@ export function ResourceShow({
51
61
  const [record, setRecord] = useState<DataRecord | null>(null);
52
62
  const [loading, setLoading] = useState(true);
53
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));
54
66
 
55
67
  // Latest-request guard: only the most recent load may write state, so an older
56
68
  // id's or an older request's response can't land over a newer one.
@@ -90,6 +102,15 @@ export function ResourceShow({
90
102
  fetchRecord();
91
103
  }, [fetchRecord]);
92
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
+
93
114
  if (!resourceConfig) {
94
115
  return <ErrorState message={`Resource "${resource}" not found in admin configuration`} />;
95
116
  }
@@ -109,8 +130,14 @@ export function ResourceShow({
109
130
  const label = resourceConfig.label ?? resourceConfig.name;
110
131
  const showFields = resourceConfig.showFields ?? resourceConfig.fields.map(f => f.name);
111
132
  const fieldMap = new Map(resourceConfig.fields.map(f => [f.name, f]));
112
- const canEdit = resourceConfig.actions?.edit !== false;
113
- 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;
114
141
  const rowActions = visibleRowActions(resourceConfig.rowActions, record);
115
142
  const layout = screenLayout(resourceConfig, 'show');
116
143
  const columns = layout
@@ -148,11 +175,30 @@ export function ResourceShow({
148
175
  <Button label="Edit" variant="outline" onPress={onEditPress} />
149
176
  )}
150
177
  {canDelete && onDeletePress && (
151
- <Button label="Delete" variant="destructive" onPress={onDeletePress} />
178
+ <Button label="Delete" variant="destructive" onPress={associations ? () => setPreviewOpen(true) : onDeletePress} />
152
179
  )}
153
180
  </View>
154
181
  </View>
155
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
+
156
202
  {/* Fields */}
157
203
  <Card style={{ marginHorizontal: 16, marginBottom: 16 }}>
158
204
  {columns ? (
@@ -161,7 +207,7 @@ export function ResourceShow({
161
207
  renderField={(fieldDef, idx) => (
162
208
  <>
163
209
  {idx > 0 && <Divider />}
164
- <FieldRow field={fieldDef} value={record[fieldDef.name]} record={record} />
210
+ <FieldRow field={fieldDef} value={record[fieldDef.name]} record={record} labels={relationLabels} />
165
211
  </>
166
212
  )}
167
213
  />
@@ -172,7 +218,7 @@ export function ResourceShow({
172
218
  return (
173
219
  <React.Fragment key={fieldName}>
174
220
  {idx > 0 && <Divider />}
175
- <FieldRow field={fieldDef} value={value} record={record} />
221
+ <FieldRow field={fieldDef} value={value} record={record} labels={relationLabels} />
176
222
  </React.Fragment>
177
223
  );
178
224
  })}
@@ -204,7 +250,12 @@ async function fetchDetail(
204
250
  }
205
251
  return (await adapter.rpc(`${toCamelCase(resource)}EventDetail`, { id })) as DataRecord;
206
252
  }
207
- 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);
208
259
  }
209
260
 
210
261
  // =============================================================================
@@ -215,9 +266,10 @@ interface FieldRowProps {
215
266
  field: FieldDefinition;
216
267
  value: unknown;
217
268
  record: DataRecord;
269
+ labels: RelationLabels;
218
270
  }
219
271
 
220
- function FieldRow({ field, value, record }: FieldRowProps): React.ReactElement {
272
+ function FieldRow({ field, value, record, labels }: FieldRowProps): React.ReactElement {
221
273
  return (
222
274
  <View className="py-3">
223
275
  <Text className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-1">
@@ -229,6 +281,11 @@ function FieldRow({ field, value, record }: FieldRowProps): React.ReactElement {
229
281
  <ImageDisplay field={field} value={value} view="show" />
230
282
  ) : field.type === 'tags' ? (
231
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" />
232
289
  ) : (
233
290
  <Text className="text-sm text-gray-900">{formatFieldValue(value, field)}</Text>
234
291
  )}
@@ -236,6 +293,11 @@ function FieldRow({ field, value, record }: FieldRowProps): React.ReactElement {
236
293
  );
237
294
  }
238
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
+
239
301
  function formatFieldValue(value: unknown, field: FieldDefinition): string {
240
302
  if (value == null) return '\u2014';
241
303
 
@@ -244,10 +306,8 @@ function formatFieldValue(value: unknown, field: FieldDefinition): string {
244
306
  return value ? 'Yes' : 'No';
245
307
  case 'date':
246
308
  return formatDateOnly(value);
247
- case 'datetime': {
248
- const date = new Date(String(value));
249
- return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString();
250
- }
309
+ case 'datetime':
310
+ return formatDateTime(value);
251
311
  case 'json':
252
312
  return JSON.stringify(value, null, 2);
253
313
  case 'url':