@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
@@ -0,0 +1,109 @@
1
+ /**
2
+ * DeletePreview — before a delete, how many rows each declared association links
3
+ * to this record. The counts are information only: a count that fails shows `?`,
4
+ * and Confirm never waits for them or depends on them. It adds no write.
5
+ */
6
+
7
+ import React, { useEffect, useRef, useState } from 'react';
8
+ import { View, Text } from 'react-native';
9
+ import { Button } from '@everystack/ui';
10
+ import type { DataAdapter } from '../adapters/types';
11
+ import type { AssociationConfig } from '../types';
12
+ import { safeRelationId } from '../hooks/useRelationLabels';
13
+
14
+ /** Each association costs one request, so the preview counts at most this many. */
15
+ export const MAX_ASSOCIATIONS = 12;
16
+
17
+ export interface DeletePreviewProps {
18
+ adapter: DataAdapter;
19
+ /** e.g. 'Contents' */
20
+ label: string;
21
+ id: string | number;
22
+ associations: AssociationConfig[];
23
+ /** The resource soft-deletes, so the linked rows stay as they are. */
24
+ softDelete: boolean;
25
+ /** The existing delete path. */
26
+ onConfirm: () => void | Promise<void>;
27
+ onCancel: () => void;
28
+ }
29
+
30
+ /** The linked rows of one association: a `count` aggregate when the adapter has one, else a one-row list's total. */
31
+ export async function countAssociation(adapter: DataAdapter, association: AssociationConfig, id: string | number): Promise<number> {
32
+ const safeId = safeRelationId(id);
33
+ if (safeId === null) throw new Error('Association count needs a safe record id');
34
+ // The adapter skips an undefined filter value, so a missing typeValue would count every type.
35
+ if (association.typeColumn && association.typeValue == null) {
36
+ throw new Error(`association "${association.label}": typeColumn needs a typeValue`);
37
+ }
38
+ const filter = {
39
+ [association.foreignKey]: `eq.${safeId}`,
40
+ ...(association.typeColumn ? { [association.typeColumn]: association.typeValue } : {}),
41
+ };
42
+ if (adapter.aggregate) {
43
+ const result = await adapter.aggregate(association.resource, { aggregates: [{ function: 'count' }], filter });
44
+ return Number(result.data[0]?.count ?? 0);
45
+ }
46
+ return (await adapter.fetchList(association.resource, { page: 1, perPage: 1, filter })).total;
47
+ }
48
+
49
+ type Count = number | '?' | undefined;
50
+
51
+ export function DeletePreview({
52
+ adapter,
53
+ label,
54
+ id,
55
+ associations,
56
+ softDelete,
57
+ onConfirm,
58
+ onCancel,
59
+ }: DeletePreviewProps): React.ReactElement {
60
+ const shown = associations.slice(0, MAX_ASSOCIATIONS);
61
+ const [counts, setCounts] = useState<Count[]>([]);
62
+ const busy = useRef(false);
63
+ const [error, setError] = useState<string | null>(null);
64
+
65
+ useEffect(() => {
66
+ if (associations.length > MAX_ASSOCIATIONS) {
67
+ console.warn(`[admin] ${label}: only the first ${MAX_ASSOCIATIONS} associations are counted`);
68
+ }
69
+ let live = true;
70
+ setCounts([]);
71
+ Promise.allSettled(associations.slice(0, MAX_ASSOCIATIONS).map(a => countAssociation(adapter, a, id)))
72
+ .then(results => {
73
+ if (live) setCounts(results.map(r => (r.status === 'fulfilled' ? r.value : '?')));
74
+ });
75
+ return () => { live = false; };
76
+ }, [adapter, associations, id, label]);
77
+
78
+ const confirm = async (): Promise<void> => {
79
+ if (busy.current) return;
80
+ busy.current = true;
81
+ setError(null);
82
+ try {
83
+ await onConfirm();
84
+ } catch (err) {
85
+ setError(err instanceof Error ? err.message : String(err));
86
+ } finally {
87
+ busy.current = false;
88
+ }
89
+ };
90
+
91
+ return (
92
+ <View testID="delete-preview" className="mx-4 mb-4 p-3 rounded-md bg-red-50">
93
+ <Text className="text-base font-semibold text-red-800 mb-2">{`Delete ${label} #${String(id)}?`}</Text>
94
+ {shown.map((association, i) => (
95
+ <Text key={`${association.resource}:${association.foreignKey}:${i}`} className="text-sm text-gray-900">
96
+ {`${association.label}: ${counts[i] ?? '…'}`}
97
+ </Text>
98
+ ))}
99
+ <Text className="text-xs text-gray-600 mt-2">
100
+ {softDelete ? 'Nothing is deleted from these tables.' : 'These rows will be left without their record.'}
101
+ </Text>
102
+ <View className="flex-row gap-2 mt-3">
103
+ <Button label="Confirm" variant="destructive" onPress={confirm} />
104
+ <Button label="Cancel" variant="outline" onPress={onCancel} />
105
+ </View>
106
+ {error && <Text className="text-xs text-red-600 mt-1">{error}</Text>}
107
+ </View>
108
+ );
109
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * FieldColumns — the field columns of a Show/Edit/Create screen.
3
+ *
4
+ * Side by side on a wide container: a 'wide' column takes the space left
5
+ * over, a fixed column keeps its width. Below BREAKPOINT_STACK the columns
6
+ * stack, main first, each full width. It measures its own container (not the
7
+ * window) and renders stacked until the first measurement.
8
+ */
9
+
10
+ import React, { useCallback, useRef } from 'react';
11
+ import { View } from 'react-native';
12
+ import type { LayoutChangeEvent } from 'react-native';
13
+ import { useContainerWidth } from '@everystack/ui/dashboard';
14
+ import type { FieldDefinition } from '../types';
15
+ import type { ResolvedColumn } from '../utils/resolve-columns';
16
+
17
+ /** Container width (px) below which the columns stack. */
18
+ export const BREAKPOINT_STACK = 720;
19
+
20
+ /** Space between columns that sit side by side, in px. */
21
+ const COLUMN_GAP = 24;
22
+
23
+ export interface FieldColumnsProps {
24
+ /** From resolveColumns. */
25
+ columns: ResolvedColumn[];
26
+ /** Draws one field; `index` is its place in its column. */
27
+ renderField: (field: FieldDefinition, index: number) => React.ReactNode;
28
+ /** A field's y within this component (its column's y + its own y), on every layout change. */
29
+ onFieldOffset?: (name: string, y: number) => void;
30
+ }
31
+
32
+ export function FieldColumns({ columns, renderField, onFieldOffset }: FieldColumnsProps): React.ReactElement {
33
+ // Fallback 0: stacked until the container is measured.
34
+ const { width, onLayout } = useContainerWidth(0);
35
+ const stacked = width < BREAKPOINT_STACK;
36
+
37
+ const columnY = useRef(new Map<number, number>());
38
+ const fieldY = useRef(new Map<string, { column: number; y: number }>());
39
+
40
+ const report = useCallback((name: string) => {
41
+ const field = fieldY.current.get(name);
42
+ if (!field || !onFieldOffset) return;
43
+ const top = columnY.current.get(field.column);
44
+ if (top === undefined) return;
45
+ onFieldOffset(name, top + field.y);
46
+ }, [onFieldOffset]);
47
+
48
+ const onColumnLayout = (column: number, fields: FieldDefinition[]) => (event: LayoutChangeEvent) => {
49
+ columnY.current.set(column, event.nativeEvent.layout.y);
50
+ for (const field of fields) report(field.name);
51
+ };
52
+
53
+ const onItemLayout = (column: number, name: string) => (event: LayoutChangeEvent) => {
54
+ fieldY.current.set(name, { column, y: event.nativeEvent.layout.y });
55
+ report(name);
56
+ };
57
+
58
+ return (
59
+ <View
60
+ testID="field-columns"
61
+ onLayout={onLayout}
62
+ style={stacked ? { flexDirection: 'column' } : { flexDirection: 'row', alignItems: 'flex-start', columnGap: COLUMN_GAP }}
63
+ >
64
+ {columns.map((column, ci) => (
65
+ <View
66
+ key={ci}
67
+ testID={`field-column-${ci}`}
68
+ onLayout={onColumnLayout(ci, column.fields)}
69
+ style={columnStyle(column, stacked)}
70
+ >
71
+ {column.fields.map((field, fi) => (
72
+ <View key={field.name} testID={`field-columns-item-${field.name}`} onLayout={onItemLayout(ci, field.name)}>
73
+ {renderField(field, fi)}
74
+ </View>
75
+ ))}
76
+ </View>
77
+ ))}
78
+ </View>
79
+ );
80
+ }
81
+
82
+ function columnStyle(column: ResolvedColumn, stacked: boolean) {
83
+ if (stacked) return { width: '100%' as const };
84
+ if (column.width === 'wide') return { flex: 1, minWidth: 0 };
85
+ return { width: column.width, flexShrink: 0 };
86
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * FormActionBar — Save/Cancel at the bottom of the Edit and Create forms.
3
+ *
4
+ * The screens render it as a sibling under their ScrollView, so it stays in
5
+ * view on a long form without CSS positioning. The submit error lives here,
6
+ * above the buttons, so it is visible however far the form is scrolled.
7
+ */
8
+
9
+ import React from 'react';
10
+ import { View, Text } from 'react-native';
11
+ import { Button } from '@everystack/ui';
12
+
13
+ export interface FormActionBarProps {
14
+ onSubmit: () => void;
15
+ /** Cancel is shown only when this is given. */
16
+ onCancel?: () => void;
17
+ submitting: boolean;
18
+ /** Defaults to 'Save'. */
19
+ submitLabel?: string;
20
+ error?: string | null;
21
+ }
22
+
23
+ export function FormActionBar({
24
+ onSubmit,
25
+ onCancel,
26
+ submitting,
27
+ submitLabel = 'Save',
28
+ error,
29
+ }: FormActionBarProps): React.ReactElement {
30
+ return (
31
+ <View testID="form-action-bar" className="border-t border-gray-200 bg-white px-4 py-3">
32
+ {error ? (
33
+ <View testID="form-action-bar-error" className="mb-3 bg-red-50 border border-red-200 rounded-lg px-4 py-3">
34
+ <Text className="text-sm text-red-700">{error}</Text>
35
+ </View>
36
+ ) : null}
37
+ <View className="flex-row justify-end gap-2">
38
+ {onCancel && (
39
+ <Button label="Cancel" variant="ghost" onPress={onCancel} />
40
+ )}
41
+ <Button label={submitLabel} onPress={onSubmit} loading={submitting} />
42
+ </View>
43
+ </View>
44
+ );
45
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * InlineEnumCell — a list cell that edits an enum field (`inlineEdit: true`).
3
+ *
4
+ * A pick shows the new value at once and saves it with
5
+ * `adapter.update(resource, recordId, { [field]: value })`, the only write this
6
+ * cell makes. While it saves, the pill is disabled. A rejection restores the
7
+ * previous value and shows the server's message under the pill; the next
8
+ * change clears it. A success keeps the pill disabled through `onSaved` (the
9
+ * list refreshes), then shows the value the list holds, even when that value
10
+ * did not change.
11
+ *
12
+ * The cell claims the press, so a pick does not also fire the row's onRowPress.
13
+ */
14
+
15
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
16
+ import { View, Text } from 'react-native';
17
+ import { OptionPicker } from '@everystack/ui';
18
+ import type { DataAdapter } from '../adapters/types';
19
+
20
+ export interface InlineEnumCellProps {
21
+ resource: string;
22
+ field: string;
23
+ recordId: string | number;
24
+ /** The stored value. */
25
+ value: unknown;
26
+ enumValues: string[];
27
+ adapter: DataAdapter;
28
+ /** Called after a successful save. The cell re-syncs from `value` once it settles. */
29
+ onSaved: () => void | Promise<void>;
30
+ }
31
+
32
+ export function InlineEnumCell({
33
+ resource,
34
+ field,
35
+ recordId,
36
+ value,
37
+ enumValues,
38
+ adapter,
39
+ onSaved,
40
+ }: InlineEnumCellProps): React.ReactElement {
41
+ const stored = value == null ? '' : String(value);
42
+ const [shown, setShown] = useState(stored);
43
+ const [saving, setSaving] = useState(false);
44
+ const [error, setError] = useState<string | null>(null);
45
+ // Set at once, so a second change in the same tick (before `saving` renders) is refused.
46
+ const inFlight = useRef(false);
47
+ const mounted = useRef(true);
48
+ useEffect(() => {
49
+ mounted.current = true;
50
+ return () => { mounted.current = false; };
51
+ }, []);
52
+ // Bumped after a save's refresh lands, so the cell re-syncs even when the stored value is unchanged.
53
+ const [synced, setSynced] = useState(0);
54
+
55
+ // The stored value (the list refetched) replaces what the cell shows.
56
+ useEffect(() => { setShown(stored); }, [stored, synced]);
57
+
58
+ const change = useCallback(async (next: string) => {
59
+ if (inFlight.current || next === shown) return;
60
+ inFlight.current = true;
61
+ const previous = shown;
62
+ setError(null);
63
+ setShown(next);
64
+ setSaving(true);
65
+ try {
66
+ try {
67
+ await adapter.update(resource, recordId, { [field]: next });
68
+ } catch (err) {
69
+ setShown(previous);
70
+ setError(err instanceof Error ? err.message : String(err));
71
+ return;
72
+ }
73
+ await onSaved();
74
+ if (mounted.current) setSynced(n => n + 1);
75
+ } catch {
76
+ // The save landed; a failed refresh is the list's to report.
77
+ } finally {
78
+ inFlight.current = false;
79
+ setSaving(false);
80
+ }
81
+ }, [shown, adapter, resource, recordId, field, onSaved]);
82
+
83
+ return (
84
+ <View onStartShouldSetResponder={() => true}>
85
+ <OptionPicker
86
+ value={shown}
87
+ options={enumValues.map(v => ({ label: v, value: v }))}
88
+ onChange={change}
89
+ disabled={saving}
90
+ />
91
+ {error ? (
92
+ <Text className="text-xs text-red-600 mt-1" numberOfLines={2}>
93
+ {error}
94
+ </Text>
95
+ ) : null}
96
+ </View>
97
+ );
98
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * RelationFieldInput
3
+ *
4
+ * Create/edit picker for `type: 'relation'`. Typing searches the related
5
+ * resource 250 ms after the last key: digits search by `relation.field`, other
6
+ * text searches `relation.displayField` through ListParams.search (the
7
+ * adapter escapes it). Without a displayField, text that passes
8
+ * safeRelationId is matched exactly by `relation.field`. A result
9
+ * reads `#id name`; a pick saves the fetched row's `relation.field` value, so
10
+ * a number stays a number.
11
+ *
12
+ * The current value shows as a chip with a Clear control. A picked row names
13
+ * its own chip; a stored value gets its name from useRelationLabels, one
14
+ * lookup on mount.
15
+ */
16
+
17
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
18
+ import { View, Text, Pressable } from 'react-native';
19
+ import { TextInput } from '@everystack/ui';
20
+ import { useRelationLabels, safeRelationId } from '../hooks/useRelationLabels';
21
+ import type { DataAdapter } from '../adapters/types';
22
+ import type { DataRecord, FieldDefinition, ListParams } from '../types';
23
+
24
+ export interface RelationFieldInputProps {
25
+ field: FieldDefinition;
26
+ value: unknown;
27
+ onChange: (value: string | number | null) => void;
28
+ adapter: DataAdapter;
29
+ }
30
+
31
+ const DEBOUNCE_MS = 250;
32
+ const PAGE_SIZE = 10;
33
+ const DIGITS = /^\d+$/;
34
+
35
+ type Status = 'idle' | 'searching' | 'results' | 'none' | 'failed';
36
+
37
+ function relationLabel(id: unknown, name: unknown): string {
38
+ return name != null && name !== '' ? `#${String(id)} ${String(name)}` : `#${String(id)}`;
39
+ }
40
+
41
+ export function RelationFieldInput({ field, value, onChange, adapter }: RelationFieldInputProps): React.ReactElement {
42
+ const relation = field.relation;
43
+ const [text, setText] = useState('');
44
+ const [status, setStatus] = useState<Status>('idle');
45
+ const [results, setResults] = useState<DataRecord[]>([]);
46
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
47
+ // Bumped by every search and every pick; a response for an older number is dropped.
48
+ const requestId = useRef(0);
49
+ const mounted = useRef(true);
50
+ // The last picked row, so its chip needs no lookup.
51
+ const [picked, setPicked] = useState<{ id: string; name: unknown } | null>(null);
52
+
53
+ const currentId = safeRelationId(value);
54
+ const pickedCurrent = picked !== null && picked.id === currentId;
55
+ const fields = useMemo(() => [field], [field]);
56
+ const rows = useMemo(
57
+ () => (pickedCurrent ? [] : [{ [field.name]: value }]),
58
+ [pickedCurrent, field.name, value],
59
+ );
60
+ const labels = useRelationLabels(adapter, fields, rows);
61
+
62
+ useEffect(() => {
63
+ mounted.current = true;
64
+ return () => {
65
+ mounted.current = false;
66
+ if (timer.current) clearTimeout(timer.current);
67
+ };
68
+ }, []);
69
+
70
+ const search = (query: string): void => {
71
+ if (!relation) return;
72
+ const id = ++requestId.current;
73
+ let params: ListParams;
74
+ if (DIGITS.test(query) || (!relation.displayField && safeRelationId(query) !== null)) {
75
+ params = { page: 1, perPage: PAGE_SIZE, filter: { [relation.field]: `eq.${query}` } };
76
+ } else if (relation.displayField) {
77
+ params = { page: 1, perPage: PAGE_SIZE, search: query, searchFields: [relation.displayField] };
78
+ } else {
79
+ setResults([]);
80
+ setStatus('none');
81
+ return;
82
+ }
83
+ setStatus('searching');
84
+ adapter.fetchList(relation.resource, params).then(result => {
85
+ if (!mounted.current || id !== requestId.current) return;
86
+ setResults(result.data);
87
+ setStatus(result.data.length > 0 ? 'results' : 'none');
88
+ }).catch(() => {
89
+ if (!mounted.current || id !== requestId.current) return;
90
+ setResults([]);
91
+ setStatus('failed');
92
+ });
93
+ };
94
+
95
+ const onChangeText = (next: string): void => {
96
+ setText(next);
97
+ if (timer.current) clearTimeout(timer.current);
98
+ const query = next.trim();
99
+ if (query === '') {
100
+ requestId.current++;
101
+ setResults([]);
102
+ setStatus('idle');
103
+ return;
104
+ }
105
+ timer.current = setTimeout(() => search(query), DEBOUNCE_MS);
106
+ };
107
+
108
+ const pick = (row: DataRecord): void => {
109
+ if (!relation) return;
110
+ requestId.current++;
111
+ setText('');
112
+ setResults([]);
113
+ setStatus('idle');
114
+ const id = safeRelationId(row[relation.field]);
115
+ setPicked(id === null ? null : { id, name: relation.displayField ? row[relation.displayField] : undefined });
116
+ onChange(row[relation.field] as string | number);
117
+ };
118
+
119
+ const currentName = pickedCurrent
120
+ ? picked.name
121
+ : currentId !== null ? labels.get(field.name)?.get(currentId) : undefined;
122
+ const placeholder = relation?.displayField ? 'Search by name or id' : 'Search by id';
123
+
124
+ return (
125
+ <View>
126
+ {value != null && value !== '' && (
127
+ <View className="flex-row items-center gap-2 mb-2">
128
+ <Text className="text-sm text-gray-900">{relationLabel(value, currentName)}</Text>
129
+ <Pressable accessibilityRole="button" accessibilityLabel="Clear" onPress={() => onChange(null)}>
130
+ <Text className="text-sm text-gray-500">×</Text>
131
+ </Pressable>
132
+ </View>
133
+ )}
134
+ <TextInput value={text} onChangeText={onChangeText} placeholder={placeholder} />
135
+ {status === 'searching' && <Text className="text-sm text-gray-500 mt-1">Searching…</Text>}
136
+ {status === 'none' && <Text className="text-sm text-gray-500 mt-1">No matches</Text>}
137
+ {status === 'failed' && <Text className="text-sm text-red-600 mt-1">Lookup failed</Text>}
138
+ {status === 'results' && relation && (
139
+ <View className="mt-1 border border-gray-200 rounded-lg">
140
+ {results.map(row => {
141
+ const id = row[relation.field];
142
+ const name = relation.displayField ? row[relation.displayField] : undefined;
143
+ return (
144
+ <Pressable
145
+ key={String(id)}
146
+ testID={`relation-${field.name}-option-${String(id)}`}
147
+ accessibilityRole="button"
148
+ onPress={() => pick(row)}
149
+ className="px-3 py-2"
150
+ >
151
+ <Text className="text-sm text-gray-900">{relationLabel(id, name)}</Text>
152
+ </Pressable>
153
+ );
154
+ })}
155
+ </View>
156
+ )}
157
+ </View>
158
+ );
159
+ }
@@ -0,0 +1,26 @@
1
+ import React from 'react';
2
+ import { Text } from 'react-native';
3
+ import { AdminLink } from './AdminLink';
4
+ import { safeRelationId } from '../hooks/useRelationLabels';
5
+
6
+ /**
7
+ * A relation value: the label (else the id) linked to the related show page.
8
+ * Null or empty shows nothing. An id that is unsafe in a path shows as plain text.
9
+ */
10
+ export function RelationValue({ resource, value, labels, numberOfLines }: {
11
+ resource: string;
12
+ value: unknown;
13
+ labels: Map<string, string> | undefined;
14
+ numberOfLines?: number;
15
+ }): React.ReactElement | null {
16
+ if (value == null || value === '') return null;
17
+ const id = safeRelationId(value);
18
+ if (id === null) {
19
+ return <Text className="text-sm text-gray-900" numberOfLines={numberOfLines}>{String(value)}</Text>;
20
+ }
21
+ return (
22
+ <AdminLink to={{ resource, id }} numberOfLines={numberOfLines}>
23
+ {labels?.get(id) ?? id}
24
+ </AdminLink>
25
+ );
26
+ }
@@ -25,12 +25,16 @@ export interface RowActionButtonProps {
25
25
  /** Value sent as `{ id }` to an rpc action (record[primaryKey]). */
26
26
  recordId: string | number;
27
27
  adapter: DataAdapter;
28
- /** Re-fetch the current view after the action completes. */
28
+ /** Re-fetch the current view after the action completes or fails. */
29
29
  refetch: () => Promise<void> | void;
30
30
  /** Navigate to an admin path, when the route layer provides it. */
31
31
  navigate?: (path: string) => void;
32
32
  }
33
33
 
34
+ function messageOf(err: unknown): string {
35
+ return err instanceof Error ? err.message : String(err);
36
+ }
37
+
34
38
  export function RowActionButton({
35
39
  action,
36
40
  record,
@@ -56,15 +60,26 @@ export function RowActionButton({
56
60
  setBusy(true);
57
61
  setError(null);
58
62
  try {
59
- if (action.onPress) {
60
- await action.onPress({ record, adapter, refetch, navigate });
61
- } else if (action.rpc) {
62
- if (!adapter.rpc) throw new Error('Adapter does not support RPC actions');
63
- await adapter.rpc(action.rpc, { id: recordId });
63
+ let failed = false;
64
+ try {
65
+ if (action.onPress) {
66
+ await action.onPress({ record, adapter, refetch, navigate });
67
+ } else if (action.rpc) {
68
+ if (!adapter.rpc) throw new Error('Adapter does not support RPC actions');
69
+ await adapter.rpc(action.rpc, { id: recordId });
70
+ }
71
+ } catch (err) {
72
+ failed = true;
73
+ setError(messageOf(err));
74
+ }
75
+ // Refetch on failure too: a delete of a row another session already
76
+ // removed fails, and the list must still drop the stale row.
77
+ try {
78
+ await refetch();
79
+ } catch (err) {
80
+ // A refetch fault never replaces the action's own error.
81
+ if (!failed) setError(messageOf(err));
64
82
  }
65
- await refetch();
66
- } catch (err) {
67
- setError(err instanceof Error ? err.message : String(err));
68
83
  } finally {
69
84
  setBusy(false);
70
85
  setConfirming(false);
@@ -0,0 +1,22 @@
1
+ /**
2
+ * TagsDisplay — read-only pills for a `type: 'tags'` value on the show view.
3
+ * Labels come from the field's groups; a value no group lists shows as itself.
4
+ */
5
+
6
+ import React from 'react';
7
+ import { View, Text } from 'react-native';
8
+ import { Badge } from '@everystack/ui';
9
+ import { tagLabel, tagValues } from '../utils/tags';
10
+ import type { FieldDefinition } from '../types';
11
+
12
+ export function TagsDisplay({ field, value }: { field: FieldDefinition; value: unknown }): React.ReactElement {
13
+ const tags = tagValues(value);
14
+ if (tags.length === 0) return <Text className="text-sm text-gray-900">{'—'}</Text>;
15
+ return (
16
+ <View testID={`tags-${field.name}`} className="flex-row flex-wrap gap-2">
17
+ {tags.map((tag, i) => (
18
+ <Badge key={`${i}-${tag}`} label={tagLabel(field, tag)} />
19
+ ))}
20
+ </View>
21
+ );
22
+ }