@everystack/admin 0.4.3 → 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 CHANGED
@@ -4,6 +4,29 @@ All notable changes to `@everystack/admin` are documented here.
4
4
 
5
5
  Format based on [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [0.4.4] - 2026-10-15
8
+
9
+ ### Added
10
+
11
+ - the Show screen renders html and markdown fields as sanitised HTML
12
+ - list and show can embed related rows with ResourceConfig.select
13
+ - relation fields show the related name as a link in the list and on Show
14
+ - relation fields get a searchable picker in the Create and Edit forms
15
+ - soft-deletable resources get a Deleted tab and Restore
16
+ - Delete shows how many rows link to the record before it confirms
17
+
18
+ ### Fixed
19
+
20
+ - a dashboard dimension named size, page or sort no longer clashes with view state
21
+ - clicking the default tab clears ?tab, and the tabs are a tablist
22
+ - the delete preview counts by exact id and ignores a second Confirm press
23
+
24
+ ### Documentation
25
+
26
+ - the Show screen allowlist, the install step and the known sanitize gap
27
+ - RPC list search covers the first field, counts cover all of them
28
+ - use posts and comments in the Embeds and Association examples
29
+
7
30
  ## [0.4.3] - 2026-10-14
8
31
 
9
32
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/admin",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "Declarative admin dashboard framework for PostgREST APIs",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -50,7 +50,7 @@
50
50
  },
51
51
  "peerDependencies": {
52
52
  "drizzle-orm": "0.41.0",
53
- "@everystack/api": ">=0.2.4",
53
+ "@everystack/api": ">=0.4.4",
54
54
  "@everystack/ui": ">=0.4.2",
55
55
  "@tiptap/extension-code": ">=3.0.0",
56
56
  "@tiptap/extension-code-block": ">=3.0.0",
@@ -117,8 +117,8 @@
117
117
  "react-test-renderer": "19.2.0",
118
118
  "ts-jest": "29.4.9",
119
119
  "typescript": "5.9.3",
120
- "@everystack/api": "0.4.3",
121
- "@everystack/ui": "0.4.2"
120
+ "@everystack/api": "0.4.4",
121
+ "@everystack/ui": "0.4.3"
122
122
  },
