@everystack/admin 0.4.3 → 0.4.5
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 +33 -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/editors/rich-html-extensions.ts +38 -7
- 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 +59 -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,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AdminNavigationContext — what an admin link needs to navigate: the router's
|
|
3
|
+
* push, the admin's mount point, and the configured resource names (a link
|
|
4
|
+
* to any other resource renders as plain text). AdminRouter provides it.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createContext, useContext } from 'react';
|
|
8
|
+
|
|
9
|
+
export interface AdminNavigationContextValue {
|
|
10
|
+
push(href: string): void;
|
|
11
|
+
basePath: string;
|
|
12
|
+
resources: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const AdminNavigationContext = createContext<AdminNavigationContextValue | null>(null);
|
|
16
|
+
|
|
17
|
+
export function useAdminNavigation(): AdminNavigationContextValue | null {
|
|
18
|
+
return useContext(AdminNavigationContext);
|
|
19
|
+
}
|
|
@@ -19,6 +19,8 @@ export interface UseListQueryResult {
|
|
|
19
19
|
export interface UseListQueryOptions {
|
|
20
20
|
/** The resource's page size. A query at this size leaves `size` out of the URL. */
|
|
21
21
|
defaultPerPage?: number;
|
|
22
|
+
/** The resource's default scope id. A query on this scope leaves `tab` out of the URL. */
|
|
23
|
+
defaultScope?: string;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
export function useListQuery(options: UseListQueryOptions = {}): UseListQueryResult {
|
|
@@ -31,13 +33,13 @@ export function useListQuery(options: UseListQueryOptions = {}): UseListQueryRes
|
|
|
31
33
|
|
|
32
34
|
// `update` is stable but must read the newest query: a debounce timer or an
|
|
33
35
|
// event handler may call it from a render that is already out of date.
|
|
34
|
-
const latest = useRef({ query, setParams: view?.setParams, defaultPerPage: options.defaultPerPage });
|
|
35
|
-
latest.current = { query, setParams: view?.setParams, defaultPerPage: options.defaultPerPage };
|
|
36
|
+
const latest = useRef({ query, setParams: view?.setParams, defaultPerPage: options.defaultPerPage, defaultScope: options.defaultScope });
|
|
37
|
+
latest.current = { query, setParams: view?.setParams, defaultPerPage: options.defaultPerPage, defaultScope: options.defaultScope };
|
|
36
38
|
|
|
37
39
|
const update = useCallback((patch: Partial<ListQuery>) => {
|
|
38
|
-
const { query: current, setParams, defaultPerPage } = latest.current;
|
|
40
|
+
const { query: current, setParams, defaultPerPage, defaultScope } = latest.current;
|
|
39
41
|
const next = updateListQuery(current, patch);
|
|
40
|
-
if (setParams) setParams(listQueryToParams(next, { defaultPerPage }));
|
|
42
|
+
if (setParams) setParams(listQueryToParams(next, { defaultPerPage, defaultScope }));
|
|
41
43
|
else setLocal(next);
|
|
42
44
|
}, []);
|
|
43
45
|
|
|
@@ -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
|