@everystack/admin 0.4.2 → 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.
@@ -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,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 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,
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
- if (options.skip) return;
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 === seqRef.current) setLoading(false);
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
- const refetch = useCallback(async () => {
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((): boolean => {
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 && (value == null || value === '')) {
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 Object.keys(newErrors).length === 0;
82
+ return newErrors;
68
83
  }, [fields, formData]);
69
84
 
70
85
  const handleSubmit = useCallback(async () => {
71
- if (!validate()) return;
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
- <ScrollView className="flex-1">
94
- {/* Header */}
95
- <View className="flex-row items-center justify-between px-4 py-4">
96
- <Text className="text-xl font-bold text-gray-900">Create {label}</Text>
97
- <View className="flex-row gap-2">
98
- {onCancel && (
99
- <Button label="Cancel" variant="ghost" onPress={onCancel} />
100
- )}
101
- <Button label="Create" onPress={handleSubmit} loading={submitting} />
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
- {/* Error */}
106
- {submitError && (
107
- <View className="mx-4 mb-4 bg-red-50 border border-red-200 rounded-lg px-4 py-3">
108
- <Text className="text-sm text-red-700">{submitError}</Text>
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
- {/* Form fields */}
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
- return <>{field.input({ value, onChange, field, error })}</>;
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}>