@everystack/admin 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,16 +4,88 @@ 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.3] - 2026-10-14
8
+
9
+ ### Added
10
+
11
+ - list query model: search, scope, sort and page as one URL state
12
+ - adapter applies ListParams.search across searchFields
13
+ - list search, sort and page live in the URL; search all fields after a pause
14
+ - column headers sort ascending, descending, then off, with an arrow
15
+ - scope tabs: named filters above the list, kept in the URL
16
+ - opt-in scope tab counts; one grouped query for an enum's tabs
17
+ - detect unsupported rich-text markup
18
+ - preserve rich HTML in the stock editor schema
19
+ - guard rich HTML against visual data loss
20
+ - list page size from resource config, kept in the URL as size
21
+ - fixed list column width
22
+ - Save/Cancel also at the bottom of Edit and Create
23
+ - tags field type with grouped pills, and form values on InputContext
24
+ - resolveColumns and FieldColumns for screen layouts
25
+ - Show, Edit and Create in columns, and scroll to the first error
26
+ - listPriority and listHideBelow on fields; Actions is always kept
27
+ - inline editable enum cell (inlineEdit on an enum field)
28
+
29
+ ### Fixed
30
+
31
+ - adapter sends not.* and or/and filters instead of eq
32
+ - list state updates the URL in place; the search box keeps focus
33
+ - list URL names tab and dir; review-gate fixes
34
+ - preserve search across RPC and custom adapters
35
+ - detect HTML5 markup without silent misses
36
+ - declare parse5 as a test dependency
37
+ - keep links in inline code and code blocks
38
+ - a resource perPage that is not a whole number above 0 falls back to 20
39
+ - size the Actions column from its button labels
40
+ - export ./package.json
41
+ - render field.input as its own component
42
+ - wrap a bare string or number from field.render in the cell Text
43
+ - scroll to the topmost errored field on screen
44
+ - size the Actions column from idle labels and let the buttons wrap
45
+ - scroll to the first error after the errors render
46
+ - inlineEdit follows actions.edit
47
+ - the inline enum cell refuses a double save; pin the value after a save
48
+ - refuse an update or delete by id that matches zero rows
49
+ - refetch resolves after its fetch lands; add refreshing
50
+ - keep the list table mounted through a refetch
51
+ - refetch after unmount resolves; a newer pick wins over an older save's re-sync
52
+ - refetch after a failed row action, keeping its error
53
+ - the detail screen keeps a failed row action's error through the refetch
54
+ - ResourceList keys table rows by the resource's primaryKey
55
+ - ResourceShow keeps only the latest fetch's record, error and loading
56
+ - a stale-id refetch cannot write under a newer id; row keys cannot collide with index fallbacks
57
+ - ResourceShow sets the current id when it commits and drops the dead post-await id check
58
+ - the inline enum cell stays busy until its refresh lands
59
+
60
+ ### Documentation
61
+
62
+ - aggregate with fetchList, rowActions in the list, navigate, field.input as a component
63
+ - column priority notes: never-dropping fields, hideBelow exception, identifying field, sort arrow
64
+ - search, sort, scope tabs, tab counts and the list's URL params
65
+ - FormActionBar, perPage limits, controlled setPerPage, label-based Actions width
66
+ - document TagsFieldInput and its props
67
+
68
+ ## [0.4.2] - 2026-09-22
69
+
70
+ ### Fixed
71
+
72
+ - refresh expired JWT instead of silently failing every request
73
+
74
+ ## [0.4.1] - 2026-09-22
8
75
 
9
76
  ### Added
10
77
 
11
78
  - auto-generated changelogs from conventional commits
12
79
  - automate the version-drift check — a manual note is not a gate
80
+ - wire date/datetime field types to DateInput
13
81
 
14
82
  ### Fixed
15
83
 
16
84
  - the test suite compiled with type checking off
85
+ - twitter preset field names must match the API's camelCase wire format
86
+ - date fields no longer shift a day in UTC-negative timezones on List/Show
87
+ - DateInput crash on malformed data; restore lost validation in date formatters
88
+ - DateInput/admin date-value edge cases from final review pass
17
89
 
