@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.
- package/CHANGELOG.md +96 -1
- package/package.json +46 -12
- package/src/adapters/EverystackAdapter.ts +127 -12
- package/src/adapters/types.ts +22 -5
- package/src/components/AdminLink.tsx +59 -0
- package/src/components/CellText.tsx +18 -0
- package/src/components/DeletePreview.tsx +109 -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/RelationFieldInput.tsx +159 -0
- package/src/components/RelationValue.tsx +26 -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 +108 -24
- package/src/hooks/useAdminNavigation.ts +19 -0
- package/src/hooks/useListQuery.ts +47 -0
- package/src/hooks/useRelationLabels.ts +131 -0
- package/src/hooks/useScopeCounts.ts +77 -0
- package/src/hooks/useScrollToFirstError.ts +74 -0
- package/src/index.ts +17 -0
- package/src/screens/AdminRouter.tsx +30 -4
- package/src/screens/ResourceCreate.tsx +87 -37
- package/src/screens/ResourceEdit.tsx +93 -42
- package/src/screens/ResourceList.tsx +241 -53
- package/src/screens/ResourceShow.tsx +130 -22
- package/src/types.ts +197 -4
- package/src/utils/format-cell-value.ts +5 -0
- package/src/utils/list-query.ts +202 -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 +65 -0
- package/src/utils/scopes.ts +38 -0
- package/src/utils/standard-actions.ts +41 -5
- package/src/utils/tags.ts +40 -0
- 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
|
-
## [
|
|
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.
|
|
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.
|
|
53
|
-
"@everystack/ui": ">=0.
|
|
54
|
-
"@tiptap/
|
|
55
|
-
"@tiptap/
|
|
56
|
-
"@tiptap/
|
|
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/
|
|
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.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
|
-
|
|
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
|
|
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, {
|
|
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
|
-
|
|
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, {
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
*/
|
package/src/adapters/types.ts
CHANGED
|
@@ -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>(
|
|
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
|
+
}
|