@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
package/CHANGELOG.md CHANGED
@@ -4,16 +4,111 @@ 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
- ## [Unreleased]
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
+
30
+ ## [0.4.3] - 2026-10-14
31
+
32
+ ### Added
33
+
34
+ - list query model: search, scope, sort and page as one URL state
35
+ - adapter applies ListParams.search across searchFields
36
+ - list search, sort and page live in the URL; search all fields after a pause
37
+ - column headers sort ascending, descending, then off, with an arrow
38
+ - scope tabs: named filters above the list, kept in the URL
39
+ - opt-in scope tab counts; one grouped query for an enum's tabs
40
+ - detect unsupported rich-text markup
41
+ - preserve rich HTML in the stock editor schema
42
+ - guard rich HTML against visual data loss
43
+ - list page size from resource config, kept in the URL as size
44
+ - fixed list column width
45
+ - Save/Cancel also at the bottom of Edit and Create
46
+ - tags field type with grouped pills, and form values on InputContext
47
+ - resolveColumns and FieldColumns for screen layouts
48
+ - Show, Edit and Create in columns, and scroll to the first error
49
+ - listPriority and listHideBelow on fields; Actions is always kept
50
+ - inline editable enum cell (inlineEdit on an enum field)
51
+
52
+ ### Fixed
53
+
54
+ - adapter sends not.* and or/and filters instead of eq
55
+ - list state updates the URL in place; the search box keeps focus
56
+ - list URL names tab and dir; review-gate fixes
57
+ - preserve search across RPC and custom adapters
58
+ - detect HTML5 markup without silent misses
59
+ - declare parse5 as a test dependency
60
+ - keep links in inline code and code blocks
61
+ - a resource perPage that is not a whole number above 0 falls back to 20
62
+ - size the Actions column from its button labels
63
+ - export ./package.json
64
+ - render field.input as its own component
65
+ - wrap a bare string or number from field.render in the cell Text
66
+ - scroll to the topmost errored field on screen
67
+ - size the Actions column from idle labels and let the buttons wrap
68
+ - scroll to the first error after the errors render
69
+ - inlineEdit follows actions.edit
70
+ - the inline enum cell refuses a double save; pin the value after a save
71
+ - refuse an update or delete by id that matches zero rows
72
+ - refetch resolves after its fetch lands; add refreshing
73
+ - keep the list table mounted through a refetch
74
+ - refetch after unmount resolves; a newer pick wins over an older save's re-sync
75
+ - refetch after a failed row action, keeping its error
76
+ - the detail screen keeps a failed row action's error through the refetch
77
+ - ResourceList keys table rows by the resource's primaryKey
78
+ - ResourceShow keeps only the latest fetch's record, error and loading
79
+ - a stale-id refetch cannot write under a newer id; row keys cannot collide with index fallbacks
80
+ - ResourceShow sets the current id when it commits and drops the dead post-await id check
81
+ - the inline enum cell stays busy until its refresh lands
82
+
83
+ ### Documentation
84
+
85
+ - aggregate with fetchList, rowActions in the list, navigate, field.input as a component
86
+ - column priority notes: never-dropping fields, hideBelow exception, identifying field, sort arrow
87
+ - search, sort, scope tabs, tab counts and the list's URL params
88
+ - FormActionBar, perPage limits, controlled setPerPage, label-based Actions width
89
+ - document TagsFieldInput and its props
90
+
91
+ ## [0.4.2] - 2026-09-22
92
+
93
+ ### Fixed
94
+
95
+ - refresh expired JWT instead of silently failing every request
96
+
97
+ ## [0.4.1] - 2026-09-22
8
98
 
9
99
  ### Added
10
100
 
11
101
  - auto-generated changelogs from conventional commits
12
102
  - automate the version-drift check — a manual note is not a gate
103
+ - wire date/datetime field types to DateInput
13
104
 
14
105
  ### Fixed
15
106
 
16
107
  - the test suite compiled with type checking off
108
+ - twitter preset field names must match the API's camelCase wire format
109
+ - date fields no longer shift a day in UTC-negative timezones on List/Show
110
+ - DateInput crash on malformed data; restore lost validation in date formatters
111
+ - DateInput/admin date-value edge cases from final review pass
17
112
 
18
113
  ## [0.4.0] - 2026-07-19
19
114
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/admin",
3
- "version": "0.4.2",
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>",
@@ -41,7 +41,8 @@
41
41
  "source": "./src/editors/tiptap.tsx",
42
42
  "types": "./src/editors/tiptap.tsx",
43
43
  "default": "./src/editors/tiptap.tsx"
