@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 +73 -1
- package/package.json +45 -11
- package/src/adapters/EverystackAdapter.ts +107 -4
- package/src/adapters/types.ts +7 -1
- package/src/components/CellText.tsx +18 -0
- package/src/components/FieldColumns.tsx +86 -0
- package/src/components/FormActionBar.tsx +45 -0
- package/src/components/InlineEnumCell.tsx +98 -0
- package/src/components/RowActionButton.tsx +24 -9
- package/src/components/TagsDisplay.tsx +22 -0
- package/src/components/TagsFieldInput.tsx +95 -0
- package/src/editors/rich-html-extensions.ts +63 -0
- package/src/editors/tiptap.tsx +121 -20
- package/src/editors/unsupported-markup.ts +150 -0
- package/src/hooks/useAdminData.ts +104 -24
- package/src/hooks/useListQuery.ts +45 -0
- package/src/hooks/useScopeCounts.ts +72 -0
- package/src/hooks/useScrollToFirstError.ts +74 -0
- package/src/index.ts +7 -0
- package/src/screens/AdminRouter.tsx +11 -0
- package/src/screens/ResourceCreate.tsx +74 -37
- package/src/screens/ResourceEdit.tsx +77 -40
- package/src/screens/ResourceList.tsx +204 -52
- package/src/screens/ResourceShow.tsx +57 -9
- package/src/types.ts +140 -4
- package/src/utils/format-cell-value.ts +5 -0
- package/src/utils/list-query.ts +199 -0
- package/src/utils/required.ts +10 -0
- package/src/utils/resolve-columns.ts +107 -0
- package/src/utils/row-actions.ts +28 -0
- package/src/utils/scope-counts.ts +63 -0
- package/src/utils/scopes.ts +38 -0
- package/src/utils/tags.ts +40 -0
- package/src/utils/view-filters.ts +1 -1
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
|
-
## [
|
|
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.
|
|
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.
|
|
54
|
-
"@tiptap/
|
|
55
|
-
"@tiptap/
|
|
56
|
-
"@tiptap/
|
|
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/
|
|
73
|
-
"@tiptap/
|
|
74
|
-
"@tiptap/
|
|
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.
|
|
87
|
-
"@everystack/ui": "0.4.
|
|
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
|
|
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
|
*/
|
package/src/adapters/types.ts
CHANGED
|
@@ -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
|
+
}
|