@everystack/admin 0.4.2 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +96 -1
- package/package.json +46 -12
- package/src/adapters/EverystackAdapter.ts +127 -12
- package/src/adapters/types.ts +22 -5
- package/src/components/AdminLink.tsx +59 -0
- package/src/components/CellText.tsx +18 -0
- package/src/components/DeletePreview.tsx +109 -0
- package/src/components/FieldColumns.tsx +86 -0
- package/src/components/FormActionBar.tsx +45 -0
- package/src/components/InlineEnumCell.tsx +98 -0
- package/src/components/RelationFieldInput.tsx +159 -0
- package/src/components/RelationValue.tsx +26 -0
- package/src/components/RowActionButton.tsx +24 -9
- package/src/components/TagsDisplay.tsx +22 -0
- package/src/components/TagsFieldInput.tsx +95 -0
- package/src/editors/rich-html-extensions.ts +63 -0
- package/src/editors/tiptap.tsx +121 -20
- package/src/editors/unsupported-markup.ts +150 -0
- package/src/hooks/useAdminData.ts +108 -24
- package/src/hooks/useAdminNavigation.ts +19 -0
- package/src/hooks/useListQuery.ts +47 -0
- package/src/hooks/useRelationLabels.ts +131 -0
- package/src/hooks/useScopeCounts.ts +77 -0
- package/src/hooks/useScrollToFirstError.ts +74 -0
- package/src/index.ts +17 -0
- package/src/screens/AdminRouter.tsx +30 -4
- package/src/screens/ResourceCreate.tsx +87 -37
- package/src/screens/ResourceEdit.tsx +93 -42
- package/src/screens/ResourceList.tsx +241 -53
- package/src/screens/ResourceShow.tsx +130 -22
- package/src/types.ts +197 -4
- package/src/utils/format-cell-value.ts +5 -0
- package/src/utils/list-query.ts +202 -0
- package/src/utils/required.ts +10 -0
- package/src/utils/resolve-columns.ts +107 -0
- package/src/utils/row-actions.ts +28 -0
- package/src/utils/scope-counts.ts +65 -0
- package/src/utils/scopes.ts +38 -0
- package/src/utils/standard-actions.ts +41 -5
- package/src/utils/tags.ts +40 -0
- package/src/utils/view-filters.ts +4 -2
|
@@ -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
|
-
<
|
|
74
|
-
{
|
|
75
|
-
|
|
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(():
|
|
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 && (
|
|
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
|
|
83
|
+
return newErrors;
|
|
68
84
|
}, [fields, formData]);
|
|
69
85
|
|
|
70
86
|
const handleSubmit = useCallback(async () => {
|
|
71
|
-
if (
|
|
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
|
-
<
|
|
94
|
-
{
|
|
95
|
-
|
|
96
|
-
<
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(():
|
|
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 && (
|
|
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
|
|
113
|
+
return newErrors;
|
|
97
114
|
}, [fields, formData]);
|
|
98
115
|
|
|
99
116
|
const handleSubmit = useCallback(async () => {
|
|
100
|
-
if (
|
|
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
|
-
<
|
|
135
|
-
{
|
|
136
|
-
|
|
137
|
-
<
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}>
|