44
- }
44
+ },
45
+ "./package.json": "./package.json"
45
46
  },
46
47
  "dependencies": {
47
48
  "d3-scale": "4.0.2",
@@ -49,15 +50,39 @@
49
50
  },
50
51
  "peerDependencies": {
51
52
  "drizzle-orm": "0.41.0",
52
- "@everystack/api": ">=0.2.4",
53
- "@everystack/ui": ">=0.2.2",
54
- "@tiptap/pm": ">=2.0.0",
55
- "@tiptap/react": ">=2.0.0",
56
- "@tiptap/starter-kit": ">=2.0.0",
53
+ "@everystack/api": ">=0.4.4",
54
+ "@everystack/ui": ">=0.4.2",
55
+ "@tiptap/extension-code": ">=3.0.0",
56
+ "@tiptap/extension-code-block": ">=3.0.0",
57
+ "@tiptap/extension-image": ">=3.0.0",
58
+ "@tiptap/extension-subscript": ">=3.0.0",
59
+ "@tiptap/extension-superscript": ">=3.0.0",
60
+ "@tiptap/extension-table": ">=3.0.0",
61
+ "@tiptap/pm": ">=3.0.0",
62
+ "@tiptap/react": ">=3.0.0",
63
+ "@tiptap/starter-kit": ">=3.0.0",
57
64
  "react": "19.2.0",
58
65
  "react-native": "0.83.6"
59
66
  },
60
67
  "peerDependenciesMeta": {
68
+ "@tiptap/extension-code": {
69
+ "optional": true
70
+ },
71
+ "@tiptap/extension-code-block": {
72
+ "optional": true
73
+ },
74
+ "@tiptap/extension-image": {
75
+ "optional": true
76
+ },
77
+ "@tiptap/extension-subscript": {
78
+ "optional": true
79
+ },
80
+ "@tiptap/extension-superscript": {
81
+ "optional": true
82
+ },
83
+ "@tiptap/extension-table": {
84
+ "optional": true
85
+ },
61
86
  "@tiptap/pm": {
62
87
  "optional": true
63
88
  },
@@ -69,22 +94,31 @@
69
94
  }
70
95
  },
71
96
  "devDependencies": {
72
- "@tiptap/pm": "^2.0.0",
73
- "@tiptap/react": "^2.0.0",
74
- "@tiptap/starter-kit": "^2.0.0",
97
+ "@tiptap/extension-code": "^3.0.0",
98
+ "@tiptap/extension-code-block": "^3.0.0",
99
+ "@tiptap/extension-image": "^3.0.0",
100
+ "@tiptap/extension-subscript": "^3.0.0",
101
+ "@tiptap/extension-superscript": "^3.0.0",
102
+ "@tiptap/extension-table": "^3.0.0",
103
+ "@tiptap/pm": "^3.0.0",
104
+ "@tiptap/react": "^3.0.0",
105
+ "@tiptap/starter-kit": "^3.0.0",
75
106
  "@types/d3-scale": "4.0.9",
76
107
  "@types/d3-time-format": "4.0.3",
77
108
  "@types/jest": "29.5.14",
78
109
  "@types/node": "22.19.18",
79
110
  "@types/react": "19.2.14",
80
111
  "jest": "29.7.0",
112
+ "jest-environment-jsdom": "29.7.0",
113
+ "jsdom": "^20.0.3",
114
+ "parse5": "^7.3.0",
81
115
  "react": "19.2.0",
82
116
  "react-native": "0.83.6",
83
117
  "react-test-renderer": "19.2.0",
84
118
  "ts-jest": "29.4.9",
85
119
  "typescript": "5.9.3",
86
- "@everystack/api": "0.4.2",
87
- "@everystack/ui": "0.4.1"
120
+ "@everystack/api": "0.4.4",
121
+ "@everystack/ui": "0.4.3"
88
122
  },
