@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.
- package/CHANGELOG.md +73 -1
- package/package.json +45 -11
- package/src/adapters/EverystackAdapter.ts +98 -4
- package/src/adapters/types.ts +7 -1
- package/src/components/CellText.tsx +18 -0
- package/src/components/FieldColumns.tsx +86 -0
- package/src/components/FormActionBar.tsx +45 -0
- package/src/components/InlineEnumCell.tsx +98 -0
- package/src/components/RowActionButton.tsx +24 -9
- package/src/components/TagsDisplay.tsx +22 -0
- package/src/components/TagsFieldInput.tsx +95 -0
- package/src/editors/rich-html-extensions.ts +63 -0
- package/src/editors/tiptap.tsx +121 -20
- package/src/editors/unsupported-markup.ts +150 -0
- package/src/hooks/useAdminData.ts +104 -24
- package/src/hooks/useListQuery.ts +45 -0
- package/src/hooks/useScopeCounts.ts +72 -0
- package/src/hooks/useScrollToFirstError.ts +74 -0
- package/src/index.ts +7 -0
- package/src/screens/AdminRouter.tsx +11 -0
- package/src/screens/ResourceCreate.tsx +74 -37
- package/src/screens/ResourceEdit.tsx +77 -40
- package/src/screens/ResourceList.tsx +204 -52
- package/src/screens/ResourceShow.tsx +57 -9
- package/src/types.ts +140 -4
- package/src/utils/format-cell-value.ts +5 -0
- package/src/utils/list-query.ts +199 -0
- package/src/utils/required.ts +10 -0
- package/src/utils/resolve-columns.ts +107 -0
- package/src/utils/row-actions.ts +28 -0
- package/src/utils/scope-counts.ts +63 -0
- package/src/utils/scopes.ts +38 -0
- package/src/utils/tags.ts +40 -0
- package/src/utils/view-filters.ts +1 -1
|
@@ -5,16 +5,22 @@
|
|
|
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 { 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, DataRecord } from '../types';
|
|
19
25
|
|
|
20
26
|
export interface ResourceEditProps {
|
|
@@ -40,6 +46,8 @@ export function ResourceEdit({ resource, id, onSuccess, onCancel }: ResourceEdit
|
|
|
40
46
|
const [submitting, setSubmitting] = useState(false);
|
|
41
47
|
const [fetchError, setFetchError] = useState<Error | null>(null);
|
|
42
48
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
49
|
+
// Set synchronously so two presses in one tick (before a re-render) submit once.
|
|
50
|
+
const inFlight = useRef(false);
|
|
43
51
|
|
|
44
52
|
const fields = useMemo(() => {
|
|
45
53
|
if (!resourceConfig) return [];
|
|
@@ -51,6 +59,13 @@ export function ResourceEdit({ resource, id, onSuccess, onCancel }: ResourceEdit
|
|
|
51
59
|
.filter(f => f.name !== (resourceConfig.primaryKey ?? 'id'));
|
|
52
60
|
}, [resourceConfig]);
|
|
53
61
|
|
|
62
|
+
const editors = useEditors();
|
|
63
|
+
const columns = useMemo(() => {
|
|
64
|
+
const layout = resourceConfig ? screenLayout(resourceConfig, 'edit') : undefined;
|
|
65
|
+
return layout ? resolveColumns(fields, layout, editors) : null;
|
|
66
|
+
}, [resourceConfig, fields, editors]);
|
|
67
|
+
const { scrollRef, onContainerLayout, onFieldOffset, requestScrollToFirstError } = useScrollToFirstError(errors);
|
|
68
|
+
|
|
54
69
|
// Fetch existing record
|
|
55
70
|
const fetchRecord = useCallback(async () => {
|
|
56
71
|
setLoading(true);
|
|
@@ -84,21 +99,27 @@ export function ResourceEdit({ resource, id, onSuccess, onCancel }: ResourceEdit
|
|
|
84
99
|
});
|
|
85
100
|
}, []);
|
|
86
101
|
|
|
87
|
-
const validate = useCallback(():
|
|
102
|
+
const validate = useCallback((): Record<string, string> => {
|
|
88
103
|
const newErrors: Record<string, string> = {};
|
|
89
104
|
for (const field of fields) {
|
|
90
105
|
const value = formData[field.name];
|
|
91
|
-
if (field.validation?.required && (
|
|
106
|
+
if (field.validation?.required && missingRequired(field, value)) {
|
|
92
107
|
newErrors[field.name] = field.validation.message ?? `${field.label ?? field.name} is required`;
|
|
93
108
|
}
|
|
94
109
|
}
|
|
95
110
|
setErrors(newErrors);
|
|
96
|
-
return
|
|
111
|
+
return newErrors;
|
|
97
112
|
}, [fields, formData]);
|
|
98
113
|
|
|
99
114
|
const handleSubmit = useCallback(async () => {
|
|
100
|
-
if (
|
|
115
|
+
if (inFlight.current) return;
|
|
116
|
+
const invalid = validate();
|
|
117
|
+
if (Object.keys(invalid).length > 0) {
|
|
118
|
+
requestScrollToFirstError(fields);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
101
121
|
|
|
122
|
+
inFlight.current = true;
|
|
102
123
|
setSubmitting(true);
|
|
103
124
|
setSubmitError(null);
|
|
104
125
|
|
|
@@ -108,9 +129,10 @@ export function ResourceEdit({ resource, id, onSuccess, onCancel }: ResourceEdit
|
|
|
108
129
|
} catch (err) {
|
|
109
130
|
setSubmitError(err instanceof Error ? err.message : String(err));
|
|
110
131
|
} finally {
|
|
132
|
+
inFlight.current = false;
|
|
111
133
|
setSubmitting(false);
|
|
112
134
|
}
|
|
113
|
-
}, [adapter, resource, id, formData, validate, onSuccess]);
|
|
135
|
+
}, [adapter, resource, id, formData, validate, onSuccess, fields, requestScrollToFirstError]);
|
|
114
136
|
|
|
115
137
|
if (!resourceConfig) {
|
|
116
138
|
return <ErrorState message={`Resource "${resource}" not found in admin configuration`} />;
|
|
@@ -129,43 +151,52 @@ export function ResourceEdit({ resource, id, onSuccess, onCancel }: ResourceEdit
|
|
|
129
151
|
}
|
|
130
152
|
|
|
131
153
|
const label = resourceConfig.label ?? resourceConfig.name;
|
|
154
|
+
const renderInput = (field: FieldDefinition): React.ReactNode => (
|
|
155
|
+
<EditFieldInput
|
|
156
|
+
field={field}
|
|
157
|
+
value={formData[field.name]}
|
|
158
|
+
error={errors[field.name]}
|
|
159
|
+
onChange={value => setFieldValue(field.name, value)}
|
|
160
|
+
values={formData}
|
|
161
|
+
record={record}
|
|
162
|
+
/>
|
|
163
|
+
);
|
|
132
164
|
|
|
133
165
|
return (
|
|
134
|
-
<
|
|
135
|
-
{
|
|
136
|
-
|
|
137
|
-
<
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
166
|
+
<View className="flex-1">
|
|
167
|
+
<ScrollView ref={scrollRef} className="flex-1">
|
|
168
|
+
{/* Header */}
|
|
169
|
+
<View className="flex-row items-center justify-between px-4 py-4">
|
|
170
|
+
<Text className="text-xl font-bold text-gray-900">
|
|
171
|
+
Edit {label} #{String(id)}
|
|
172
|
+
</Text>
|
|
173
|
+
<View className="flex-row gap-2">
|
|
174
|
+
{onCancel && (
|
|
175
|
+
<Button label="Cancel" variant="ghost" onPress={onCancel} />
|
|
176
|
+
)}
|
|
177
|
+
<Button label="Save" onPress={handleSubmit} loading={submitting} />
|
|
178
|
+
</View>
|
|
145
179
|
</View>
|
|
146
|
-
</View>
|
|
147
180
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
181
|
+
{/* Form fields */}
|
|
182
|
+
<View className="px-4" testID="form-fields" onLayout={onContainerLayout}>
|
|
183
|
+
{columns ? (
|
|
184
|
+
<FieldColumns columns={columns} renderField={renderInput} onFieldOffset={onFieldOffset} />
|
|
185
|
+
) : (
|
|
186
|
+
fields.map(field => (
|
|
187
|
+
<View
|
|
188
|
+
key={field.name}
|
|
189
|
+
testID={`form-fields-item-${field.name}`}
|
|
190
|
+
onLayout={event => onFieldOffset(field.name, event.nativeEvent.layout.y)}
|
|
191
|
+
>
|
|
192
|
+
{renderInput(field)}
|
|
193
|
+
</View>
|
|
194
|
+
))
|
|
195
|
+
)}
|
|
152
196
|
</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>
|
|
197
|
+
</ScrollView>
|
|
198
|
+
<FormActionBar onSubmit={handleSubmit} onCancel={onCancel} submitting={submitting} error={submitError} />
|
|
199
|
+
</View>
|
|
169
200
|
);
|
|
170
201
|
}
|
|
171
202
|
|
|
@@ -179,14 +210,17 @@ interface EditFieldInputProps {
|
|
|
179
210
|
error?: string;
|
|
180
211
|
onChange: (value: unknown) => void;
|
|
181
212
|
record?: DataRecord;
|
|
213
|
+
/** The whole form's current values, for a custom input. */
|
|
214
|
+
values: Record<string, unknown>;
|
|
182
215
|
}
|
|
183
216
|
|
|
184
|
-
function EditFieldInput({ field, value, error, onChange, record }: EditFieldInputProps): React.ReactElement {
|
|
217
|
+
function EditFieldInput({ field, value, error, onChange, record, values }: EditFieldInputProps): React.ReactElement {
|
|
185
218
|
const editors = useEditors();
|
|
186
219
|
|
|
187
220
|
// Resolution: inline input → registered editor → type-based default.
|
|
188
221
|
if (field.input) {
|
|
189
|
-
|
|
222
|
+
// Its own component, so hooks inside a custom input keep their own order.
|
|
223
|
+
return React.createElement(field.input, { value, onChange, field, record, error, values });
|
|
190
224
|
}
|
|
191
225
|
const Editor = editorFor(field, editors);
|
|
192
226
|
if (Editor) {
|
|
@@ -298,6 +332,9 @@ function EditFieldInput({ field, value, error, onChange, record }: EditFieldInpu
|
|
|
298
332
|
</FormField>
|
|
299
333
|
);
|
|
300
334
|
|
|
335
|
+
case 'tags':
|
|
336
|
+
return <TagsFieldInput field={field} value={value} error={error} onChange={onChange} />;
|
|
337
|
+
|
|
301
338
|
default:
|
|
302
339
|
return (
|
|
303
340
|
<FormField label={label} error={error} required={required}>
|
|
@@ -3,19 +3,37 @@
|
|
|
3
3
|
*
|
|
4
4
|
* List view for a resource with sortable table, search/filter, and pagination.
|
|
5
5
|
* Wires useAdminData() to DataTable, FilterBar, and PaginationBar components.
|
|
6
|
+
*
|
|
7
|
+
* The list's search, sort, page and page size live in the URL (useListQuery),
|
|
8
|
+
* so a list view is a link. The search box waits for a pause in typing before writing it.
|
|
6
9
|
*/
|
|
7
10
|
|
|
8
|
-
|
|
11
|
+
/** How long the search box waits after the last keystroke before it searches. */
|
|
12
|
+
const SEARCH_DEBOUNCE_MS = 300;
|
|
13
|
+
|
|
14
|
+
/** Rows per page when the resource names no `perPage`. */
|
|
15
|
+
const DEFAULT_PER_PAGE = 20;
|
|
16
|
+
/** Sizes the page bar always offers; the resource's own size is added when missing. */
|
|
17
|
+
const PAGE_SIZES = [20, 50, 100];
|
|
18
|
+
|
|
19
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
9
20
|
import { View, Text } from 'react-native';
|
|
10
21
|
import { useAdminConfig } from '../hooks/useAdminConfig';
|
|
11
22
|
import { useAdminData, useDataAdapter } from '../hooks/useAdminData';
|
|
12
|
-
import {
|
|
23
|
+
import { useListQuery } from '../hooks/useListQuery';
|
|
24
|
+
import { buildListParams, mergeFilters, nextSort, normalizeSearchText, pageForSize, parseListQuery, resolvePageSize } from '../utils/list-query';
|
|
25
|
+
import { resolveScope } from '../utils/scopes';
|
|
26
|
+
import { useScopeCounts } from '../hooks/useScopeCounts';
|
|
27
|
+
import { RESERVED_PARAMS } from '../utils/view-filters';
|
|
28
|
+
import { DataTable, FilterBar, PaginationBar, ScopeTabs, LoadingState, ErrorState, EmptyState, Button, type DataTableColumn } from '@everystack/ui';
|
|
13
29
|
import { formatCellValue } from '../utils/format-cell-value';
|
|
14
30
|
import { cellRenderKind } from '../utils/render-resolution';
|
|
15
|
-
import { visibleRowActions } from '../utils/row-actions';
|
|
31
|
+
import { estimateActionsWidth, visibleRowActions } from '../utils/row-actions';
|
|
16
32
|
import { standardRowActions } from '../utils/standard-actions';
|
|
17
33
|
import { ImageDisplay } from '../components/ImageDisplay';
|
|
18
34
|
import { RowActionButton } from '../components/RowActionButton';
|
|
35
|
+
import { InlineEnumCell } from '../components/InlineEnumCell';
|
|
36
|
+
import { wrapBareText } from '../components/CellText';
|
|
19
37
|
import type { DataAdapter } from '../adapters/types';
|
|
20
38
|
import type { ResourceConfig, FieldDefinition, DataRecord, RowActionConfig } from '../types';
|
|
21
39
|
|
|
@@ -41,28 +59,115 @@ export function ResourceList({ resource, onRowPress, onEditPress, onCreatePress,
|
|
|
41
59
|
const resourceConfig = config.resources.find(r => r.name === resource);
|
|
42
60
|
|
|
43
61
|
const defaultSort = resourceConfig?.defaultSort;
|
|
62
|
+
const defaultPerPage = resolvePageSize(resourceConfig?.perPage, DEFAULT_PER_PAGE);
|
|
63
|
+
const pageSizes = useMemo(
|
|
64
|
+
() => [...new Set([...PAGE_SIZES, defaultPerPage])].sort((a, b) => a - b),
|
|
65
|
+
[defaultPerPage],
|
|
66
|
+
);
|
|
67
|
+
const { query, update } = useListQuery({ defaultPerPage });
|
|
68
|
+
|
|
69
|
+
const searchFields = useMemo(
|
|
70
|
+
() => resourceConfig?.fields.filter(f => f.searchable).map(f => f.name) ?? [],
|
|
71
|
+
[resourceConfig],
|
|
72
|
+
);
|
|
73
|
+
const sortableFields = useMemo(
|
|
74
|
+
() => new Set(resourceConfig?.fields.filter(f => f.sortable).map(f => f.name) ?? []),
|
|
75
|
+
[resourceConfig],
|
|
76
|
+
);
|
|
77
|
+
const activeScope = useMemo(
|
|
78
|
+
() => resolveScope(resourceConfig?.scopes, query.scope),
|
|
79
|
+
[resourceConfig, query.scope],
|
|
80
|
+
);
|
|
81
|
+
const listParams = useMemo(
|
|
82
|
+
() => buildListParams(query, { perPage: defaultPerPage, baseFilter, scopeFilter: activeScope?.filter, sortableFields, defaultSort }),
|
|
83
|
+
[query, defaultPerPage, baseFilter, activeScope, sortableFields, defaultSort],
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const searchParams = adapter.supportsSearch === true && searchFields.length > 0
|
|
87
|
+
? { search: listParams.search, searchFields }
|
|
88
|
+
: {};
|
|
89
|
+
const legacySearchFilter = useMemo(() => {
|
|
90
|
+
const text = normalizeSearchText(query.search);
|
|
91
|
+
return adapter.supportsSearch !== true && text && searchFields[0]
|
|
92
|
+
? { [searchFields[0]]: `ilike.*${text}*` }
|
|
93
|
+
: undefined;
|
|
94
|
+
}, [adapter.supportsSearch, query.search, searchFields]);
|
|
95
|
+
|
|
96
|
+
// Bumped after an inline edit, so the tab counts follow the change.
|
|
97
|
+
const [countsToken, setCountsToken] = useState(0);
|
|
98
|
+
const scopeCounts = useScopeCounts({
|
|
99
|
+
resource,
|
|
100
|
+
scopes: resourceConfig?.scopes,
|
|
101
|
+
enabled: Boolean(resourceConfig?.scopeCounts),
|
|
102
|
+
baseFilter: mergeFilters(baseFilter, legacySearchFilter),
|
|
103
|
+
...searchParams,
|
|
104
|
+
refreshToken: countsToken,
|
|
105
|
+
});
|
|
106
|
+
|
|
44
107
|
const {
|
|
45
108
|
data,
|
|
46
109
|
total,
|
|
47
110
|
loading,
|
|
111
|
+
refreshing,
|
|
48
112
|
error,
|
|
49
113
|
refetch,
|
|
50
114
|
page,
|
|
51
115
|
perPage,
|
|
52
|
-
setPage,
|
|
53
|
-
setSort,
|
|
54
|
-
setFilter,
|
|
55
116
|
} = useAdminData(resource, {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
117
|
+
controlled: true,
|
|
118
|
+
page: listParams.page,
|
|
119
|
+
perPage: listParams.perPage,
|
|
120
|
+
sort: listParams.sort,
|
|
121
|
+
order: listParams.order,
|
|
122
|
+
...searchParams,
|
|
123
|
+
baseFilter: mergeFilters(listParams.filter, legacySearchFilter),
|
|
59
124
|
});
|
|
60
125
|
|
|
126
|
+
// A column named like a list URL param cannot be filtered from the URL: say so while developing.
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
if (process.env.NODE_ENV === 'production') return;
|
|
129
|
+
const clashes = (resourceConfig?.fields ?? []).map(f => f.name).filter(name => RESERVED_PARAMS.has(name));
|
|
130
|
+
if (clashes.length === 0) return;
|
|
131
|
+
// eslint-disable-next-line no-console
|
|
132
|
+
console.warn(
|
|
133
|
+
`[admin] resource "${resource}" has ${clashes.map(c => `"${c}"`).join(', ')}, a name the list reserves for its URL ` +
|
|
134
|
+
`(reserved: ${[...RESERVED_PARAMS].join(', ')}). That column cannot be filtered with ?${clashes[0]}=... in the URL.`,
|
|
135
|
+
);
|
|
136
|
+
}, [resourceConfig, resource]);
|
|
137
|
+
|
|
138
|
+
// The box shows what the user typed; the URL gets it after a pause. When the
|
|
139
|
+
// URL changes by itself (back button, a link), the box follows.
|
|
140
|
+
const [searchText, setSearchText] = useState(query.search ?? '');
|
|
141
|
+
const lastWritten = useRef(query.search);
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
if (query.search === lastWritten.current) return;
|
|
144
|
+
lastWritten.current = query.search;
|
|
145
|
+
setSearchText(query.search ?? '');
|
|
146
|
+
}, [query.search]);
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
const wanted = parseListQuery({ q: searchText }).search;
|
|
149
|
+
// Against what we last wrote, not the URL's current value: the URL may not have caught up.
|
|
150
|
+
if (wanted === lastWritten.current) return;
|
|
151
|
+
const timer = setTimeout(() => {
|
|
152
|
+
lastWritten.current = wanted;
|
|
153
|
+
update({ search: wanted });
|
|
154
|
+
}, SEARCH_DEBOUNCE_MS);
|
|
155
|
+
return () => clearTimeout(timer);
|
|
156
|
+
// `query.search` is read, not watched: re-running on it would restart the pause.
|
|
157
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
158
|
+
}, [searchText]);
|
|
159
|
+
|
|
61
160
|
// Build columns from field definitions, plus an actions column combining the
|
|
62
161
|
// built-in Show/Edit/Delete (gated by actions.*) with any custom rowActions.
|
|
162
|
+
// Returns the refetch, so the cell can re-sync once the new rows are in.
|
|
163
|
+
const afterInlineEdit = useCallback((): Promise<void> => {
|
|
164
|
+
setCountsToken(t => t + 1);
|
|
165
|
+
return refetch();
|
|
166
|
+
}, [refetch]);
|
|
167
|
+
|
|
63
168
|
const columns = useMemo(() => {
|
|
64
169
|
if (!resourceConfig) return [];
|
|
65
|
-
const cols = fieldsToColumns(resourceConfig);
|
|
170
|
+
const cols = fieldsToColumns(resourceConfig, adapter, afterInlineEdit);
|
|
66
171
|
const actions = [
|
|
67
172
|
...standardRowActions(resourceConfig, { onShow: onRowPress, onEdit: onEditPress }),
|
|
68
173
|
...(resourceConfig.rowActions ?? []),
|
|
@@ -71,31 +176,12 @@ export function ResourceList({ resource, onRowPress, onEditPress, onCreatePress,
|
|
|
71
176
|
cols.push(actionsColumn(resourceConfig, actions, adapter, refetch));
|
|
72
177
|
}
|
|
73
178
|
return cols;
|
|
74
|
-
}, [resourceConfig, adapter, refetch, onRowPress, onEditPress]);
|
|
75
|
-
|
|
76
|
-
// Build filter chips from filterable fields
|
|
77
|
-
const filterChips = useMemo(() => {
|
|
78
|
-
if (!resourceConfig) return [];
|
|
79
|
-
return resourceConfig.fields
|
|
80
|
-
.filter(f => f.filterable)
|
|
81
|
-
.map(f => ({ key: f.name, label: f.label ?? f.name }));
|
|
82
|
-
}, [resourceConfig]);
|
|
179
|
+
}, [resourceConfig, adapter, refetch, afterInlineEdit, onRowPress, onEditPress]);
|
|
83
180
|
|
|
181
|
+
// The click cycles from what is shown (the default sort included), not from the URL alone.
|
|
84
182
|
const handleSort = useCallback((key: string) => {
|
|
85
|
-
|
|
86
|
-
}, [
|
|
87
|
-
|
|
88
|
-
const handleSearch = useCallback((query: string) => {
|
|
89
|
-
if (!query) {
|
|
90
|
-
setFilter({});
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
93
|
-
// Search across searchable fields using ilike
|
|
94
|
-
const searchableFields = resourceConfig?.fields.filter(f => f.searchable) ?? [];
|
|
95
|
-
if (searchableFields.length > 0) {
|
|
96
|
-
setFilter({ [searchableFields[0].name]: `ilike.*${query}*` });
|
|
97
|
-
}
|
|
98
|
-
}, [resourceConfig, setFilter]);
|
|
183
|
+
update(nextSort({ sort: listParams.sort, order: listParams.order }, key, defaultSort));
|
|
184
|
+
}, [update, listParams.sort, listParams.order, defaultSort]);
|
|
99
185
|
|
|
100
186
|
if (!resourceConfig) {
|
|
101
187
|
return (
|
|
@@ -117,27 +203,51 @@ export function ResourceList({ resource, onRowPress, onEditPress, onCreatePress,
|
|
|
117
203
|
</View>
|
|
118
204
|
|
|
119
205
|
{/* Filter bar */}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
206
|
+
{searchFields.length > 0 ? (
|
|
207
|
+
<FilterBar
|
|
208
|
+
placeholder={`Search ${label.toLowerCase()}...`}
|
|
209
|
+
onSearch={setSearchText}
|
|
210
|
+
value={searchText}
|
|
211
|
+
/>
|
|
212
|
+
) : null}
|
|
213
|
+
{query.search && searchFields.length > 0 && (!loading || refreshing) && !error ? (
|
|
214
|
+
<Text className="px-4 pb-2 text-sm text-gray-500">
|
|
215
|
+
{`${total} ${total === 1 ? 'result' : 'results'}`}
|
|
216
|
+
</Text>
|
|
217
|
+
) : null}
|
|
218
|
+
|
|
219
|
+
{resourceConfig.scopes && resourceConfig.scopes.length > 0 && activeScope ? (
|
|
220
|
+
<ScopeTabs
|
|
221
|
+
tabs={resourceConfig.scopes.map(scope => ({ id: scope.id, label: scope.label, count: scopeCounts?.[scope.id] }))}
|
|
222
|
+
active={activeScope.id}
|
|
223
|
+
onChange={id => update({ scope: id })}
|
|
224
|
+
/>
|
|
225
|
+
) : null}
|
|
125
226
|
|
|
126
|
-
{/* Content */}
|
|
127
|
-
{loading ? (
|
|
227
|
+
{/* Content. An explicit refetch keeps the table, and its cells, on screen until new rows land. */}
|
|
228
|
+
{loading && !refreshing ? (
|
|
128
229
|
<LoadingState message={`Loading ${label.toLowerCase()}...`} />
|
|
129
230
|
) : error ? (
|
|
130
231
|
<ErrorState message={error.message} onRetry={refetch} />
|
|
131
232
|
) : data.length === 0 ? (
|
|
132
|
-
|
|
133
|
-
title={`No ${
|
|
134
|
-
|
|
135
|
-
|
|
233
|
+
query.search ? (
|
|
234
|
+
<EmptyState title={`No results for \u201c${query.search}\u201d`} message="Try a different search." />
|
|
235
|
+
) : (
|
|
236
|
+
<EmptyState
|
|
237
|
+
title={`No ${label.toLowerCase()}`}
|
|
238
|
+
message={`No ${label.toLowerCase()} found. ${canCreate ? 'Create one to get started.' : ''}`}
|
|
239
|
+
/>
|
|
240
|
+
)
|
|
136
241
|
) : (
|
|
137
242
|
<DataTable
|
|
138
243
|
columns={columns}
|
|
139
244
|
data={data}
|
|
140
245
|
onSort={handleSort}
|
|
246
|
+
sortField={listParams.sort}
|
|
247
|
+
sortOrder={listParams.order}
|
|
248
|
+
// Key rows by the record's own key, so a row's cell state (an inline-enum
|
|
249
|
+
// error, a row action's message) stays with that record across a refetch.
|
|
250
|
+
rowKey={resourceConfig?.primaryKey ?? 'id'}
|
|
141
251
|
// A list-only resource (actions.show === false) has no detail screen —
|
|
142
252
|
// don't make rows navigate to a fetchOne that 404s. The row-action menu
|
|
143
253
|
// already hides "Show" via standardRowActions; this gates the whole-row tap.
|
|
@@ -151,7 +261,9 @@ export function ResourceList({ resource, onRowPress, onEditPress, onCreatePress,
|
|
|
151
261
|
page={page}
|
|
152
262
|
perPage={perPage}
|
|
153
263
|
total={total}
|
|
154
|
-
onPageChange={
|
|
264
|
+
onPageChange={next => update({ page: next })}
|
|
265
|
+
pageSizes={pageSizes}
|
|
266
|
+
onPerPageChange={size => update({ perPage: size, page: pageForSize(page, perPage, size) })}
|
|
155
267
|
/>
|
|
156
268
|
)}
|
|
157
269
|
</View>
|
|
@@ -162,7 +274,11 @@ export function ResourceList({ resource, onRowPress, onEditPress, onCreatePress,
|
|
|
162
274
|
// Helpers
|
|
163
275
|
// =============================================================================
|
|
164
276
|
|
|
165
|
-
function fieldsToColumns(
|
|
277
|
+
function fieldsToColumns(
|
|
278
|
+
resourceConfig: ResourceConfig,
|
|
279
|
+
adapter: DataAdapter,
|
|
280
|
+
onInlineSaved: () => Promise<void>,
|
|
281
|
+
): DataTableColumn[] {
|
|
166
282
|
const fieldNames = resourceConfig.listFields ?? resourceConfig.fields.map(f => f.name);
|
|
167
283
|
const fieldMap = new Map(resourceConfig.fields.map(f => [f.name, f]));
|
|
168
284
|
|
|
@@ -173,18 +289,42 @@ function fieldsToColumns(resourceConfig: ResourceConfig): DataTableColumn[] {
|
|
|
173
289
|
key: field.name,
|
|
174
290
|
label: field.label ?? field.name,
|
|
175
291
|
sortable: field.sortable,
|
|
176
|
-
|
|
292
|
+
...(field.listWidth !== undefined && { width: field.listWidth }),
|
|
293
|
+
...(field.listPriority !== undefined && { priority: field.listPriority }),
|
|
294
|
+
...(field.listHideBelow !== undefined && { hideBelow: field.listHideBelow }),
|
|
295
|
+
render: cellRenderer(field, resourceConfig, adapter, onInlineSaved),
|
|
177
296
|
}));
|
|
178
297
|
}
|
|
179
298
|
|
|
180
299
|
/**
|
|
181
300
|
* Cell rendering precedence: custom render → image thumbnail → type text.
|
|
182
|
-
* (See render-resolution.cellRenderKind.)
|
|
301
|
+
* (See render-resolution.cellRenderKind.) An `inlineEdit` enum field shows a
|
|
302
|
+
* picker in place of the type text, unless the resource sets `actions.edit: false`.
|
|
183
303
|
*/
|
|
184
|
-
function cellRenderer(
|
|
185
|
-
|
|
304
|
+
function cellRenderer(
|
|
305
|
+
field: FieldDefinition,
|
|
306
|
+
resourceConfig: ResourceConfig,
|
|
307
|
+
adapter: DataAdapter,
|
|
308
|
+
onInlineSaved: () => Promise<void>,
|
|
309
|
+
): DataTableColumn['render'] {
|
|
310
|
+
const kind = cellRenderKind(field);
|
|
311
|
+
if (kind === 'text' && field.inlineEdit === true && field.type === 'enum' && resourceConfig.actions?.edit !== false) {
|
|
312
|
+
const primaryKey = resourceConfig.primaryKey ?? 'id';
|
|
313
|
+
return (value, record) => (
|
|
314
|
+
<InlineEnumCell
|
|
315
|
+
resource={resourceConfig.name}
|
|
316
|
+
field={field.name}
|
|
317
|
+
recordId={(record[primaryKey] as string | number) ?? (record.id as string | number)}
|
|
318
|
+
value={value}
|
|
319
|
+
enumValues={field.enumValues ?? []}
|
|
320
|
+
adapter={adapter}
|
|
321
|
+
onSaved={onInlineSaved}
|
|
322
|
+
/>
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
switch (kind) {
|
|
186
326
|
case 'custom':
|
|
187
|
-
return (value, record) => field.render!(value, record as DataRecord, { view: 'list' });
|
|
327
|
+
return (value, record) => wrapBareText(field.render!(value, record as DataRecord, { view: 'list' }), 1);
|
|
188
328
|
case 'image':
|
|
189
329
|
return (value) => <ImageDisplay field={field} value={value} view="list" />;
|
|
190
330
|
default:
|
|
@@ -196,6 +336,13 @@ function cellRenderer(field: FieldDefinition): DataTableColumn['render'] {
|
|
|
196
336
|
}
|
|
197
337
|
}
|
|
198
338
|
|
|
339
|
+
/** The resource's actionsWidth when it is a finite number above 0, else the estimate from the labels. */
|
|
340
|
+
function actionsColumnWidth(configured: number | undefined, actions: RowActionConfig[]): number {
|
|
341
|
+
return typeof configured === 'number' && Number.isFinite(configured) && configured > 0
|
|
342
|
+
? configured
|
|
343
|
+
: estimateActionsWidth(actions);
|
|
344
|
+
}
|
|
345
|
+
|
|
199
346
|
/**
|
|
200
347
|
* A trailing column of per-row actions, each gated by its `when` clause.
|
|
201
348
|
* The cell claims the press so tapping an action doesn't also fire the row's
|
|
@@ -211,11 +358,16 @@ function actionsColumn(
|
|
|
211
358
|
return {
|
|
212
359
|
key: '__actions',
|
|
213
360
|
label: 'Actions',
|
|
361
|
+
// Rows cannot share a content-sized width in a flex layout, so the width is a number.
|
|
362
|
+
width: actionsColumnWidth(resourceConfig.actionsWidth, actions),
|
|
363
|
+
// Every row needs its actions, so a narrow list never drops this column.
|
|
364
|
+
// `keep` rather than priority 1, which would turn dropping on for every list with actions.
|
|
365
|
+
keep: true,
|
|
214
366
|
render: (_value, record) => {
|
|
215
367
|
const visible = visibleRowActions(actions, record);
|
|
216
368
|
if (!visible.length) return null;
|
|
217
369
|
return (
|
|
218
|
-
<View className="flex-row gap-2" onStartShouldSetResponder={() => true}>
|
|
370
|
+
<View className="flex-row flex-wrap gap-2" onStartShouldSetResponder={() => true}>
|
|
219
371
|
{visible.map(action => (
|
|
220
372
|
<RowActionButton
|
|
221
373
|
key={action.id}
|