@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
|
@@ -39,6 +39,13 @@ export interface UseAdminDataOptions extends ListParams {
|
|
|
39
39
|
* Memoize it at the call site to keep identity stable across renders.
|
|
40
40
|
*/
|
|
41
41
|
baseFilter?: Record<string, unknown>;
|
|
42
|
+
/**
|
|
43
|
+
* The caller owns the list query (e.g. the URL). `page`, `perPage`, `sort`,
|
|
44
|
+
* `order`, `search` and `searchFields` are then the values to fetch, read on every
|
|
45
|
+
* render, not starting values; the hook's own setters for them do nothing.
|
|
46
|
+
* Without this, they are only initial values and the hook keeps its own state.
|
|
47
|
+
*/
|
|
48
|
+
controlled?: boolean;
|
|
42
49
|
}
|
|
43
50
|
|
|
44
51
|
export interface UseAdminDataResult<T extends DataRecord = DataRecord> {
|
|
@@ -50,7 +57,19 @@ export interface UseAdminDataResult<T extends DataRecord = DataRecord> {
|
|
|
50
57
|
loading: boolean;
|
|
51
58
|
/** Error state */
|
|
52
59
|
error: Error | null;
|
|
53
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* True while a fetch started by `refetch()` runs over data an earlier fetch
|
|
62
|
+
* already put on screen, with no error shown. A screen can keep that data in
|
|
63
|
+
* view instead of a loading state. `loading` is still true during it. False
|
|
64
|
+
* on the first load, on a page, sort, search or filter change, and on a
|
|
65
|
+
* retry after an error.
|
|
66
|
+
*/
|
|
67
|
+
refreshing: boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Fetch again with the same params. Resolves after that fetch has settled and
|
|
70
|
+
* its result (data, total, or error) is in state. Never rejects: a failure
|
|
71
|
+
* lands in `error`.
|
|
72
|
+
*/
|
|
54
73
|
refetch: () => Promise<void>;
|
|
55
74
|
/** Current page */
|
|
56
75
|
page: number;
|
|
@@ -76,13 +95,19 @@ export function useAdminData<T extends DataRecord = DataRecord>(
|
|
|
76
95
|
const [loading, setLoading] = useState(!options.skip);
|
|
77
96
|
const [error, setError] = useState<Error | null>(null);
|
|
78
97
|
|
|
79
|
-
// Params state
|
|
98
|
+
// Params state. In controlled mode the caller's options win over this state.
|
|
99
|
+
const controlled = options.controlled === true;
|
|
80
100
|
const [page, setPage] = useState(options.page ?? 1);
|
|
81
101
|
const [perPage, setPerPage] = useState(options.perPage ?? 10);
|
|
82
102
|
const [sort, setSortState] = useState(options.sort);
|
|
83
103
|
const [order, setOrder] = useState(options.order ?? 'asc');
|
|
84
104
|
const [filter, setFilterState] = useState(options.filter ?? {});
|
|
85
105
|
|
|
106
|
+
const pageValue = controlled ? options.page ?? 1 : page;
|
|
107
|
+
const perPageValue = controlled ? options.perPage ?? 10 : perPage;
|
|
108
|
+
const sortValue = controlled ? options.sort : sort;
|
|
109
|
+
const orderValue = controlled ? options.order ?? 'asc' : order;
|
|
110
|
+
|
|
86
111
|
// Manual-refetch trigger (bumped by refetch()).
|
|
87
112
|
const [refetchIndex, setRefetchIndex] = useState(0);
|
|
88
113
|
|
|
@@ -91,80 +116,139 @@ export function useAdminData<T extends DataRecord = DataRecord>(
|
|
|
91
116
|
// new-but-equal `filter`/`baseFilter` (e.g. FilterBar's initial empty search,
|
|
92
117
|
// or the auth refresh re-rendering the tree) does NOT re-fire the fetch.
|
|
93
118
|
// This is what collapses the per-load double request.
|
|
94
|
-
const
|
|
119
|
+
const paramsKey = JSON.stringify({
|
|
95
120
|
resource,
|
|
96
|
-
page,
|
|
97
|
-
perPage,
|
|
98
|
-
sort:
|
|
99
|
-
order,
|
|
121
|
+
page: pageValue,
|
|
122
|
+
perPage: perPageValue,
|
|
123
|
+
sort: sortValue ?? null,
|
|
124
|
+
order: orderValue,
|
|
100
125
|
baseFilter: options.baseFilter ?? {},
|
|
101
126
|
filter,
|
|
102
127
|
search: options.search ?? null,
|
|
103
|
-
|
|
128
|
+
searchFields: options.searchFields ?? null,
|
|
129
|
+
select: options.select ?? null,
|
|
130
|
+
deleted: options.deleted ?? null,
|
|
104
131
|
});
|
|
132
|
+
const reqKey = `${paramsKey}#${refetchIndex}`;
|
|
105
133
|
|
|
106
134
|
// Latest-request guard: only the most recent fetch may write state, so a
|
|
107
135
|
// superseded in-flight response can't clobber newer data (out-of-order races).
|
|
108
136
|
const seqRef = useRef(0);
|
|
137
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
138
|
+
// The params of the data on screen, set only by a fetch that succeeded; null after an error.
|
|
139
|
+
const shownParamsRef = useRef<string | null>(null);
|
|
140
|
+
const lastRefetchIndexRef = useRef(refetchIndex);
|
|
141
|
+
// refetch() callers not yet taken by a fetch, and the ones the latest fetch carries.
|
|
142
|
+
const queuedRef = useRef<Array<() => void>>([]);
|
|
143
|
+
const carriedRef = useRef<Array<() => void>>([]);
|
|
109
144
|
|
|
110
145
|
useEffect(() => {
|
|
111
|
-
|
|
146
|
+
const byRefetch = refetchIndex !== lastRefetchIndexRef.current;
|
|
147
|
+
lastRefetchIndexRef.current = refetchIndex;
|
|
148
|
+
if (options.skip) {
|
|
149
|
+
settle(queuedRef.current.splice(0));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
112
152
|
const seq = ++seqRef.current;
|
|
153
|
+
// A superseded fetch hands its callers to this one: they resolve once fresh data is in.
|
|
154
|
+
const waiters = [...carriedRef.current, ...queuedRef.current.splice(0)];
|
|
155
|
+
carriedRef.current = waiters;
|
|
113
156
|
setLoading(true);
|
|
157
|
+
setRefreshing(byRefetch && shownParamsRef.current === paramsKey);
|
|
114
158
|
setError(null);
|
|
115
159
|
|
|
116
160
|
adapter
|
|
117
161
|
.fetchList<T>(resource, {
|
|
118
|
-
page,
|
|
119
|
-
perPage,
|
|
120
|
-
sort,
|
|
121
|
-
order,
|
|
162
|
+
page: pageValue,
|
|
163
|
+
perPage: perPageValue,
|
|
164
|
+
sort: sortValue,
|
|
165
|
+
order: orderValue,
|
|
122
166
|
// Base filters (time range, segment) sit beneath the in-view search filter.
|
|
123
167
|
filter: { ...(options.baseFilter ?? {}), ...filter },
|
|
124
|
-
search: options.search,
|
|
168
|
+
...(options.search !== undefined ? { search: options.search } : {}),
|
|
169
|
+
...(options.searchFields !== undefined ? { searchFields: options.searchFields } : {}),
|
|
170
|
+
...(options.select ? { select: options.select } : {}),
|
|
171
|
+
...(options.deleted ? { deleted: options.deleted } : {}),
|
|
125
172
|
})
|
|
126
173
|
.then(result => {
|
|
127
174
|
if (seq !== seqRef.current) return;
|
|
128
175
|
setData(result.data);
|
|
129
176
|
setTotal(result.total);
|
|
177
|
+
shownParamsRef.current = paramsKey;
|
|
130
178
|
})
|
|
131
179
|
.catch(err => {
|
|
132
180
|
if (seq !== seqRef.current) return;
|
|
133
181
|
setError(err instanceof Error ? err : new Error(String(err)));
|
|
182
|
+
shownParamsRef.current = null;
|
|
134
183
|
})
|
|
135
184
|
.finally(() => {
|
|
136
|
-
if (seq
|
|
185
|
+
if (seq !== seqRef.current) return;
|
|
186
|
+
setLoading(false);
|
|
187
|
+
setRefreshing(false);
|
|
188
|
+
carriedRef.current = [];
|
|
189
|
+
settle(waiters);
|
|
137
190
|
});
|
|
138
191
|
// reqKey encodes every input; the per-field deps are folded into it.
|
|
139
192
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
140
193
|
}, [adapter, reqKey, options.skip]);
|
|
141
194
|
|
|
195
|
+
// On unmount no fetch will write state again: release every waiting caller.
|
|
196
|
+
const mountedRef = useRef(true);
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
mountedRef.current = true;
|
|
199
|
+
return () => {
|
|
200
|
+
mountedRef.current = false;
|
|
201
|
+
settle(carriedRef.current.splice(0));
|
|
202
|
+
settle(queuedRef.current.splice(0));
|
|
203
|
+
};
|
|
204
|
+
}, []);
|
|
205
|
+
|
|
142
206
|
// Refetch by bumping the trigger — runs through the same effect/guard path.
|
|
143
|
-
|
|
207
|
+
// After unmount no effect will run, so it resolves at once.
|
|
208
|
+
const refetch = useCallback((): Promise<void> => new Promise<void>(resolve => {
|
|
209
|
+
if (!mountedRef.current) {
|
|
210
|
+
resolve();
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
queuedRef.current.push(resolve);
|
|
144
214
|
setRefetchIndex(i => i + 1);
|
|
145
|
-
}, []);
|
|
215
|
+
}), []);
|
|
146
216
|
|
|
147
217
|
const setSort = useCallback((field: string, newOrder?: 'asc' | 'desc') => {
|
|
218
|
+
if (controlled) return;
|
|
148
219
|
setSortState(field);
|
|
149
220
|
setOrder(newOrder ?? 'asc');
|
|
150
|
-
}, []);
|
|
221
|
+
}, [controlled]);
|
|
151
222
|
|
|
152
223
|
const setFilter = useCallback((newFilter: Record<string, unknown>) => {
|
|
153
224
|
setFilterState(newFilter);
|
|
154
|
-
setPage(1); // Reset to first page when filter changes
|
|
155
|
-
}, []);
|
|
225
|
+
if (!controlled) setPage(1); // Reset to first page when filter changes
|
|
226
|
+
}, [controlled]);
|
|
227
|
+
|
|
228
|
+
const setPageChecked = useCallback((next: number) => {
|
|
229
|
+
if (!controlled) setPage(next);
|
|
230
|
+
}, [controlled]);
|
|
231
|
+
|
|
232
|
+
const setPerPageChecked = useCallback((next: number) => {
|
|
233
|
+
if (!controlled) setPerPage(next);
|
|
234
|
+
}, [controlled]);
|
|
156
235
|
|
|
157
236
|
return {
|
|
158
237
|
data,
|
|
159
238
|
total,
|
|
160
239
|
loading,
|
|
240
|
+
refreshing,
|
|
161
241
|
error,
|
|
162
242
|
refetch,
|
|
163
|
-
page,
|
|
164
|
-
perPage,
|
|
165
|
-
setPage,
|
|
166
|
-
setPerPage,
|
|
243
|
+
page: pageValue,
|
|
244
|
+
perPage: perPageValue,
|
|
245
|
+
setPage: setPageChecked,
|
|
246
|
+
setPerPage: setPerPageChecked,
|
|
167
247
|
setSort,
|
|
168
248
|
setFilter,
|
|
169
249
|
};
|
|
170
250
|
}
|
|
251
|
+
|
|
252
|
+
function settle(waiters: Array<() => void>): void {
|
|
253
|
+
for (const resolve of waiters) resolve();
|
|
254
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useListQuery — a list's query (search, scope, sort, page, page size).
|
|
3
|
+
*
|
|
4
|
+
* The URL owns it when a view-state provider is present, so every list view is
|
|
5
|
+
* a link. Outside a provider (a list embedded without the router) it falls back
|
|
6
|
+
* to local state, so the list still works. The rules live in utils/list-query.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useCallback, useContext, useMemo, useRef, useState } from 'react';
|
|
10
|
+
import { ViewStateContext } from './useViewState';
|
|
11
|
+
import { listQueryToParams, parseListQuery, updateListQuery, type ListQuery } from '../utils/list-query';
|
|
12
|
+
|
|
13
|
+
export interface UseListQueryResult {
|
|
14
|
+
query: ListQuery;
|
|
15
|
+
/** Apply a change. Changing search, scope or sort returns to page 1. */
|
|
16
|
+
update: (patch: Partial<ListQuery>) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface UseListQueryOptions {
|
|
20
|
+
/** The resource's page size. A query at this size leaves `size` out of the URL. */
|
|
21
|
+
defaultPerPage?: number;
|
|
22
|
+
/** The resource's default scope id. A query on this scope leaves `tab` out of the URL. */
|
|
23
|
+
defaultScope?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useListQuery(options: UseListQueryOptions = {}): UseListQueryResult {
|
|
27
|
+
const view = useContext(ViewStateContext);
|
|
28
|
+
const [local, setLocal] = useState<ListQuery>({});
|
|
29
|
+
|
|
30
|
+
const params = view?.params;
|
|
31
|
+
const urlQuery = useMemo(() => (params ? parseListQuery(params) : null), [params]);
|
|
32
|
+
const query = urlQuery ?? local;
|
|
33
|
+
|
|
34
|
+
// `update` is stable but must read the newest query: a debounce timer or an
|
|
35
|
+
// event handler may call it from a render that is already out of date.
|
|
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 };
|
|
38
|
+
|
|
39
|
+
const update = useCallback((patch: Partial<ListQuery>) => {
|
|
40
|
+
const { query: current, setParams, defaultPerPage, defaultScope } = latest.current;
|
|
41
|
+
const next = updateListQuery(current, patch);
|
|
42
|
+
if (setParams) setParams(listQueryToParams(next, { defaultPerPage, defaultScope }));
|
|
43
|
+
else setLocal(next);
|
|
44
|
+
}, []);
|
|
45
|
+
|
|
46
|
+
return { query, update };
|
|
47
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useScopeCounts — the number on each scope tab. Opt-in (`enabled`); off, it makes no request.
|
|
3
|
+
*
|
|
4
|
+
* Counts are decoration, so they never break the list: a count that fails is left out, and an
|
|
5
|
+
* adapter without `aggregate` shows none. A slower, older answer never overwrites a newer one.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useEffect, useRef, useState } from 'react';
|
|
9
|
+
import { useDataAdapter } from './useAdminData';
|
|
10
|
+
import { mergeFilters } from '../utils/list-query';
|
|
11
|
+
import { planScopeCounts, countsFromGrouped } from '../utils/scope-counts';
|
|
12
|
+
import type { ScopeConfig } from '../types';
|
|
13
|
+
|
|
14
|
+
export interface UseScopeCountsOptions {
|
|
15
|
+
resource: string;
|
|
16
|
+
scopes: ScopeConfig[] | undefined;
|
|
17
|
+
enabled: boolean;
|
|
18
|
+
/** The page's own filter, applied to every tab. A tab's own filter is added per tab. */
|
|
19
|
+
baseFilter?: Record<string, unknown>;
|
|
20
|
+
search?: string;
|
|
21
|
+
searchFields?: string[];
|
|
22
|
+
/** Change it to ask again with the same inputs, e.g. after a row was edited. */
|
|
23
|
+
refreshToken?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useScopeCounts(options: UseScopeCountsOptions): Record<string, number> | undefined {
|
|
27
|
+
const adapter = useDataAdapter();
|
|
28
|
+
const [counts, setCounts] = useState<Record<string, number> | undefined>(undefined);
|
|
29
|
+
const latest = useRef(0);
|
|
30
|
+
const { resource, scopes, enabled, baseFilter, search, searchFields, refreshToken } = options;
|
|
31
|
+
|
|
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, s.deleted]), baseFilter, search, searchFields]);
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
const aggregate = adapter.aggregate?.bind(adapter);
|
|
37
|
+
if (!enabled || !scopes?.length || !aggregate) {
|
|
38
|
+
setCounts(undefined);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const run = ++latest.current;
|
|
42
|
+
const plan = planScopeCounts(scopes);
|
|
43
|
+
const count = [{ function: 'count' as const }];
|
|
44
|
+
const shared = {
|
|
45
|
+
...(search !== undefined ? { search } : {}),
|
|
46
|
+
...(searchFields !== undefined ? { searchFields } : {}),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const tasks: Array<Promise<Record<string, number>>> = [
|
|
50
|
+
...plan.grouped.map(group =>
|
|
51
|
+
aggregate(resource, { aggregates: count, groupBy: [group.column], filter: baseFilter, ...shared })
|
|
52
|
+
.then(result => countsFromGrouped(result.data, group)),
|
|
53
|
+
),
|
|
54
|
+
...plan.single.map(scope =>
|
|
55
|
+
aggregate(resource, {
|
|
56
|
+
aggregates: count,
|
|
57
|
+
filter: mergeFilters(baseFilter, scope.filter),
|
|
58
|
+
...shared,
|
|
59
|
+
...(scope.deleted ? { deleted: scope.deleted } : {}),
|
|
60
|
+
})
|
|
61
|
+
.then(result => ({ [scope.id]: Number(result.data[0]?.count ?? 0) })),
|
|
62
|
+
),
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
Promise.allSettled(tasks).then(results => {
|
|
66
|
+
if (run !== latest.current) return;
|
|
67
|
+
const merged: Record<string, number> = {};
|
|
68
|
+
for (const result of results) if (result.status === 'fulfilled') Object.assign(merged, result.value);
|
|
69
|
+
setCounts(merged);
|
|
70
|
+
});
|
|
71
|
+
// Leaving (new inputs, or unmount) retires this run's answer.
|
|
72
|
+
return () => { latest.current++; };
|
|
73
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
74
|
+
}, [adapter, enabled, key, refreshToken]);
|
|
75
|
+
|
|
76
|
+
return counts;
|
|
77
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useScrollToFirstError — after a failed submit, scroll an Edit/Create form
|
|
3
|
+
* so the topmost errored field on screen is in view. With a layout that can
|
|
4
|
+
* differ from the first errored field in field order: stacked columns draw
|
|
5
|
+
* the main column first.
|
|
6
|
+
*
|
|
7
|
+
* The scroll waits for the render that shows the errors. A scroll in the same
|
|
8
|
+
* tick as setErrors runs before the error line is laid out, so the scroll
|
|
9
|
+
* range does not include it yet and the message can stay below the visible
|
|
10
|
+
* area. A request is pending until the next change to `errors`; a change with
|
|
11
|
+
* no request pending (typing clears an error) does not scroll.
|
|
12
|
+
*
|
|
13
|
+
* Offsets come from onLayout: the fields container's y in the scroll content,
|
|
14
|
+
* plus each field's y in that container. An errored field with no offset yet
|
|
15
|
+
* is skipped; equal offsets keep field order. With no ScrollView ref, no
|
|
16
|
+
* container offset, or no errored field with an offset it does nothing.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { useCallback, useEffect, useRef, type RefObject } from 'react';
|
|
20
|
+
import type { LayoutChangeEvent, ScrollView } from 'react-native';
|
|
21
|
+
import type { FieldDefinition } from '../types';
|
|
22
|
+
|
|
23
|
+
export interface ScrollToFirstError {
|
|
24
|
+
/** Attach to the form's ScrollView. */
|
|
25
|
+
scrollRef: RefObject<ScrollView | null>;
|
|
26
|
+
/** Attach to the View that holds the fields, a direct child of the scroll content. */
|
|
27
|
+
onContainerLayout: (event: LayoutChangeEvent) => void;
|
|
28
|
+
/** A field's y within that View. */
|
|
29
|
+
onFieldOffset: (name: string, y: number) => void;
|
|
30
|
+
/**
|
|
31
|
+
* Call with the screen's fields when validation fails, after setErrors.
|
|
32
|
+
* Once the new errors have rendered, scroll to the errored field with the
|
|
33
|
+
* smallest offset.
|
|
34
|
+
*/
|
|
35
|
+
requestScrollToFirstError: (fields: FieldDefinition[]) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function useScrollToFirstError(errors: Record<string, string>): ScrollToFirstError {
|
|
39
|
+
const scrollRef = useRef<ScrollView | null>(null);
|
|
40
|
+
const containerY = useRef<number | undefined>(undefined);
|
|
41
|
+
const fieldY = useRef(new Map<string, number>());
|
|
42
|
+
const pending = useRef<FieldDefinition[] | null>(null);
|
|
43
|
+
|
|
44
|
+
const onContainerLayout = useCallback((event: LayoutChangeEvent) => {
|
|
45
|
+
containerY.current = event.nativeEvent.layout.y;
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
const onFieldOffset = useCallback((name: string, y: number) => {
|
|
49
|
+
fieldY.current.set(name, y);
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
const requestScrollToFirstError = useCallback((fields: FieldDefinition[]) => {
|
|
53
|
+
pending.current = fields;
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
const fields = pending.current;
|
|
58
|
+
if (!fields) return;
|
|
59
|
+
pending.current = null;
|
|
60
|
+
let topmost: number | undefined;
|
|
61
|
+
for (const field of fields) {
|
|
62
|
+
if (errors[field.name] === undefined) continue;
|
|
63
|
+
const y = fieldY.current.get(field.name);
|
|
64
|
+
// Strict `<`: on a tie the earlier field in field order stays.
|
|
65
|
+
if (y !== undefined && (topmost === undefined || y < topmost)) topmost = y;
|
|
66
|
+
}
|
|
67
|
+
const top = containerY.current;
|
|
68
|
+
const scroll = scrollRef.current;
|
|
69
|
+
if (topmost === undefined || top === undefined || !scroll) return;
|
|
70
|
+
scroll.scrollTo({ y: top + topmost, animated: true });
|
|
71
|
+
}, [errors]);
|
|
72
|
+
|
|
73
|
+
return { scrollRef, onContainerLayout, onFieldOffset, requestScrollToFirstError };
|
|
74
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
export type {
|
|
11
11
|
FieldType,
|
|
12
12
|
FieldDefinition,
|
|
13
|
+
TagGroup,
|
|
13
14
|
ImageSize,
|
|
14
15
|
CellContext,
|
|
15
16
|
ActionContext,
|
|
@@ -19,6 +20,8 @@ export type {
|
|
|
19
20
|
EditorComponent,
|
|
20
21
|
EditorRegistry,
|
|
21
22
|
ResourceConfig,
|
|
23
|
+
ScreenLayout,
|
|
24
|
+
LayoutColumn,
|
|
22
25
|
StorageConfig,
|
|
23
26
|
WidgetType,
|
|
24
27
|
ChartType,
|
|
@@ -27,6 +30,9 @@ export type {
|
|
|
27
30
|
MenuItem,
|
|
28
31
|
AdminConfig,
|
|
29
32
|
ListParams,
|
|
33
|
+
ScopeConfig,
|
|
34
|
+
AssociationConfig,
|
|
35
|
+
DeletedMode,
|
|
30
36
|
ListResult,
|
|
31
37
|
DataRecord,
|
|
32
38
|
AggregateFunction,
|
|
@@ -86,6 +92,12 @@ export {
|
|
|
86
92
|
type ViewStateContextValue,
|
|
87
93
|
} from './hooks/useViewState';
|
|
88
94
|
|
|
95
|
+
export {
|
|
96
|
+
AdminNavigationContext,
|
|
97
|
+
useAdminNavigation,
|
|
98
|
+
type AdminNavigationContextValue,
|
|
99
|
+
} from './hooks/useAdminNavigation';
|
|
100
|
+
|
|
89
101
|
// RPC Factory
|
|
90
102
|
export {
|
|
91
103
|
createResourceDashboardRpcs,
|
|
@@ -156,8 +168,12 @@ export { useEditors, EditorRegistryContext } from './hooks/useEditors';
|
|
|
156
168
|
// Image field components
|
|
157
169
|
export { ImageFieldInput } from './components/ImageFieldInput';
|
|
158
170
|
export { AvatarFieldInput } from './components/AvatarFieldInput';
|
|
171
|
+
export { TagsFieldInput, type TagsFieldInputProps } from './components/TagsFieldInput';
|
|
172
|
+
export { RelationFieldInput, type RelationFieldInputProps } from './components/RelationFieldInput';
|
|
159
173
|
export { ImageDisplay } from './components/ImageDisplay';
|
|
160
174
|
export { RowActionButton, type RowActionButtonProps } from './components/RowActionButton';
|
|
175
|
+
export { AdminLink, type AdminLinkProps } from './components/AdminLink';
|
|
176
|
+
export { FormActionBar, type FormActionBarProps } from './components/FormActionBar';
|
|
161
177
|
|
|
162
178
|
// Image utilities
|
|
163
179
|
export { resolveImageUrl, type ResolveImageUrlOptions } from './utils/resolve-image-url';
|
|
@@ -165,6 +181,7 @@ export { uploadFile, type UploadFileOptions, type UploadFileResult } from './uti
|
|
|
165
181
|
|
|
166
182
|
// Row actions
|
|
167
183
|
export { visibleRowActions } from './utils/row-actions';
|
|
184
|
+
export { enumScopes } from './utils/scopes';
|
|
168
185
|
|
|
169
186
|
// Utilities
|
|
170
187
|
export {
|