@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
@@ -12,6 +12,7 @@ import React, { useMemo } from 'react';
12
12
  import { useAdminConfig } from '../hooks/useAdminConfig';
13
13
  import { useDataAdapter } from '../hooks/useAdminData';
14
14
  import { ViewStateContext, normalizeParams, buildQueryString } from '../hooks/useViewState';
15
+ import { AdminNavigationContext } from '../hooks/useAdminNavigation';
15
16
  import { AdminDashboard } from './AdminDashboard';
16
17
  import { ResourceListView } from './ResourceListView';
17
18
  import { ResourceShow } from './ResourceShow';
@@ -24,6 +25,13 @@ export interface AdminNavigator {
24
25
  push: (href: string) => void;
25
26
  back?: () => void;
26
27
  replace?: (href: string) => void;
28
+ /**
29
+ * Update the current route's query in place. Preferred over `replace` for list state
30
+ * (search, sort, page, scope): expo-router's replace makes a new screen instance, which
31
+ * remounts the list and takes the cursor out of the search box. A key set to undefined
32
+ * is removed.
33
+ */
34
+ setParams?: (params: Record<string, string | undefined>) => void;
27
35
  }
28
36
 
29
37
  export interface AdminRouterProps {
@@ -62,6 +70,10 @@ export function AdminRouter({
62
70
  return {
63
71
  params,
64
72
  setParams: (next: Record<string, string | undefined>) => {
73
+ if (router.setParams) {
74
+ router.setParams(next);
75
+ return;
76
+ }
65
77
  const merged: Record<string, string | undefined> = { ...params, ...next };
66
78
  (router.replace ?? router.push)(`${pathname}${buildQueryString(merged)}`);
67
79
  },
@@ -69,10 +81,17 @@ export function AdminRouter({
69
81
  // eslint-disable-next-line react-hooks/exhaustive-deps
70
82
  }, [JSON.stringify(searchParams), pathname, router]);
71
83
 
84
+ const navigation = useMemo(
85
+ () => ({ push: (href: string) => router.push(href), basePath, resources: config.resources.map(r => r.name) }),
86
+ [router, basePath, config.resources],
87
+ );
88
+
72
89
  return (
73
- <ViewStateContext.Provider value={viewState}>
74
- {renderScreen()}
75
- </ViewStateContext.Provider>
90
+ <AdminNavigationContext.Provider value={navigation}>
91
+ <ViewStateContext.Provider value={viewState}>
92
+ {renderScreen()}
93
+ </ViewStateContext.Provider>
94
+ </AdminNavigationContext.Provider>
76
95
  );
77
96
 
78
97
  function renderScreen(): React.ReactElement {
@@ -100,15 +119,22 @@ export function AdminRouter({
100
119
  />
101
120
  );
102
121
 
103
- case 'show':
122
+ case 'show': {
123
+ // Show gets Delete only behind the delete preview, i.e. when the resource declares associations.
124
+ const hasAssociations = Boolean(config.resources.find(r => r.name === match.resource)?.associations?.length);
104
125
  return (
105
126
  <ResourceShow
106
127
  resource={match.resource!}
107
128
  id={match.id!}
108
129
  onEditPress={() => go({ resource: match.resource, id: match.id, action: 'edit' })}
130
+ onDeletePress={hasAssociations
131
+ ? async () => { await adapter.delete(match.resource!, match.id!); go({ resource: match.resource }); }
132
+ : undefined}
109
133
  onBackPress={() => go({ resource: match.resource })}
134
+ confirmDelete={viewState.params.confirmDelete === '1'}
110
135
  />
111
136
  );
137
+ }
112
138
 
113
139
  case 'list':
114
140
  default: {
@@ -5,16 +5,23 @@
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 { RelationFieldInput } from '../components/RelationFieldInput';
21
+ import { FormActionBar } from '../components/FormActionBar';
22
+ import { FieldColumns } from '../components/FieldColumns';
23
+ import { useScrollToFirstError } from '../hooks/useScrollToFirstError';
24
+ import { resolveColumns, screenLayout } from '../utils/resolve-columns';
18
25
  import type { FieldDefinition } from '../types';
19
26
 
20
27
  export interface ResourceCreateProps {
@@ -35,6 +42,8 @@ export function ResourceCreate({ resource, onSuccess, onCancel }: ResourceCreate
35
42
  const [errors, setErrors] = useState<Record<string, string>>({});
36
43
  const [submitting, setSubmitting] = useState(false);
37
44
  const [submitError, setSubmitError] = useState<string | null>(null);
45
+ // Set synchronously so two presses in one tick (before a re-render) submit once.
46
+ const inFlight = useRef(false);
38
47
 
39
48
  const fields = useMemo(() => {
40
49
  if (!resourceConfig) return [];
@@ -46,6 +55,13 @@ export function ResourceCreate({ resource, onSuccess, onCancel }: ResourceCreate
46
55
  .filter(f => f.name !== (resourceConfig.primaryKey ?? 'id'));
47
56
  }, [resourceConfig]);
48
57
 
58
+ const editors = useEditors();
59
+ const columns = useMemo(() => {
60
+ const layout = resourceConfig ? screenLayout(resourceConfig, 'create') : undefined;
61
+ return layout ? resolveColumns(fields, layout, editors) : null;
62
+ }, [resourceConfig, fields, editors]);
63
+ const { scrollRef, onContainerLayout, onFieldOffset, requestScrollToFirstError } = useScrollToFirstError(errors);
64
+
49
65
  const setFieldValue = useCallback((name: string, value: unknown) => {
50
66
  setFormData(prev => ({ ...prev, [name]: value }));
51
67
  setErrors(prev => {
@@ -55,21 +71,27 @@ export function ResourceCreate({ resource, onSuccess, onCancel }: ResourceCreate
55
71
  });
56
72
  }, []);
57
73
 
58
- const validate = useCallback((): boolean => {
74
+ const validate = useCallback((): Record<string, string> => {
59
75
  const newErrors: Record<string, string> = {};
60
76
  for (const field of fields) {
61
77
  const value = formData[field.name];
62
- if (field.validation?.required && (value == null || value === '')) {
78
+ if (field.validation?.required && missingRequired(field, value)) {
63
79
  newErrors[field.name] = field.validation.message ?? `${field.label ?? field.name} is required`;
64
80
  }
65
81
  }
66
82
  setErrors(newErrors);
67
- return Object.keys(newErrors).length === 0;
83
+ return newErrors;
68
84
  }, [fields, formData]);
69
85
 
70
86
  const handleSubmit = useCallback(async () => {
71
- if (!validate()) return;
87
+ if (inFlight.current) return;
88
+ const invalid = validate();
89
+ if (Object.keys(invalid).length > 0) {
90
+ requestScrollToFirstError(fields);
91
+ return;
92
+ }
72
93
 
94
+ inFlight.current = true;
73
95
  setSubmitting(true);
74
96
  setSubmitError(null);
75
97
 
@@ -79,49 +101,59 @@ export function ResourceCreate({ resource, onSuccess, onCancel }: ResourceCreate
79
101
  } catch (err) {
80
102
  setSubmitError(err instanceof Error ? err.message : String(err));
81
103
  } finally {
104
+ inFlight.current = false;
82
105
  setSubmitting(false);
83
106
  }
84
- }, [adapter, resource, formData, validate, onSuccess]);
107
+ }, [adapter, resource, formData, validate, onSuccess, fields, requestScrollToFirstError]);
85
108
 
86
109
  if (!resourceConfig) {
87
110
  return <ErrorState message={`Resource "${resource}" not found in admin configuration`} />;
88
111
  }
89
112
 
90
113
  const label = resourceConfig.label ?? resourceConfig.name;
114
+ const renderInput = (field: FieldDefinition): React.ReactNode => (
115
+ <FieldInput
116
+ field={field}
117
+ value={formData[field.name]}
118
+ error={errors[field.name]}
119
+ onChange={value => setFieldValue(field.name, value)}
120
+ values={formData}
121
+ />
122
+ );
91
123
 
92
124
  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} />
125
+ <View className="flex-1">
126
+ <ScrollView ref={scrollRef} className="flex-1">
127
+ {/* Header */}
128
+ <View className="flex-row items-center justify-between px-4 py-4">
129
+ <Text className="text-xl font-bold text-gray-900">Create {label}</Text>
130
+ <View className="flex-row gap-2">
131
+ {onCancel && (
132
+ <Button label="Cancel" variant="ghost" onPress={onCancel} />
133
+ )}
134
+ <Button label="Create" onPress={handleSubmit} loading={submitting} />
135
+ </View>
102
136
  </View>
103
- </View>
104
137
 
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>
138
+ {/* Form fields */}
139
+ <View className="px-4" testID="form-fields" onLayout={onContainerLayout}>
140
+ {columns ? (
141
+ <FieldColumns columns={columns} renderField={renderInput} onFieldOffset={onFieldOffset} />
142
+ ) : (
143
+ fields.map(field => (
144
+ <View
145
+ key={field.name}
146
+ testID={`form-fields-item-${field.name}`}
147
+ onLayout={event => onFieldOffset(field.name, event.nativeEvent.layout.y)}
148
+ >
149
+ {renderInput(field)}
150
+ </View>
151
+ ))
152
+ )}
109
153
  </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>
154
+ </ScrollView>
155
+ <FormActionBar onSubmit={handleSubmit} onCancel={onCancel} submitting={submitting} submitLabel="Create" error={submitError} />
156
+ </View>
125
157
  );
126
158
  }
127
159
 
@@ -134,14 +166,18 @@ interface FieldInputProps {
134
166
  value: unknown;
135
167
  error?: string;
136
168
  onChange: (value: unknown) => void;
169
+ /** The whole form's current values, for a custom input. */
170
+ values: Record<string, unknown>;
137
171
  }
138
172
 
139
- function FieldInput({ field, value, error, onChange }: FieldInputProps): React.ReactElement {
173
+ function FieldInput({ field, value, error, onChange, values }: FieldInputProps): React.ReactElement {
140
174
  const editors = useEditors();
175
+ const adapter = useDataAdapter();
141
176
 
142
177
  // Resolution: inline input → registered editor → type-based default.
143
178
  if (field.input) {
144
- return <>{field.input({ value, onChange, field, error })}</>;
179
+ // Its own component, so hooks inside a custom input keep their own order.
180
+ return React.createElement(field.input, { value, onChange, field, error, values });
145
181
  }
146
182
  const Editor = editorFor(field, editors);
147
183
  if (Editor) {
@@ -256,6 +292,20 @@ function FieldInput({ field, value, error, onChange }: FieldInputProps): React.R
256
292
  </FormField>
257
293
  );
258
294
 
295
+ case 'tags':
296
+ return <TagsFieldInput field={field} value={value} error={error} onChange={onChange} />;
297
+
298
+ case 'relation':
299
+ // Without `relation` there is nothing to search: the plain text input below.
300
+ if (field.relation) {
301
+ return (
302
+ <FormField label={label} error={error} required={required}>
303
+ <RelationFieldInput field={field} value={value} onChange={onChange} adapter={adapter} />
304
+ </FormField>
305
+ );
306
+ }
307
+ // falls through
308
+
259
309
  default:
260
310
  return (
261
311
  <FormField label={label} error={error} required={required}>
@@ -5,16 +5,23 @@
5
5
  * Pre-populates form with existing data, submits via adapter.update().
6
6
  */
7
7
 
8
- import React, { useState, useEffect, useCallback, useMemo } from 'react';
8
+ import React, { useState, useEffect, 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, LoadingState, 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 { RelationFieldInput } from '../components/RelationFieldInput';
21
+ import { FormActionBar } from '../components/FormActionBar';
22
+ import { FieldColumns } from '../components/FieldColumns';
23
+ import { useScrollToFirstError } from '../hooks/useScrollToFirstError';
24
+ import { resolveColumns, screenLayout } from '../utils/resolve-columns';
18
25
  import type { FieldDefinition, DataRecord } from '../types';
19
26
 
20
27
  export interface ResourceEditProps {
@@ -40,6 +47,8 @@ export function ResourceEdit({ resource, id, onSuccess, onCancel }: ResourceEdit
40
47
  const [submitting, setSubmitting] = useState(false);
41
48
  const [fetchError, setFetchError] = useState<Error | null>(null);
42
49
  const [submitError, setSubmitError] = useState<string | null>(null);
50
+ // Set synchronously so two presses in one tick (before a re-render) submit once.
51
+ const inFlight = useRef(false);
43
52
 
44
53
  const fields = useMemo(() => {
45
54
  if (!resourceConfig) return [];
@@ -51,12 +60,20 @@ export function ResourceEdit({ resource, id, onSuccess, onCancel }: ResourceEdit
51
60
  .filter(f => f.name !== (resourceConfig.primaryKey ?? 'id'));
52
61
  }, [resourceConfig]);
53
62
 
63
+ const editors = useEditors();
64
+ const columns = useMemo(() => {
65
+ const layout = resourceConfig ? screenLayout(resourceConfig, 'edit') : undefined;
66
+ return layout ? resolveColumns(fields, layout, editors) : null;
67
+ }, [resourceConfig, fields, editors]);
68
+ const { scrollRef, onContainerLayout, onFieldOffset, requestScrollToFirstError } = useScrollToFirstError(errors);
69
+
54
70
  // Fetch existing record
55
71
  const fetchRecord = useCallback(async () => {
56
72
  setLoading(true);
57
73
  setFetchError(null);
58
74
  try {
59
- const result = await adapter.fetchOne(resource, id);
75
+ const select = resourceConfig?.select;
76
+ const result = select ? await adapter.fetchOne(resource, id, { select }) : await adapter.fetchOne(resource, id);
60
77
  setRecord(result);
61
78
  // Pre-populate form data with existing values
62
79
  const initial: Record<string, unknown> = {};
@@ -69,7 +86,7 @@ export function ResourceEdit({ resource, id, onSuccess, onCancel }: ResourceEdit
69
86
  } finally {
70
87
  setLoading(false);
71
88
  }
72
- }, [adapter, resource, id, fields]);
89
+ }, [adapter, resource, id, fields, resourceConfig?.select]);
73
90
 
74
91
  useEffect(() => {
75
92
  fetchRecord();
@@ -84,21 +101,27 @@ export function ResourceEdit({ resource, id, onSuccess, onCancel }: ResourceEdit
84
101
  });
85
102
  }, []);
86
103
 
87
- const validate = useCallback((): boolean => {
104
+ const validate = useCallback((): Record<string, string> => {
88
105
  const newErrors: Record<string, string> = {};
89
106
  for (const field of fields) {
90
107
  const value = formData[field.name];
91
- if (field.validation?.required && (value == null || value === '')) {
108
+ if (field.validation?.required && missingRequired(field, value)) {
92
109
  newErrors[field.name] = field.validation.message ?? `${field.label ?? field.name} is required`;
93
110
  }
94
111
  }
95
112
  setErrors(newErrors);
96
- return Object.keys(newErrors).length === 0;
113
+ return newErrors;
97
114
  }, [fields, formData]);
98
115
 
99
116
  const handleSubmit = useCallback(async () => {
100
- if (!validate()) return;
117
+ if (inFlight.current) return;
118
+ const invalid = validate();
119
+ if (Object.keys(invalid).length > 0) {
120
+ requestScrollToFirstError(fields);
121
+ return;
122
+ }
101
123
 
124
+ inFlight.current = true;
102
125
  setSubmitting(true);
103
126
  setSubmitError(null);
104
127
 
@@ -108,9 +131,10 @@ export function ResourceEdit({ resource, id, onSuccess, onCancel }: ResourceEdit
108
131
  } catch (err) {
109
132
  setSubmitError(err instanceof Error ? err.message : String(err));
110
133
  } finally {
134
+ inFlight.current = false;
111
135
  setSubmitting(false);
112
136
  }
113
- }, [adapter, resource, id, formData, validate, onSuccess]);
137
+ }, [adapter, resource, id, formData, validate, onSuccess, fields, requestScrollToFirstError]);
114
138
 
115
139
  if (!resourceConfig) {
116
140
  return <ErrorState message={`Resource "${resource}" not found in admin configuration`} />;
@@ -129,43 +153,52 @@ export function ResourceEdit({ resource, id, onSuccess, onCancel }: ResourceEdit
129
153
  }
130
154
 
131
155
  const label = resourceConfig.label ?? resourceConfig.name;
156
+ const renderInput = (field: FieldDefinition): React.ReactNode => (
157
+ <EditFieldInput
158
+ field={field}
159
+ value={formData[field.name]}
160
+ error={errors[field.name]}
161
+ onChange={value => setFieldValue(field.name, value)}
162
+ values={formData}
163
+ record={record}
164
+ />
165
+ );
132
166
 
133
167
  return (
134
- <ScrollView className="flex-1">
135
- {/* Header */}
136
- <View className="flex-row items-center justify-between px-4 py-4">
137
- <Text className="text-xl font-bold text-gray-900">
138
- Edit {label} #{String(id)}
139
- </Text>
140
- <View className="flex-row gap-2">
141
- {onCancel && (
142
- <Button label="Cancel" variant="ghost" onPress={onCancel} />
143
- )}
144
- <Button label="Save" onPress={handleSubmit} loading={submitting} />
168
+ <View className="flex-1">
169
+ <ScrollView ref={scrollRef} className="flex-1">
170
+ {/* Header */}
171
+ <View className="flex-row items-center justify-between px-4 py-4">
172
+ <Text className="text-xl font-bold text-gray-900">
173
+ Edit {label} #{String(id)}
174
+ </Text>
175
+ <View className="flex-row gap-2">
176
+ {onCancel && (
177
+ <Button label="Cancel" variant="ghost" onPress={onCancel} />
178
+ )}
179
+ <Button label="Save" onPress={handleSubmit} loading={submitting} />
180
+ </View>
145
181
  </View>
146
- </View>
147
182
 
148
- {/* Error */}
149
- {submitError && (
150
- <View className="mx-4 mb-4 bg-red-50 border border-red-200 rounded-lg px-4 py-3">
151
- <Text className="text-sm text-red-700">{submitError}</Text>
183
+ {/* Form fields */}
184
+ <View className="px-4" testID="form-fields" onLayout={onContainerLayout}>
185
+ {columns ? (
186
+ <FieldColumns columns={columns} renderField={renderInput} onFieldOffset={onFieldOffset} />
187
+ ) : (
188
+ fields.map(field => (
189
+ <View
190
+ key={field.name}
191
+ testID={`form-fields-item-${field.name}`}
192
+ onLayout={event => onFieldOffset(field.name, event.nativeEvent.layout.y)}
193
+ >
194
+ {renderInput(field)}
195
+ </View>
196
+ ))
197
+ )}
152
198
  </View>
153
- )}
154
-
155
- {/* Form fields */}
156
- <View className="px-4">
157
- {fields.map(field => (
158
- <EditFieldInput
159
- key={field.name}
160
- field={field}
161
- value={formData[field.name]}
162
- error={errors[field.name]}
163
- onChange={value => setFieldValue(field.name, value)}
164
- record={record}
165
- />
166
- ))}
167
- </View>
168
- </ScrollView>
199
+ </ScrollView>
200
+ <FormActionBar onSubmit={handleSubmit} onCancel={onCancel} submitting={submitting} error={submitError} />
201
+ </View>
169
202
  );
170
203
  }
171
204
 
@@ -179,14 +212,18 @@ interface EditFieldInputProps {
179
212
  error?: string;
180
213
  onChange: (value: unknown) => void;
181
214
  record?: DataRecord;
215
+ /** The whole form's current values, for a custom input. */
216
+ values: Record<string, unknown>;
182
217
  }
183
218
 
184
- function EditFieldInput({ field, value, error, onChange, record }: EditFieldInputProps): React.ReactElement {
219
+ function EditFieldInput({ field, value, error, onChange, record, values }: EditFieldInputProps): React.ReactElement {
185
220
  const editors = useEditors();
221
+ const adapter = useDataAdapter();
186
222
 
187
223
  // Resolution: inline input → registered editor → type-based default.
188
224
  if (field.input) {
189
- return <>{field.input({ value, onChange, field, record, error })}</>;
225
+ // Its own component, so hooks inside a custom input keep their own order.
226
+ return React.createElement(field.input, { value, onChange, field, record, error, values });
190
227
  }
191
228
  const Editor = editorFor(field, editors);
192
229
  if (Editor) {
@@ -298,6 +335,20 @@ function EditFieldInput({ field, value, error, onChange, record }: EditFieldInpu
298
335
  </FormField>
299
336
  );
300
337
 
338
+ case 'tags':
339
+ return <TagsFieldInput field={field} value={value} error={error} onChange={onChange} />;
340
+
341
+ case 'relation':
342
+ // Without `relation` there is nothing to search: the plain text input below.
343
+ if (field.relation) {
344
+ return (
345
+ <FormField label={label} error={error} required={required}>
346
+ <RelationFieldInput field={field} value={value} onChange={onChange} adapter={adapter} />
347
+ </FormField>
348
+ );
349
+ }
350
+ // falls through
351
+
301
352
  default:
302
353
  return (
303
354
  <FormField label={label} error={error} required={required}>