123
123
  "scripts": {
124
124
  "test": "jest",
@@ -10,6 +10,7 @@ import { toCamelCaseKeys } from '../utils/naming';
10
10
  import { mergeFilters, normalizeSearchText } from '../utils/list-query';
11
11
  import type { DataAdapter } from './types';
12
12
  import type {
13
+ DeletedMode,
13
14
  ListParams,
14
15
  ListResult,
15
16
  DataRecord,
@@ -63,7 +64,7 @@ export interface EverystackAdapterOptions extends ClientOptions {
63
64
 
64
65
  export class EverystackAdapter implements DataAdapter {
65
66
  readonly supportsSearch = true;
66
- private client: Client;
67
+ protected readonly client: Client;
67
68
  private listRpcs: Record<string, string>;
68
69
  private crudRpcs: Record<string, string>;
69
70
 
@@ -98,6 +99,7 @@ export class EverystackAdapter implements DataAdapter {
98
99
  sort: params.sort,
99
100
  dir: params.order ?? 'asc',
100
101
  filter,
102
+ ...(params.deleted ? { deleted: params.deleted } : {}),
101
103
  });
102
104
  // Normalize row keys to camelCase so the list columns resolve regardless of whether
103
105
  // the RPC returns snake_case (e.g. jobsEventList over ops.runs) or camelCase (S3 lists,
@@ -112,6 +114,8 @@ export class EverystackAdapter implements DataAdapter {
112
114
  }
113
115
 
114
116
  let builder = this.client.from(resource);
117
+ if (params.select) builder = builder.select(params.select);
118
+ if (params.deleted) builder = builder.deleted(params.deleted);
115
119
 
116
120
  // Apply filters, then the search group beside them
117
121
  builder = applyFilters(builder, params.filter);
@@ -142,16 +146,24 @@ export class EverystackAdapter implements DataAdapter {
142
146
  async fetchOne<T extends DataRecord = DataRecord>(
143
147
  resource: string,
144
148
  id: string | number,
149
+ params?: { select?: string; deleted?: DeletedMode },
145
150
  ): Promise<T> {
146
151
  // RPC-backed single read (e.g. DB-free alert rules from the S3 config object).
147
152
  const crudRpc = this.crudRpcs[resource];
148
153
  if (crudRpc) {
149
- const record = await this.rpc<T | null>(crudRpc, { action: 'get', id: String(id) });
154
+ const record = await this.rpc<T | null>(crudRpc, {
155
+ action: 'get',
156
+ id: String(id),
157
+ ...(params?.deleted ? { deleted: params.deleted } : {}),
158
+ });
150
159
  if (!record) throw new Error(`${resource}/${id} not found`);
151
160
  return record;
152
161
  }
153
162
 
154
- const result = await this.client.from(resource).eq('id', String(id)).single();
163
+ let builder = this.client.from(resource).eq('id', String(id));
164
+ if (params?.select) builder = builder.select(params.select);
165
+ if (params?.deleted) builder = builder.deleted(params.deleted);
166
+ const result = await builder.single();
155
167
 
156
168
  if (result.error) {
157
169
  throw new Error(result.error.status === 406
@@ -184,23 +196,31 @@ export class EverystackAdapter implements DataAdapter {
184
196
  resource: string,
185
197
  id: string | number,
186
198
  data: Partial<T>,
199
+ params?: { deleted?: DeletedMode },
187
200
  ): Promise<T> {
188
201
  const crudRpc = this.crudRpcs[resource];
189
202
  if (crudRpc) {
190
- return this.rpcResult<T>(await this.rpc<T | { error: string }>(crudRpc, { action: 'update', id: String(id), data }));
203
+ return this.rpcResult<T>(await this.rpc<T | { error: string }>(crudRpc, {
204
+ action: 'update',
205
+ id: String(id),
206
+ data,
207
+ ...(params?.deleted ? { deleted: params.deleted } : {}),
208
+ }));
191
209
  }
192
210
 
193
- const result = await this.client
194
- .from(resource)
195
- .eq('id', String(id))
196
- .update(data as Record<string, unknown>);
211
+ let builder = this.client.from(resource).eq('id', String(id));
212
+ if (params?.deleted) builder = builder.deleted(params.deleted);
213
+ const result = await builder.update(data as Record<string, unknown>);
197
214
 
198
215
  if (result.error) {
199
216
  throw new Error(result.error.message);
200
217
  }
201
218
 
202
219
  const rows = (result.data ?? []) as T[];
203
- if (rows.length === 0) throw new Error(ZERO_ROW_UPDATE);
220
+ if (rows.length === 0) {
221
+ // A restore that matched nothing: the row is not deleted, or not there.
222
+ throw new Error(params?.deleted ? `${resource}/${id} not found or not changed` : ZERO_ROW_UPDATE);
223
+ }
204
224
  return rows[0];
205
225
  }
206
226
 
@@ -258,6 +278,7 @@ export class EverystackAdapter implements DataAdapter {
258
278
  }
259
279
 
260
280
  let builder = this.client.from(resource).select(selectParts.join(','));
281
+ if (params.deleted) builder = builder.deleted(params.deleted);
261
282
 
262
283
  // Apply filters, then the search group beside them (the same rules as a list)
263
284
  builder = applyFilters(builder, params.filter);
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import type {
9
+ DeletedMode,
9
10
  ListParams,
10
11
  ListResult,
11
12
  DataRecord,
@@ -30,9 +31,15 @@ export interface DataAdapter {
30
31
  ): Promise<ListResult<T>>;
31
32
 
32
33
  /**
33
- * Fetch a single record by ID
34
+ * Fetch a single record by ID. `params.select` is a PostgREST select string,
35
+ * embeds included. EverystackAdapter ignores it on the crudRpcs branch.
36
+ * `params.deleted` reads a soft-deleted row (see ListParams.deleted).
34
37
  */
35
- fetchOne<T extends DataRecord = DataRecord>(resource: string, id: string | number): Promise<T>;
38
+ fetchOne<T extends DataRecord = DataRecord>(
39
+ resource: string,
40
+ id: string | number,
41
+ params?: { select?: string; deleted?: DeletedMode }
42
+ ): Promise<T>;
36
43
 
37
44
  /**
38
45
  * Create a new record
@@ -40,12 +47,14 @@ export interface DataAdapter {
40
47
  create<T extends DataRecord = DataRecord>(resource: string, data: Omit<T, 'id'>): Promise<T>;
41
48
 
42
49
  /**
43
- * Update an existing record
50
+ * Update an existing record. `params.deleted` lets the write match a
51
+ * soft-deleted row: Restore sends `{ [column]: null }` with `{ deleted: 'only' }`.
44
52
  */
45
53
  update<T extends DataRecord = DataRecord>(
46
54
  resource: string,
47
55
  id: string | number,
48
- data: Partial<T>
56
+ data: Partial<T>,
57
+ params?: { deleted?: DeletedMode }
49
58
  ): Promise<T>;
50
59
 
51
60
  /**
@@ -58,6 +67,8 @@ export interface DataAdapter {
58
67
  * EverystackAdapter queries the table named after the resource; unlike its
59
68
  * fetchList it has no listRpcs path. For a resource whose name is not a
60
69
  * table, override aggregate() together with fetchList().
70
+ * Honour `params.deleted`: an aggregate that ignores it counts live rows on
71
+ * a Deleted tab, and the admin cannot tell.
61
72
  */
62
73
  aggregate?(resource: string, params: AggregateParams): Promise<AggregateResult>;
63
74
 
@@ -0,0 +1,59 @@
1
+ /**
2
+ * AdminLink — a link to an admin screen of another resource.
3
+ *
4
+ * The href comes from adminHref, the same builder AdminRouter uses. With no
5
+ * AdminNavigationContext, or a resource the admin does not configure, it
6
+ * renders the children as plain Text: there is nowhere to go.
7
+ *
8
+ * The link claims the press, so a press in a list row does not also fire the
9
+ * row's onRowPress. `href` lets react-native-web render an anchor.
10
+ */
11
+
12
+ import React, { type ReactNode } from 'react';
13
+ import { Pressable, Text } from 'react-native';
14
+ import { useAdminNavigation } from '../hooks/useAdminNavigation';
15
+ import { adminHref } from '../utils/admin-routing';
16
+
17
+ export interface AdminLinkProps {
18
+ to: { resource: string; id?: string | number; action?: 'show' | 'edit' };
19
+ children: ReactNode;
20
+ numberOfLines?: number;
21
+ }
22
+
23
+ interface PressEvent {
24
+ stopPropagation?: () => void;
25
+ preventDefault?: () => void;
26
+ metaKey?: boolean;
27
+ ctrlKey?: boolean;
28
+ shiftKey?: boolean;
29
+ }
30
+
31
+ export function AdminLink({ to, children, numberOfLines }: AdminLinkProps): React.ReactElement {
32
+ const navigation = useAdminNavigation();
33
+ if (!navigation || !navigation.resources.includes(to.resource)) {
34
+ return <Text className="text-sm text-gray-900" numberOfLines={numberOfLines}>{children}</Text>;
35
+ }
36
+ const href = adminHref(navigation.basePath, {
37
+ resource: to.resource,
38
+ id: to.id,
39
+ action: to.action === 'edit' ? 'edit' : undefined,
40
+ });
41
+ // react-native's Pressable types have no `href`; react-native-web reads it.
42
+ const anchor = { href } as Record<string, unknown>;
43
+ return (
44
+ <Pressable
45
+ accessibilityRole="link"
46
+ {...anchor}
47
+ onPress={(e?: PressEvent) => {
48
+ e?.stopPropagation?.();
49
+ // A modifier click is the browser's: the anchor opens a new tab.
50
+ if (e?.metaKey || e?.ctrlKey || e?.shiftKey) return;
51
+ // The anchor would load the page itself on web; the router navigates instead.
52
+ e?.preventDefault?.();
53
+ navigation.push(href);
54
+ }}
55
+ >
56
+ <Text className="text-sm text-blue-600 underline" numberOfLines={numberOfLines}>{children}</Text>
57
+ </Pressable>
58
+ );
59
+ }
@@ -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,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
+ }
@@ -126,6 +126,8 @@ export function useAdminData<T extends DataRecord = DataRecord>(
126
126
  filter,
127
127
  search: options.search ?? null,
128
128
  searchFields: options.searchFields ?? null,
129
+ select: options.select ?? null,
130
+ deleted: options.deleted ?? null,
129
131
  });
130
132
  const reqKey = `${paramsKey}#${refetchIndex}`;
131
133
 
@@ -165,6 +167,8 @@ export function useAdminData<T extends DataRecord = DataRecord>(
165
167
  filter: { ...(options.baseFilter ?? {}), ...filter },
166
168
  ...(options.search !== undefined ? { search: options.search } : {}),
167
169
  ...(options.searchFields !== undefined ? { searchFields: options.searchFields } : {}),
170
+ ...(options.select ? { select: options.select } : {}),
171
+ ...(options.deleted ? { deleted: options.deleted } : {}),
168
172
  })
169
173
  .then(result => {
170
174
  if (seq !== seqRef.current) return;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * AdminNavigationContext — what an admin link needs to navigate: the router's
3
+ * push, the admin's mount point, and the configured resource names (a link
4
+ * to any other resource renders as plain text). AdminRouter provides it.
5
+ */
6
+
7
+ import { createContext, useContext } from 'react';
8
+
9
+ export interface AdminNavigationContextValue {
10
+ push(href: string): void;
11
+ basePath: string;
12
+ resources: string[];
13
+ }
14
+
15
+ export const AdminNavigationContext = createContext<AdminNavigationContextValue | null>(null);
16
+
17
+ export function useAdminNavigation(): AdminNavigationContextValue | null {
18
+ return useContext(AdminNavigationContext);
19
+ }
@@ -19,6 +19,8 @@ export interface UseListQueryResult {
19
19
  export interface UseListQueryOptions {
20
20
  /** The resource's page size. A query at this size leaves `size` out of the URL. */
21
21
  defaultPerPage?: number;
22
+ /** The resource's default scope id. A query on this scope leaves `tab` out of the URL. */
23
+ defaultScope?: string;
22
24
  }
23
25
 
24
26
  export function useListQuery(options: UseListQueryOptions = {}): UseListQueryResult {
@@ -31,13 +33,13 @@ export function useListQuery(options: UseListQueryOptions = {}): UseListQueryRes
31
33
 
32
34
  // `update` is stable but must read the newest query: a debounce timer or an
33
35
  // event handler may call it from a render that is already out of date.
34
- const latest = useRef({ query, setParams: view?.setParams, defaultPerPage: options.defaultPerPage });
35
- latest.current = { query, setParams: view?.setParams, defaultPerPage: options.defaultPerPage };
36
+ const latest = useRef({ query, setParams: view?.setParams, defaultPerPage: options.defaultPerPage, defaultScope: options.defaultScope });
37
+ latest.current = { query, setParams: view?.setParams, defaultPerPage: options.defaultPerPage, defaultScope: options.defaultScope };
36
38
 
37
39
  const update = useCallback((patch: Partial<ListQuery>) => {
38
- const { query: current, setParams, defaultPerPage } = latest.current;
40
+ const { query: current, setParams, defaultPerPage, defaultScope } = latest.current;
39
41
  const next = updateListQuery(current, patch);
40
- if (setParams) setParams(listQueryToParams(next, { defaultPerPage }));
42
+ if (setParams) setParams(listQueryToParams(next, { defaultPerPage, defaultScope }));
41
43
  else setLocal(next);
42
44
  }, []);
43
45