@everystack/admin 0.4.1 → 0.4.3
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 +73 -1
- package/package.json +45 -11
- package/src/adapters/EverystackAdapter.ts +107 -4
- package/src/adapters/types.ts +7 -1
- package/src/components/CellText.tsx +18 -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/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 +104 -24
- package/src/hooks/useListQuery.ts +45 -0
- package/src/hooks/useScopeCounts.ts +72 -0
- package/src/hooks/useScrollToFirstError.ts +74 -0
- package/src/index.ts +7 -0
- package/src/screens/AdminRouter.tsx +11 -0
- package/src/screens/ResourceCreate.tsx +74 -37
- package/src/screens/ResourceEdit.tsx +77 -40
- package/src/screens/ResourceList.tsx +204 -52
- package/src/screens/ResourceShow.tsx +57 -9
- package/src/types.ts +140 -4
- package/src/utils/format-cell-value.ts +5 -0
- package/src/utils/list-query.ts +199 -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 +63 -0
- package/src/utils/scopes.ts +38 -0
- package/src/utils/tags.ts +40 -0
- package/src/utils/view-filters.ts +1 -1
|
@@ -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,135 @@ 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,
|
|
104
129
|
});
|
|
130
|
+
const reqKey = `${paramsKey}#${refetchIndex}`;
|
|
105
131
|
|
|
106
132
|
// Latest-request guard: only the most recent fetch may write state, so a
|
|
107
133
|
// superseded in-flight response can't clobber newer data (out-of-order races).
|
|
108
134
|
const seqRef = useRef(0);
|
|
135
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
136
|
+
// The params of the data on screen, set only by a fetch that succeeded; null after an error.
|
|
137
|
+
const shownParamsRef = useRef<string | null>(null);
|
|
138
|
+
const lastRefetchIndexRef = useRef(refetchIndex);
|
|
139
|
+
// refetch() callers not yet taken by a fetch, and the ones the latest fetch carries.
|
|
140
|
+
const queuedRef = useRef<Array<() => void>>([]);
|
|
141
|
+
const carriedRef = useRef<Array<() => void>>([]);
|
|
109
142
|
|
|
110
143
|
useEffect(() => {
|
|
111
|
-
|
|
144
|
+
const byRefetch = refetchIndex !== lastRefetchIndexRef.current;
|
|
145
|
+
lastRefetchIndexRef.current = refetchIndex;
|
|
146
|
+
if (options.skip) {
|
|
147
|
+
settle(queuedRef.current.splice(0));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
112
150
|
const seq = ++seqRef.current;
|
|
151
|
+
// A superseded fetch hands its callers to this one: they resolve once fresh data is in.
|
|
152
|
+
const waiters = [...carriedRef.current, ...queuedRef.current.splice(0)];
|
|
153
|
+
carriedRef.current = waiters;
|
|
113
154
|
setLoading(true);
|
|
155
|
+
setRefreshing(byRefetch && shownParamsRef.current === paramsKey);
|
|
114
156
|
setError(null);
|
|
115
157
|
|
|
116
158
|
adapter
|
|
117
159
|
.fetchList<T>(resource, {
|
|
118
|
-
page,
|
|
119
|
-
perPage,
|
|
120
|
-
sort,
|
|
121
|
-
order,
|
|
160
|
+
page: pageValue,
|
|
161
|
+
perPage: perPageValue,
|
|
162
|
+
sort: sortValue,
|
|
163
|
+
order: orderValue,
|
|
122
164
|
// Base filters (time range, segment) sit beneath the in-view search filter.
|
|
123
165
|
filter: { ...(options.baseFilter ?? {}), ...filter },
|
|
124
|
-
search: options.search,
|
|
166
|
+
...(options.search !== undefined ? { search: options.search } : {}),
|
|
167
|
+
...(options.searchFields !== undefined ? { searchFields: options.searchFields } : {}),
|
|
125
168
|
})
|
|
126
169
|
.then(result => {
|
|
127
170
|
if (seq !== seqRef.current) return;
|
|
128
171
|
setData(result.data);
|
|
129
172
|
setTotal(result.total);
|
|
173
|
+
shownParamsRef.current = paramsKey;
|
|
130
174
|
})
|
|
131
175
|
.catch(err => {
|
|
132
176
|
if (seq !== seqRef.current) return;
|
|
133
177
|
setError(err instanceof Error ? err : new Error(String(err)));
|
|
178
|
+
shownParamsRef.current = null;
|
|
134
179
|
})
|
|
135
180
|
.finally(() => {
|
|
136
|
-
if (seq
|
|
181
|
+
if (seq !== seqRef.current) return;
|
|
182
|
+
setLoading(false);
|
|
183
|
+
setRefreshing(false);
|
|
184
|
+
carriedRef.current = [];
|
|
185
|
+
settle(waiters);
|
|
137
186
|
});
|
|
138
187
|
// reqKey encodes every input; the per-field deps are folded into it.
|
|
139
188
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
140
189
|
}, [adapter, reqKey, options.skip]);
|
|
141
190
|
|
|
191
|
+
// On unmount no fetch will write state again: release every waiting caller.
|
|
192
|
+
const mountedRef = useRef(true);
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
mountedRef.current = true;
|
|
195
|
+
return () => {
|
|
196
|
+
mountedRef.current = false;
|
|
197
|
+
settle(carriedRef.current.splice(0));
|
|
198
|
+
settle(queuedRef.current.splice(0));
|
|
199
|
+
};
|
|
200
|
+
}, []);
|
|
201
|
+
|
|
142
202
|
// Refetch by bumping the trigger — runs through the same effect/guard path.
|
|
143
|
-
|
|
203
|
+
// After unmount no effect will run, so it resolves at once.
|
|
204
|
+
const refetch = useCallback((): Promise<void> => new Promise<void>(resolve => {
|
|
205
|
+
if (!mountedRef.current) {
|
|
206
|
+
resolve();
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
queuedRef.current.push(resolve);
|
|
144
210
|
setRefetchIndex(i => i + 1);
|
|
145
|
-
}, []);
|
|
211
|
+
}), []);
|
|
146
212
|
|
|
147
213
|
const setSort = useCallback((field: string, newOrder?: 'asc' | 'desc') => {
|
|
214
|
+
if (controlled) return;
|
|
148
215
|
setSortState(field);
|
|
149
216
|
setOrder(newOrder ?? 'asc');
|
|
150
|
-
}, []);
|
|
217
|
+
}, [controlled]);
|
|
151
218
|
|
|
152
219
|
const setFilter = useCallback((newFilter: Record<string, unknown>) => {
|
|
153
220
|
setFilterState(newFilter);
|
|
154
|
-
setPage(1); // Reset to first page when filter changes
|
|
155
|
-
}, []);
|
|
221
|
+
if (!controlled) setPage(1); // Reset to first page when filter changes
|
|
222
|
+
}, [controlled]);
|
|
223
|
+
|
|
224
|
+
const setPageChecked = useCallback((next: number) => {
|
|
225
|
+
if (!controlled) setPage(next);
|
|
226
|
+
}, [controlled]);
|
|
227
|
+
|
|
228
|
+
const setPerPageChecked = useCallback((next: number) => {
|
|
229
|
+
if (!controlled) setPerPage(next);
|
|
230
|
+
}, [controlled]);
|
|
156
231
|
|
|
157
232
|
return {
|
|
158
233
|
data,
|
|
159
234
|
total,
|
|
160
235
|
loading,
|
|
236
|
+
refreshing,
|
|
161
237
|
error,
|
|
162
238
|
refetch,
|
|
163
|
-
page,
|
|
164
|
-
perPage,
|
|
165
|
-
setPage,
|
|
166
|
-
setPerPage,
|
|
239
|
+
page: pageValue,
|
|
240
|
+
perPage: perPageValue,
|
|
241
|
+
setPage: setPageChecked,
|
|
242
|
+
setPerPage: setPerPageChecked,
|
|
167
243
|
setSort,
|
|
168
244
|
setFilter,
|
|
169
245
|
};
|
|
170
246
|
}
|
|
247
|
+
|
|
248
|
+
function settle(waiters: Array<() => void>): void {
|
|
249
|
+
for (const resolve of waiters) resolve();
|
|
250
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
}
|
|
23
|
+
|
|
24
|
+
export function useListQuery(options: UseListQueryOptions = {}): UseListQueryResult {
|
|
25
|
+
const view = useContext(ViewStateContext);
|
|
26
|
+
const [local, setLocal] = useState<ListQuery>({});
|
|
27
|
+
|
|
28
|
+
const params = view?.params;
|
|
29
|
+
const urlQuery = useMemo(() => (params ? parseListQuery(params) : null), [params]);
|
|
30
|
+
const query = urlQuery ?? local;
|
|
31
|
+
|
|
32
|
+
// `update` is stable but must read the newest query: a debounce timer or an
|
|
33
|
+
// 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
|
+
|
|
37
|
+
const update = useCallback((patch: Partial<ListQuery>) => {
|
|
38
|
+
const { query: current, setParams, defaultPerPage } = latest.current;
|
|
39
|
+
const next = updateListQuery(current, patch);
|
|
40
|
+
if (setParams) setParams(listQueryToParams(next, { defaultPerPage }));
|
|
41
|
+
else setLocal(next);
|
|
42
|
+
}, []);
|
|
43
|
+
|
|
44
|
+
return { query, update };
|
|
45
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
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]), 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, { aggregates: count, filter: mergeFilters(baseFilter, scope.filter), ...shared })
|
|
56
|
+
.then(result => ({ [scope.id]: Number(result.data[0]?.count ?? 0) })),
|
|
57
|
+
),
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
Promise.allSettled(tasks).then(results => {
|
|
61
|
+
if (run !== latest.current) return;
|
|
62
|
+
const merged: Record<string, number> = {};
|
|
63
|
+
for (const result of results) if (result.status === 'fulfilled') Object.assign(merged, result.value);
|
|
64
|
+
setCounts(merged);
|
|
65
|
+
});
|
|
66
|
+
// Leaving (new inputs, or unmount) retires this run's answer.
|
|
67
|
+
return () => { latest.current++; };
|
|
68
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
69
|
+
}, [adapter, enabled, key, refreshToken]);
|
|
70
|
+
|
|
71
|
+
return counts;
|
|
72
|
+
}
|
|
@@ -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,7 @@ export type {
|
|
|
27
30
|
MenuItem,
|
|
28
31
|
AdminConfig,
|
|
29
32
|
ListParams,
|
|
33
|
+
ScopeConfig,
|
|
30
34
|
ListResult,
|
|
31
35
|
DataRecord,
|
|
32
36
|
AggregateFunction,
|
|
@@ -156,8 +160,10 @@ export { useEditors, EditorRegistryContext } from './hooks/useEditors';
|
|
|
156
160
|
// Image field components
|
|
157
161
|
export { ImageFieldInput } from './components/ImageFieldInput';
|
|
158
162
|
export { AvatarFieldInput } from './components/AvatarFieldInput';
|
|
163
|
+
export { TagsFieldInput, type TagsFieldInputProps } from './components/TagsFieldInput';
|
|
159
164
|
export { ImageDisplay } from './components/ImageDisplay';
|
|
160
165
|
export { RowActionButton, type RowActionButtonProps } from './components/RowActionButton';
|
|
166
|
+
export { FormActionBar, type FormActionBarProps } from './components/FormActionBar';
|
|
161
167
|
|
|
162
168
|
// Image utilities
|
|
163
169
|
export { resolveImageUrl, type ResolveImageUrlOptions } from './utils/resolve-image-url';
|
|
@@ -165,6 +171,7 @@ export { uploadFile, type UploadFileOptions, type UploadFileResult } from './uti
|
|
|
165
171
|
|
|
166
172
|
// Row actions
|
|
167
173
|
export { visibleRowActions } from './utils/row-actions';
|
|
174
|
+
export { enumScopes } from './utils/scopes';
|
|
168
175
|
|
|
169
176
|
// Utilities
|
|
170
177
|
export {
|
|
@@ -24,6 +24,13 @@ export interface AdminNavigator {
|
|
|
24
24
|
push: (href: string) => void;
|
|
25
25
|
back?: () => void;
|
|
26
26
|
replace?: (href: string) => void;
|
|
27
|
+
/**
|
|
28
|
+
* Update the current route's query in place. Preferred over `replace` for list state
|
|
29
|
+
* (search, sort, page, scope): expo-router's replace makes a new screen instance, which
|
|
30
|
+
* remounts the list and takes the cursor out of the search box. A key set to undefined
|
|
31
|
+
* is removed.
|
|
32
|
+
*/
|
|
33
|
+
setParams?: (params: Record<string, string | undefined>) => void;
|
|
27
34
|
}
|
|
28
35
|
|
|
29
36
|
export interface AdminRouterProps {
|
|
@@ -62,6 +69,10 @@ export function AdminRouter({
|
|
|
62
69
|
return {
|
|
63
70
|
params,
|
|
64
71
|
setParams: (next: Record<string, string | undefined>) => {
|
|
72
|
+
if (router.setParams) {
|
|
73
|
+
router.setParams(next);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
65
76
|
const merged: Record<string, string | undefined> = { ...params, ...next };
|
|
66
77
|
(router.replace ?? router.push)(`${pathname}${buildQueryString(merged)}`);
|
|
67
78
|
},
|
|
@@ -5,16 +5,22 @@
|
|
|
5
5
|
* Field type determines which input component is used.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import React, { useState, useCallback, useMemo } from 'react';
|
|
8
|
+
import React, { useState, 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
12
|
import { useEditors } from '../hooks/useEditors';
|
|
13
13
|
import { editorFor } from '../utils/render-resolution';
|
|
14
14
|
import { toDateInputValue } from '../utils/format-cell-value';
|
|
15
|
+
import { missingRequired } from '../utils/required';
|
|
15
16
|
import { FormField, TextInput, SelectInput, SwitchInput, DateInput, Button, ErrorState } from '@everystack/ui';
|
|
16
17
|
import { ImageFieldInput } from '../components/ImageFieldInput';
|
|
17
18
|
import { AvatarFieldInput } from '../components/AvatarFieldInput';
|
|
19
|
+
import { TagsFieldInput } from '../components/TagsFieldInput';
|
|
20
|
+
import { FormActionBar } from '../components/FormActionBar';
|
|
21
|
+
import { FieldColumns } from '../components/FieldColumns';
|
|
22
|
+
import { useScrollToFirstError } from '../hooks/useScrollToFirstError';
|
|
23
|
+
import { resolveColumns, screenLayout } from '../utils/resolve-columns';
|
|
18
24
|
import type { FieldDefinition } from '../types';
|
|
19
25
|
|
|
20
26
|
export interface ResourceCreateProps {
|
|
@@ -35,6 +41,8 @@ export function ResourceCreate({ resource, onSuccess, onCancel }: ResourceCreate
|
|
|
35
41
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
36
42
|
const [submitting, setSubmitting] = useState(false);
|
|
37
43
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
44
|
+
// Set synchronously so two presses in one tick (before a re-render) submit once.
|
|
45
|
+
const inFlight = useRef(false);
|
|
38
46
|
|
|
39
47
|
const fields = useMemo(() => {
|
|
40
48
|
if (!resourceConfig) return [];
|
|
@@ -46,6 +54,13 @@ export function ResourceCreate({ resource, onSuccess, onCancel }: ResourceCreate
|
|
|
46
54
|
.filter(f => f.name !== (resourceConfig.primaryKey ?? 'id'));
|
|
47
55
|
}, [resourceConfig]);
|
|
48
56
|
|
|
57
|
+
const editors = useEditors();
|
|
58
|
+
const columns = useMemo(() => {
|
|
59
|
+
const layout = resourceConfig ? screenLayout(resourceConfig, 'create') : undefined;
|
|
60
|
+
return layout ? resolveColumns(fields, layout, editors) : null;
|
|
61
|
+
}, [resourceConfig, fields, editors]);
|
|
62
|
+
const { scrollRef, onContainerLayout, onFieldOffset, requestScrollToFirstError } = useScrollToFirstError(errors);
|
|
63
|
+
|
|
49
64
|
const setFieldValue = useCallback((name: string, value: unknown) => {
|
|
50
65
|
setFormData(prev => ({ ...prev, [name]: value }));
|
|
51
66
|
setErrors(prev => {
|
|
@@ -55,21 +70,27 @@ export function ResourceCreate({ resource, onSuccess, onCancel }: ResourceCreate
|
|
|
55
70
|
});
|
|
56
71
|
}, []);
|
|
57
72
|
|
|
58
|
-
const validate = useCallback(():
|
|
73
|
+
const validate = useCallback((): Record<string, string> => {
|
|
59
74
|
const newErrors: Record<string, string> = {};
|
|
60
75
|
for (const field of fields) {
|
|
61
76
|
const value = formData[field.name];
|
|
62
|
-
if (field.validation?.required && (
|
|
77
|
+
if (field.validation?.required && missingRequired(field, value)) {
|
|
63
78
|
newErrors[field.name] = field.validation.message ?? `${field.label ?? field.name} is required`;
|
|
64
79
|
}
|
|
65
80
|
}
|
|
66
81
|
setErrors(newErrors);
|
|
67
|
-
return
|
|
82
|
+
return newErrors;
|
|
68
83
|
}, [fields, formData]);
|
|
69
84
|
|
|
70
85
|
const handleSubmit = useCallback(async () => {
|
|
71
|
-
if (
|
|
86
|
+
if (inFlight.current) return;
|
|
87
|
+
const invalid = validate();
|
|
88
|
+
if (Object.keys(invalid).length > 0) {
|
|
89
|
+
requestScrollToFirstError(fields);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
72
92
|
|
|
93
|
+
inFlight.current = true;
|
|
73
94
|
setSubmitting(true);
|
|
74
95
|
setSubmitError(null);
|
|
75
96
|
|
|
@@ -79,49 +100,59 @@ export function ResourceCreate({ resource, onSuccess, onCancel }: ResourceCreate
|
|
|
79
100
|
} catch (err) {
|
|
80
101
|
setSubmitError(err instanceof Error ? err.message : String(err));
|
|
81
102
|
} finally {
|
|
103
|
+
inFlight.current = false;
|
|
82
104
|
setSubmitting(false);
|
|
83
105
|
}
|
|
84
|
-
}, [adapter, resource, formData, validate, onSuccess]);
|
|
106
|
+
}, [adapter, resource, formData, validate, onSuccess, fields, requestScrollToFirstError]);
|
|
85
107
|
|
|
86
108
|
if (!resourceConfig) {
|
|
87
109
|
return <ErrorState message={`Resource "${resource}" not found in admin configuration`} />;
|
|
88
110
|
}
|
|
89
111
|
|
|
90
112
|
const label = resourceConfig.label ?? resourceConfig.name;
|
|
113
|
+
const renderInput = (field: FieldDefinition): React.ReactNode => (
|
|
114
|
+
<FieldInput
|
|
115
|
+
field={field}
|
|
116
|
+
value={formData[field.name]}
|
|
117
|
+
error={errors[field.name]}
|
|
118
|
+
onChange={value => setFieldValue(field.name, value)}
|
|
119
|
+
values={formData}
|
|
120
|
+
/>
|
|
121
|
+
);
|
|
91
122
|
|
|
92
123
|
return (
|
|
93
|
-
<
|
|
94
|
-
{
|
|
95
|
-
|
|
96
|
-
<
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
124
|
+
<View className="flex-1">
|
|
125
|
+
<ScrollView ref={scrollRef} className="flex-1">
|
|
126
|
+
{/* Header */}
|
|
127
|
+
<View className="flex-row items-center justify-between px-4 py-4">
|
|
128
|
+
<Text className="text-xl font-bold text-gray-900">Create {label}</Text>
|
|
129
|
+
<View className="flex-row gap-2">
|
|
130
|
+
{onCancel && (
|
|
131
|
+
<Button label="Cancel" variant="ghost" onPress={onCancel} />
|
|
132
|
+
)}
|
|
133
|
+
<Button label="Create" onPress={handleSubmit} loading={submitting} />
|
|
134
|
+
</View>
|
|
102
135
|
</View>
|
|
103
|
-
</View>
|
|
104
136
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
137
|
+
{/* Form fields */}
|
|
138
|
+
<View className="px-4" testID="form-fields" onLayout={onContainerLayout}>
|
|
139
|
+
{columns ? (
|
|
140
|
+
<FieldColumns columns={columns} renderField={renderInput} onFieldOffset={onFieldOffset} />
|
|
141
|
+
) : (
|
|
142
|
+
fields.map(field => (
|
|
143
|
+
<View
|
|
144
|
+
key={field.name}
|
|
145
|
+
testID={`form-fields-item-${field.name}`}
|
|
146
|
+
onLayout={event => onFieldOffset(field.name, event.nativeEvent.layout.y)}
|
|
147
|
+
>
|
|
148
|
+
{renderInput(field)}
|
|
149
|
+
</View>
|
|
150
|
+
))
|
|
151
|
+
)}
|
|
109
152
|
</View>
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
<View className="px-4">
|
|
114
|
-
{fields.map(field => (
|
|
115
|
-
<FieldInput
|
|
116
|
-
key={field.name}
|
|
117
|
-
field={field}
|
|
118
|
-
value={formData[field.name]}
|
|
119
|
-
error={errors[field.name]}
|
|
120
|
-
onChange={value => setFieldValue(field.name, value)}
|
|
121
|
-
/>
|
|
122
|
-
))}
|
|
123
|
-
</View>
|
|
124
|
-
</ScrollView>
|
|
153
|
+
</ScrollView>
|
|
154
|
+
<FormActionBar onSubmit={handleSubmit} onCancel={onCancel} submitting={submitting} submitLabel="Create" error={submitError} />
|
|
155
|
+
</View>
|
|
125
156
|
);
|
|
126
157
|
}
|
|
127
158
|
|
|
@@ -134,14 +165,17 @@ interface FieldInputProps {
|
|
|
134
165
|
value: unknown;
|
|
135
166
|
error?: string;
|
|
136
167
|
onChange: (value: unknown) => void;
|
|
168
|
+
/** The whole form's current values, for a custom input. */
|
|
169
|
+
values: Record<string, unknown>;
|
|
137
170
|
}
|
|
138
171
|
|
|
139
|
-
function FieldInput({ field, value, error, onChange }: FieldInputProps): React.ReactElement {
|
|
172
|
+
function FieldInput({ field, value, error, onChange, values }: FieldInputProps): React.ReactElement {
|
|
140
173
|
const editors = useEditors();
|
|
141
174
|
|
|
142
175
|
// Resolution: inline input → registered editor → type-based default.
|
|
143
176
|
if (field.input) {
|
|
144
|
-
|
|
177
|
+
// Its own component, so hooks inside a custom input keep their own order.
|
|
178
|
+
return React.createElement(field.input, { value, onChange, field, error, values });
|
|
145
179
|
}
|
|
146
180
|
const Editor = editorFor(field, editors);
|
|
147
181
|
if (Editor) {
|
|
@@ -256,6 +290,9 @@ function FieldInput({ field, value, error, onChange }: FieldInputProps): React.R
|
|
|
256
290
|
</FormField>
|
|
257
291
|
);
|
|
258
292
|
|
|
293
|
+
case 'tags':
|
|
294
|
+
return <TagsFieldInput field={field} value={value} error={error} onChange={onChange} />;
|
|
295
|
+
|
|
259
296
|
default:
|
|
260
297
|
return (
|
|
261
298
|
<FormField label={label} error={error} required={required}>
|