@everystack/admin 0.4.2 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +96 -1
- package/package.json +46 -12
- package/src/adapters/EverystackAdapter.ts +127 -12
- package/src/adapters/types.ts +22 -5
- package/src/components/AdminLink.tsx +59 -0
- package/src/components/CellText.tsx +18 -0
- package/src/components/DeletePreview.tsx +109 -0
- package/src/components/FieldColumns.tsx +86 -0
- package/src/components/FormActionBar.tsx +45 -0
- package/src/components/InlineEnumCell.tsx +98 -0
- package/src/components/RelationFieldInput.tsx +159 -0
- package/src/components/RelationValue.tsx +26 -0
- package/src/components/RowActionButton.tsx +24 -9
- package/src/components/TagsDisplay.tsx +22 -0
- package/src/components/TagsFieldInput.tsx +95 -0
- package/src/editors/rich-html-extensions.ts +63 -0
- package/src/editors/tiptap.tsx +121 -20
- package/src/editors/unsupported-markup.ts +150 -0
- package/src/hooks/useAdminData.ts +108 -24
- package/src/hooks/useAdminNavigation.ts +19 -0
- package/src/hooks/useListQuery.ts +47 -0
- package/src/hooks/useRelationLabels.ts +131 -0
- package/src/hooks/useScopeCounts.ts +77 -0
- package/src/hooks/useScrollToFirstError.ts +74 -0
- package/src/index.ts +17 -0
- package/src/screens/AdminRouter.tsx +30 -4
- package/src/screens/ResourceCreate.tsx +87 -37
- package/src/screens/ResourceEdit.tsx +93 -42
- package/src/screens/ResourceList.tsx +241 -53
- package/src/screens/ResourceShow.tsx +130 -22
- package/src/types.ts +197 -4
- package/src/utils/format-cell-value.ts +5 -0
- package/src/utils/list-query.ts +202 -0
- package/src/utils/required.ts +10 -0
- package/src/utils/resolve-columns.ts +107 -0
- package/src/utils/row-actions.ts +28 -0
- package/src/utils/scope-counts.ts +65 -0
- package/src/utils/scopes.ts +38 -0
- package/src/utils/standard-actions.ts +41 -5
- package/src/utils/tags.ts +40 -0
- package/src/utils/view-filters.ts +4 -2
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
86
|
-
}, [
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
{
|
|
127
|
-
|
|
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
|
-
|
|
133
|
-
title={`No ${
|
|
134
|
-
|
|
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={
|
|
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(
|
|
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
|
-
|
|
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(
|
|
185
|
-
|
|
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
|
-
/**
|
|
29
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
const
|
|
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={
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
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':
|