89
123
  "scripts": {
90
124
  "test": "jest",
@@ -7,8 +7,10 @@
7
7
 
8
8
  import { createClient, type Client, type ClientOptions, type QueryBuilder } from '@everystack/api/client';
9
9
  import { toCamelCaseKeys } from '../utils/naming';
10
+ import { mergeFilters, normalizeSearchText } from '../utils/list-query';
10
11
  import type { DataAdapter } from './types';
11
12
  import type {
13
+ DeletedMode,
12
14
  ListParams,
13
15
  ListResult,
14
16
  DataRecord,
@@ -23,6 +25,14 @@ const POSTGREST_OPERATORS = new Set([
23
25
  'sl', 'sr', 'nxl', 'nxr', 'adj', 'ov', 'fts',
24
26
  ]);
25
27
 
28
+ /**
29
+ * The handler answers 200 [] when a write matches no row, whether the row is gone or a policy
30
+ * hides it. A write by one id that touched nothing failed, so update and delete refuse it with
31
+ * one message for both causes: naming the cause would tell a caller whether the row exists.
32
+ */
33
+ const ZERO_ROW_UPDATE = 'No row was updated. It may not exist, or you may not have permission.';
34
+ const ZERO_ROW_DELETE = 'No row was deleted. It may not exist, or you may not have permission.';
35
+
26
36
  /**
27
37
  * `onTokenExpired` and `onAuthFailure` (inherited from `ClientOptions`) are how
28
38
  * the admin survives a session longer than the access token's TTL. Without
@@ -53,7 +63,8 @@ export interface EverystackAdapterOptions extends ClientOptions {
53
63
  }
54
64
 
55
65
  export class EverystackAdapter implements DataAdapter {
56
- private client: Client;
66
+ readonly supportsSearch = true;
67
+ protected readonly client: Client;
57
68
  private listRpcs: Record<string, string>;
58
69
  private crudRpcs: Record<string, string>;
59
70
 
@@ -74,6 +85,12 @@ export class EverystackAdapter implements DataAdapter {
74
85
  // RPC-backed list (e.g. DB-free observability tables over the S3 archive).
75
86
  const listRpc = this.listRpcs[resource];
76
87
  if (listRpc) {
88
+ const text = normalizeSearchText(params.search);
89
+ const field = params.searchFields?.[0];
90
+ // RPC lists keep the first-field filter protocol; escape wildcard text for the S3 matcher.
91
+ const filter = text && field
92
+ ? mergeFilters(params.filter, { [field]: `ilike.*${text.replace(/[\\*%]/g, c => `\\${c}`)}*` })
93
+ : params.filter;
77
94
  // Accept either the S3 list shape ({ data, total }) or the dashboard eventList shape
78
95
  // ({ rows, total }) so a resource can be served by either kind of RPC.
79
96
  const result = await this.rpc<{ data?: T[]; rows?: T[]; total: number }>(listRpc, {
@@ -81,7 +98,8 @@ export class EverystackAdapter implements DataAdapter {
81
98
  offset,
82
99
  sort: params.sort,
83
100
  dir: params.order ?? 'asc',
84
- filter: params.filter,
101
+ filter,
102
+ ...(params.deleted ? { deleted: params.deleted } : {}),
85
103
  });
86
104
  // Normalize row keys to camelCase so the list columns resolve regardless of whether
87
105
  // the RPC returns snake_case (e.g. jobsEventList over ops.runs) or camelCase (S3 lists,
@@ -96,9 +114,12 @@ export class EverystackAdapter implements DataAdapter {
96
114
  }
97
115
 
98
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);
99
119
 
100
- // Apply filters
120
+ // Apply filters, then the search group beside them
101
121
  builder = applyFilters(builder, params.filter);
122
+ builder = applySearch(builder, params);
102
123
 
103
124
  // Apply sort
104
125
  if (params.sort) {
@@ -125,16 +146,24 @@ export class EverystackAdapter implements DataAdapter {
125
146
  async fetchOne<T extends DataRecord = DataRecord>(
126
147
  resource: string,
127
148
  id: string | number,
149
+ params?: { select?: string; deleted?: DeletedMode },
128
150
  ): Promise<T> {
129
151
  // RPC-backed single read (e.g. DB-free alert rules from the S3 config object).
130
152
  const crudRpc = this.crudRpcs[resource];
131
153
  if (crudRpc) {
132
- 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
+ });
133
159
  if (!record) throw new Error(`${resource}/${id} not found`);
134
160
  return record;
135
161
  }
136
162
 
137
- 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();
138
167
 
139
168
  if (result.error) {
140
169
  throw new Error(result.error.status === 406
@@ -167,22 +196,31 @@ export class EverystackAdapter implements DataAdapter {
167
196
  resource: string,
168
197
  id: string | number,
169
198
  data: Partial<T>,
199
+ params?: { deleted?: DeletedMode },
170
200
  ): Promise<T> {
171
201
  const crudRpc = this.crudRpcs[resource];
172
202
  if (crudRpc) {
173
- 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
+ }));
174
209
  }
175
210
 
176
- const result = await this.client
177
- .from(resource)
178
- .eq('id', String(id))
179
- .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>);
180
214
 
181
215
  if (result.error) {
182
216
  throw new Error(result.error.message);
183
217
  }
184
218
 
185
- const rows = result.data as T[];
219
+ const rows = (result.data ?? []) as T[];
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
+ }
186
224
  return rows[0];
187
225
  }
188
226
 
@@ -198,6 +236,8 @@ export class EverystackAdapter implements DataAdapter {
198
236
  if (result.error) {
199
237
  throw new Error(result.error.message);
200
238
  }
239
+
240
+ if ((result.data ?? []).length === 0) throw new Error(ZERO_ROW_DELETE);
201
241
  }
202
242
 
203
243
  /** Unwrap an action RPC result, surfacing a `{ error }` payload as a throw. */
@@ -238,9 +278,11 @@ export class EverystackAdapter implements DataAdapter {
238
278
  }
239
279
 
240
280
  let builder = this.client.from(resource).select(selectParts.join(','));
281
+ if (params.deleted) builder = builder.deleted(params.deleted);
241
282
 
242
- // Apply filters
283
+ // Apply filters, then the search group beside them (the same rules as a list)
243
284
  builder = applyFilters(builder, params.filter);
285
+ builder = applySearch(builder, params);
244
286
 
245
287
  const result = await builder.execute();
246
288
 
@@ -268,6 +310,15 @@ function applyFilters(builder: QueryBuilder, filter?: Record<string, unknown>):
268
310
 
269
311
  for (const [key, value] of Object.entries(filter)) {
270
312
  if (value == null) continue;
313
+ // `or` / `and` are PostgREST logical groups, not columns. The handler reads
314
+ // one of each, so a second expression would silently replace the first.
315
+ if (key === 'or' || key === 'and') {
316
+ if (typeof value !== 'string') {
317
+ throw new Error(`filter "${key}" takes one expression string, e.g. "(a.eq.1,b.eq.2)"`);
318
+ }
319
+ builder = builder[key](stripOuterParens(value));
320
+ continue;
321
+ }
271
322
  // An array applies several conditions to the same column (PostgREST ANDs
272
323
  // them) — e.g. a time range as ['gte.<from>', 'lte.<to>'].
273
324
  if (Array.isArray(value)) {
@@ -293,6 +344,8 @@ function applyOneFilter(builder: QueryBuilder, key: string, value: unknown): Que
293
344
  const op = strValue.substring(0, dotIndex);
294
345
  const val = strValue.substring(dotIndex + 1);
295
346
 
347
+ if (op === 'not') return applyNegatedFilter(builder, key, val, strValue);
348
+
296
349
  switch (op) {
297
350
  case 'eq': return builder.eq(key, val);
298
351
  case 'neq': return builder.neq(key, val);
@@ -312,6 +365,68 @@ function applyOneFilter(builder: QueryBuilder, key: string, value: unknown): Que
312
365
  }
313
366
  }
314
367
 
368
+ /**
369
+ * Apply `not.<op>.<value>`. `rest` is everything after `not.`; `whole` is the
370
+ * full original value, used for the same eq fallback an unknown operator gets.
371
+ */
372
+ function applyNegatedFilter(builder: QueryBuilder, key: string, rest: string, whole: string): QueryBuilder {
373
+ const dotIndex = rest.indexOf('.');
374
+ const op = dotIndex < 0 ? rest : rest.substring(0, dotIndex);
375
+ const val = dotIndex < 0 ? '' : rest.substring(dotIndex + 1);
376
+
377
+ switch (op) {
378
+ case 'eq': return builder.notEq(key, val);
379
+ case 'neq': return builder.notNeq(key, val);
380
+ case 'gt': return builder.notGt(key, val);
381
+ case 'gte': return builder.notGte(key, val);
382
+ case 'lt': return builder.notLt(key, val);
383
+ case 'lte': return builder.notLte(key, val);
384
+ case 'like': return builder.notLike(key, val);
385
+ case 'ilike': return builder.notIlike(key, val);
386
+ case 'is': return builder.notIs(key, val);
387
+ case 'in': return builder.notIn(key, val.replace(/^\(/, '').replace(/\)$/, '').split(','));
388
+ default:
389
+ return builder.eq(key, whole);
390
+ }
391
+ }
392
+
393
+ /** Plain column names only: a search field is spliced into an `or=(...)` expression. */
394
+ const SEARCH_FIELD = /^[A-Za-z_][A-Za-z0-9_]*$/;
395
+
396
+ /**
397
+ * Match `params.search` in any of `params.searchFields`, as one `or()` group.
398
+ *
399
+ * The handler splits an `or` group on every comma and has no quoting, so user
400
+ * text must not carry `,` `(` `)`. `\` `%` `_` are escaped so they match
401
+ * literally; the structural characters and a typed `*` (the handler turns every
402
+ * `*` into `%`) become `_`, the one-character wildcard, so a stored value that
403
+ * contains them ("Alpha, Beta") still matches.
404
+ */
405
+ function applySearch(builder: QueryBuilder, params: Pick<ListParams, 'filter' | 'search' | 'searchFields'>): QueryBuilder {
406
+ const fields = params.searchFields ?? [];
407
+ for (const field of fields) {
408
+ if (!SEARCH_FIELD.test(field)) throw new Error(`invalid search field "${field}": plain column names only`);
409
+ }
410
+
411
+ const text = normalizeSearchText(params.search);
412
+ if (!text) return builder;
413
+
414
+ if (fields.length === 0) {
415
+ throw new Error('ListParams.search needs ListParams.searchFields (the columns to look in)');
416
+ }
417
+ if (params.filter?.or != null) {
418
+ throw new Error('search cannot be combined with an "or" filter: the handler reads one or group');
419
+ }
420
+
421
+ const pattern = `*${text.replace(/[\\%_]/g, c => `\\${c}`).replace(/[,()*]/g, '_')}*`;
422
+ return builder.or(fields.map(field => `${field}.ilike.${pattern}`).join(','));
423
+ }
424
+
425
+ /** `(a.eq.1,b.eq.2)` → `a.eq.1,b.eq.2`; the client builder adds its own parentheses. */
426
+ function stripOuterParens(expression: string): string {
427
+ return expression.startsWith('(') && expression.endsWith(')') ? expression.slice(1, -1) : expression;
428
+ }
429
+
315
430
  /**
316
431
  * Check if a filter value is already in PostgREST format (operator.value).
317
432
  */
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import type {
9
+ DeletedMode,
9
10
  ListParams,
10
11
  ListResult,
11
12
  DataRecord,
@@ -18,6 +19,9 @@ import type {
18
19
  // =============================================================================
19
20
 
20
21
  export interface DataAdapter {
22
+ /** Handles search + searchFields in both list and aggregate params. Otherwise the UI uses a first-field filter. */
23
+ supportsSearch?: boolean;
24
+
21
25
  /**
22
26
  * Fetch a list of records with pagination, sorting, and filtering
23
27
  */
@@ -27,9 +31,15 @@ export interface DataAdapter {
27
31
  ): Promise<ListResult<T>>;
28
32
 
29
33
  /**
30
- * 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).
31
37
  */
32
- 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>;
33
43
 
34
44
  /**
35
45
  * Create a new record
@@ -37,12 +47,14 @@ export interface DataAdapter {
37
47
  create<T extends DataRecord = DataRecord>(resource: string, data: Omit<T, 'id'>): Promise<T>;
38
48
 
39
49
  /**
40
- * 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' }`.
41
52
  */
42
53
  update<T extends DataRecord = DataRecord>(
43
54
  resource: string,
44
55
  id: string | number,
45
- data: Partial<T>
56
+ data: Partial<T>,
57
+ params?: { deleted?: DeletedMode }
46
58
  ): Promise<T>;
47
59
 
48
60
  /**
@@ -51,7 +63,12 @@ export interface DataAdapter {
51
63
  delete(resource: string, id: string | number): Promise<void>;
52
64
 
53
65
  /**
54
- * Fetch aggregate data (count, sum, avg, min, max) with optional grouping
66
+ * Fetch aggregate data (count, sum, avg, min, max) with optional grouping.
67
+ * EverystackAdapter queries the table named after the resource; unlike its
68
+ * fetchList it has no listRpcs path. For a resource whose name is not a
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.
55
72
  */
56
73
  aggregate?(resource: string, params: AggregateParams): Promise<AggregateResult>;
57
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,18 @@
1
+ /**
2
+ * Wraps a bare string or number from a custom field.render in the default cell
3
+ * Text. react-native cannot render a text node outside <Text>, so a render
4
+ * that returns `42` or `'abc'` would otherwise crash on native. Elements,
5
+ * arrays, null, undefined and booleans pass through untouched.
6
+ */
7
+
8
+ import React, { type ReactNode } from 'react';
9
+ import { Text } from 'react-native';
10
+
11
+ export function wrapBareText(node: ReactNode, numberOfLines?: number): ReactNode {
12
+ if (typeof node !== 'string' && typeof node !== 'number') return node;
13
+ return (
14
+ <Text className="text-sm text-gray-900" numberOfLines={numberOfLines}>
15
+ {node}
16
+ </Text>
17
+ );
18
+ }