@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +96 -1
  2. package/package.json +46 -12
  3. package/src/adapters/EverystackAdapter.ts +127 -12
  4. package/src/adapters/types.ts +22 -5
  5. package/src/components/AdminLink.tsx +59 -0
  6. package/src/components/CellText.tsx +18 -0
  7. package/src/components/DeletePreview.tsx +109 -0
  8. package/src/components/FieldColumns.tsx +86 -0
  9. package/src/components/FormActionBar.tsx +45 -0
  10. package/src/components/InlineEnumCell.tsx +98 -0
  11. package/src/components/RelationFieldInput.tsx +159 -0
  12. package/src/components/RelationValue.tsx +26 -0
  13. package/src/components/RowActionButton.tsx +24 -9
  14. package/src/components/TagsDisplay.tsx +22 -0
  15. package/src/components/TagsFieldInput.tsx +95 -0
  16. package/src/editors/rich-html-extensions.ts +63 -0
  17. package/src/editors/tiptap.tsx +121 -20
  18. package/src/editors/unsupported-markup.ts +150 -0
  19. package/src/hooks/useAdminData.ts +108 -24
  20. package/src/hooks/useAdminNavigation.ts +19 -0
  21. package/src/hooks/useListQuery.ts +47 -0
  22. package/src/hooks/useRelationLabels.ts +131 -0
  23. package/src/hooks/useScopeCounts.ts +77 -0
  24. package/src/hooks/useScrollToFirstError.ts +74 -0
  25. package/src/index.ts +17 -0
  26. package/src/screens/AdminRouter.tsx +30 -4
  27. package/src/screens/ResourceCreate.tsx +87 -37
  28. package/src/screens/ResourceEdit.tsx +93 -42
  29. package/src/screens/ResourceList.tsx +241 -53
  30. package/src/screens/ResourceShow.tsx +130 -22
  31. package/src/types.ts +197 -4
  32. package/src/utils/format-cell-value.ts +5 -0
  33. package/src/utils/list-query.ts +202 -0
  34. package/src/utils/required.ts +10 -0
  35. package/src/utils/resolve-columns.ts +107 -0
  36. package/src/utils/row-actions.ts +28 -0
  37. package/src/utils/scope-counts.ts +65 -0
  38. package/src/utils/scopes.ts +38 -0
  39. package/src/utils/standard-actions.ts +41 -5
  40. package/src/utils/tags.ts +40 -0
  41. 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
- /** Refetch data */
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 reqKey = JSON.stringify({
119
+ const paramsKey = JSON.stringify({
95
120
  resource,
96
- page,
97
- perPage,
98
- sort: sort ?? null,
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
- refetchIndex,
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
- if (options.skip) return;
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 === seqRef.current) setLoading(false);
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
- const refetch = useCallback(async () => {
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 {