18
90
  ## [0.4.0] - 2026-07-19
19
91
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/admin",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
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",
@@ -50,14 +51,38 @@
50
51
  "peerDependencies": {
51
52
  "drizzle-orm": "0.41.0",
52
53
  "@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",
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.1",
87
- "@everystack/ui": "0.4.1"
120
+ "@everystack/api": "0.4.3",
121
+ "@everystack/ui": "0.4.2"
88
122
  },
89
123
  "scripts": {
90
124
  "test": "jest",
@@ -7,6 +7,7 @@
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 {
12
13
  ListParams,
@@ -23,6 +24,23 @@ const POSTGREST_OPERATORS = new Set([
23
24
  'sl', 'sr', 'nxl', 'nxr', 'adj', 'ov', 'fts',
24
25
  ]);
25
26
 
27
+ /**
28
+ * The handler answers 200 [] when a write matches no row, whether the row is gone or a policy
29
+ * hides it. A write by one id that touched nothing failed, so update and delete refuse it with
30
+ * one message for both causes: naming the cause would tell a caller whether the row exists.
31
+ */
32
+ const ZERO_ROW_UPDATE = 'No row was updated. It may not exist, or you may not have permission.';
33
+ const ZERO_ROW_DELETE = 'No row was deleted. It may not exist, or you may not have permission.';
34
+
35
+ /**
36
+ * `onTokenExpired` and `onAuthFailure` (inherited from `ClientOptions`) are how
37
+ * the admin survives a session longer than the access token's TTL. Without
38
+ * `onTokenExpired`, `getToken` keeps handing every request the same dead token
39
+ * forever — the user stays "logged in" while every screen 401s. `onAuthFailure`
40
+ * fires only once refresh itself is unrecoverable (refresh token also
41
+ * expired/revoked) — wire it to sign the user out for real. See
42
+ * `@everystack/auth/api`'s `refreshAuthToken` for a ready-made `onTokenExpired`.
43
+ */
26
44
  export interface EverystackAdapterOptions extends ClientOptions {
27
45
  /**
28
46
  * Resources whose list rows come from an RPC instead of the PostgREST table.
@@ -44,6 +62,7 @@ export interface EverystackAdapterOptions extends ClientOptions {
44
62
  }
45
63
 
46
64
  export class EverystackAdapter implements DataAdapter {
65
+ readonly supportsSearch = true;
47
66
  private client: Client;
48
67
  private listRpcs: Record<string, string>;
49
68
  private crudRpcs: Record<string, string>;
@@ -65,6 +84,12 @@ export class EverystackAdapter implements DataAdapter {
65
84
  // RPC-backed list (e.g. DB-free observability tables over the S3 archive).
66
85
  const listRpc = this.listRpcs[resource];
67
86
  if (listRpc) {
87
+ const text = normalizeSearchText(params.search);
88
+ const field = params.searchFields?.[0];
89
+ // RPC lists keep the first-field filter protocol; escape wildcard text for the S3 matcher.
90
+ const filter = text && field
91
+ ? mergeFilters(params.filter, { [field]: `ilike.*${text.replace(/[\\*%]/g, c => `\\${c}`)}*` })
92
+ : params.filter;
68
93
  // Accept either the S3 list shape ({ data, total }) or the dashboard eventList shape
69
94
  // ({ rows, total }) so a resource can be served by either kind of RPC.
70
95
  const result = await this.rpc<{ data?: T[]; rows?: T[]; total: number }>(listRpc, {
@@ -72,7 +97,7 @@ export class EverystackAdapter implements DataAdapter {
72
97
  offset,
73
98
  sort: params.sort,
74
99
  dir: params.order ?? 'asc',
75
- filter: params.filter,
100
+ filter,
76
101
  });
77
102
  // Normalize row keys to camelCase so the list columns resolve regardless of whether
78
103
  // the RPC returns snake_case (e.g. jobsEventList over ops.runs) or camelCase (S3 lists,
@@ -88,8 +113,9 @@ export class EverystackAdapter implements DataAdapter {
88
113
 
89
114
  let builder = this.client.from(resource);
90
115
 
91
- // Apply filters
116
+ // Apply filters, then the search group beside them
92
117
  builder = applyFilters(builder, params.filter);
118
+ builder = applySearch(builder, params);
93
119
 
94
120
  // Apply sort
95
121
  if (params.sort) {
@@ -173,7 +199,8 @@ export class EverystackAdapter implements DataAdapter {
173
199
  throw new Error(result.error.message);
174
200
  }
175
201
 
176
- const rows = result.data as T[];
202
+ const rows = (result.data ?? []) as T[];
203
+ if (rows.length === 0) throw new Error(ZERO_ROW_UPDATE);
177
204
  return rows[0];
178
205
  }
179
206
 
@@ -189,6 +216,8 @@ export class EverystackAdapter implements DataAdapter {
189
216
  if (result.error) {
190
217
  throw new Error(result.error.message);
191
218
  }
219
+
220
+ if ((result.data ?? []).length === 0) throw new Error(ZERO_ROW_DELETE);
192
221
  }
193
222
 
194
223
  /** Unwrap an action RPC result, surfacing a `{ error }` payload as a throw. */
@@ -230,8 +259,9 @@ export class EverystackAdapter implements DataAdapter {
230
259
 
231
260
  let builder = this.client.from(resource).select(selectParts.join(','));
232
261
 
233
- // Apply filters
262
+ // Apply filters, then the search group beside them (the same rules as a list)
234
263
  builder = applyFilters(builder, params.filter);
264
+ builder = applySearch(builder, params);
235
265
 
236
266
  const result = await builder.execute();
237
267
 
@@ -259,6 +289,15 @@ function applyFilters(builder: QueryBuilder, filter?: Record<string, unknown>):
259
289
 
260
290
  for (const [key, value] of Object.entries(filter)) {
261
291
  if (value == null) continue;
292
+ // `or` / `and` are PostgREST logical groups, not columns. The handler reads
293
+ // one of each, so a second expression would silently replace the first.
294
+ if (key === 'or' || key === 'and') {
295
+ if (typeof value !== 'string') {
296
+ throw new Error(`filter "${key}" takes one expression string, e.g. "(a.eq.1,b.eq.2)"`);
297
+ }
298
+ builder = builder[key](stripOuterParens(value));
299
+ continue;
300
+ }
262
301
  // An array applies several conditions to the same column (PostgREST ANDs
263
302
  // them) — e.g. a time range as ['gte.<from>', 'lte.<to>'].
264
303
  if (Array.isArray(value)) {
@@ -284,6 +323,8 @@ function applyOneFilter(builder: QueryBuilder, key: string, value: unknown): Que
284
323
  const op = strValue.substring(0, dotIndex);
285
324
  const val = strValue.substring(dotIndex + 1);
286
325
 
326
+ if (op === 'not') return applyNegatedFilter(builder, key, val, strValue);
327
+
287
328
  switch (op) {
288
329
  case 'eq': return builder.eq(key, val);
289
330
  case 'neq': return builder.neq(key, val);
@@ -303,6 +344,68 @@ function applyOneFilter(builder: QueryBuilder, key: string, value: unknown): Que
303
344
  }
304
345
  }
305
346
 
347
+ /**
348
+ * Apply `not.<op>.<value>`. `rest` is everything after `not.`; `whole` is the
349
+ * full original value, used for the same eq fallback an unknown operator gets.
350
+ */
351
+ function applyNegatedFilter(builder: QueryBuilder, key: string, rest: string, whole: string): QueryBuilder {
352
+ const dotIndex = rest.indexOf('.');
353
+ const op = dotIndex < 0 ? rest : rest.substring(0, dotIndex);
354
+ const val = dotIndex < 0 ? '' : rest.substring(dotIndex + 1);
355
+
356
+ switch (op) {
357
+ case 'eq': return builder.notEq(key, val);
358
+ case 'neq': return builder.notNeq(key, val);
359
+ case 'gt': return builder.notGt(key, val);
360
+ case 'gte': return builder.notGte(key, val);
361
+ case 'lt': return builder.notLt(key, val);
362
+ case 'lte': return builder.notLte(key, val);
363
+ case 'like': return builder.notLike(key, val);
364
+ case 'ilike': return builder.notIlike(key, val);
365
+ case 'is': return builder.notIs(key, val);
366
+ case 'in': return builder.notIn(key, val.replace(/^\(/, '').replace(/\)$/, '').split(','));
367
+ default:
368
+ return builder.eq(key, whole);
369
+ }
370
+ }
371
+
372
+ /** Plain column names only: a search field is spliced into an `or=(...)` expression. */
373
+ const SEARCH_FIELD = /^[A-Za-z_][A-Za-z0-9_]*$/;
374
+
375
+ /**
376
+ * Match `params.search` in any of `params.searchFields`, as one `or()` group.
377
+ *
378
+ * The handler splits an `or` group on every comma and has no quoting, so user
379
+ * text must not carry `,` `(` `)`. `\` `%` `_` are escaped so they match
380
+ * literally; the structural characters and a typed `*` (the handler turns every
381
+ * `*` into `%`) become `_`, the one-character wildcard, so a stored value that
382
+ * contains them ("Alpha, Beta") still matches.
383
+ */
384
+ function applySearch(builder: QueryBuilder, params: Pick<ListParams, 'filter' | 'search' | 'searchFields'>): QueryBuilder {
385
+ const fields = params.searchFields ?? [];
386
+ for (const field of fields) {
387
+ if (!SEARCH_FIELD.test(field)) throw new Error(`invalid search field "${field}": plain column names only`);
388
+ }
389
+
390
+ const text = normalizeSearchText(params.search);
391
+ if (!text) return builder;
392
+
393
+ if (fields.length === 0) {
394
+ throw new Error('ListParams.search needs ListParams.searchFields (the columns to look in)');
395
+ }
396
+ if (params.filter?.or != null) {
397
+ throw new Error('search cannot be combined with an "or" filter: the handler reads one or group');
398
+ }
399
+
400
+ const pattern = `*${text.replace(/[\\%_]/g, c => `\\${c}`).replace(/[,()*]/g, '_')}*`;
401
+ return builder.or(fields.map(field => `${field}.ilike.${pattern}`).join(','));
402
+ }
403
+
404
+ /** `(a.eq.1,b.eq.2)` → `a.eq.1,b.eq.2`; the client builder adds its own parentheses. */
405
+ function stripOuterParens(expression: string): string {
406
+ return expression.startsWith('(') && expression.endsWith(')') ? expression.slice(1, -1) : expression;
407
+ }
408
+
306
409
  /**
307
410
  * Check if a filter value is already in PostgREST format (operator.value).
308
411
  */
@@ -18,6 +18,9 @@ import type {
18
18
  // =============================================================================
19
19
 
20
20
  export interface DataAdapter {
21
+ /** Handles search + searchFields in both list and aggregate params. Otherwise the UI uses a first-field filter. */
22
+ supportsSearch?: boolean;
23
+
21
24
  /**
22
25
  * Fetch a list of records with pagination, sorting, and filtering
23
26
  */
@@ -51,7 +54,10 @@ export interface DataAdapter {
51
54
  delete(resource: string, id: string | number): Promise<void>;
52
55
 
53
56
  /**
54
- * Fetch aggregate data (count, sum, avg, min, max) with optional grouping
57
+ * Fetch aggregate data (count, sum, avg, min, max) with optional grouping.
58
+ * EverystackAdapter queries the table named after the resource; unlike its
59
+ * fetchList it has no listRpcs path. For a resource whose name is not a
60
+ * table, override aggregate() together with fetchList().
55
61
  */
56
62
  aggregate?(resource: string, params: AggregateParams): Promise<AggregateResult>;
57
63
 
@@ -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
+ }
@@ -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
+ }