@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.
- package/CHANGELOG.md +23 -0
- package/package.json +4 -4
- package/src/adapters/EverystackAdapter.ts +30 -9
- package/src/adapters/types.ts +15 -4
- package/src/components/AdminLink.tsx +59 -0
- package/src/components/DeletePreview.tsx +109 -0
- package/src/components/RelationFieldInput.tsx +159 -0
- package/src/components/RelationValue.tsx +26 -0
- package/src/hooks/useAdminData.ts +4 -0
- package/src/hooks/useAdminNavigation.ts +19 -0
- package/src/hooks/useListQuery.ts +6 -4
- package/src/hooks/useRelationLabels.ts +131 -0
- package/src/hooks/useScopeCounts.ts +7 -2
- package/src/index.ts +10 -0
- package/src/screens/AdminRouter.tsx +19 -4
- package/src/screens/ResourceCreate.tsx +13 -0
- package/src/screens/ResourceEdit.tsx +16 -2
- package/src/screens/ResourceList.tsx +44 -8
- package/src/screens/ResourceShow.tsx +75 -15
- package/src/types.ts +57 -0
- package/src/utils/list-query.ts +7 -4
- package/src/utils/scope-counts.ts +2 -0
- package/src/utils/standard-actions.ts +41 -5
- package/src/utils/view-filters.ts +3 -1
|
@@ -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, {
|
|
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
|
-
<
|
|
85
|
-
{
|
|
86
|
-
|
|
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
|
|
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
|
|
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, {
|
|
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
|
-
|
|
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
|
-
/**
|
|
34
|
-
|
|
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
|
-
|
|
113
|
-
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;
|
|
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
|
-
|
|
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
|
-
|
|
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':
|