@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.
@@ -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((): boolean => {
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 && (value == null || value === '')) {
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 Object.keys(newErrors).length === 0;
111
+ return newErrors;
97
112
  }, [fields, formData]);
98
113
 
99
114
  const handleSubmit = useCallback(async () => {
100
- if (!validate()) return;
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
- <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} />
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
- {/* 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>
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
- {/* 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>
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
- return <>{field.input({ value, onChange, field, record, error })}</>;
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
- import React, { useCallback, useMemo } from 'react';
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 { DataTable, FilterBar, PaginationBar, LoadingState, ErrorState, EmptyState, Button, type DataTableColumn } from '@everystack/ui';
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
- sort: defaultSort?.field,
57
- order: defaultSort?.order,
58
- baseFilter,
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
- setSort(key, 'asc');
86
- }, [setSort]);
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
- <FilterBar
121
- placeholder={`Search ${label.toLowerCase()}...`}
122
- onSearch={handleSearch}
123
- filters={filterChips}
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
- <EmptyState
133
- title={`No ${label.toLowerCase()}`}
134
- message={`No ${label.toLowerCase()} found. ${canCreate ? 'Create one to get started.' : ''}`}
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={setPage}
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(resourceConfig: ResourceConfig): DataTableColumn[] {
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
- render: cellRenderer(field),
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(field: FieldDefinition): DataTableColumn['render'] {
185
- switch (cellRenderKind(field)) {
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}