@everystack/admin 0.4.3 → 0.4.5
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 +33 -0
- package/package.json +4 -4
- package/src/adapters/EverystackAdapter.ts +30 -9
- package/src/adapters/types.ts +15 -4
- package/src/components/AdminLink.tsx +59 -0
- package/src/components/DeletePreview.tsx +109 -0
- package/src/components/RelationFieldInput.tsx +159 -0
- package/src/components/RelationValue.tsx +26 -0
- package/src/editors/rich-html-extensions.ts +38 -7
- package/src/hooks/useAdminData.ts +4 -0
- package/src/hooks/useAdminNavigation.ts +19 -0
- package/src/hooks/useListQuery.ts +6 -4
- package/src/hooks/useRelationLabels.ts +131 -0
- package/src/hooks/useScopeCounts.ts +7 -2
- package/src/index.ts +10 -0
- package/src/screens/AdminRouter.tsx +19 -4
- package/src/screens/ResourceCreate.tsx +13 -0
- package/src/screens/ResourceEdit.tsx +16 -2
- package/src/screens/ResourceList.tsx +44 -8
- package/src/screens/ResourceShow.tsx +75 -15
- package/src/types.ts +59 -0
- package/src/utils/list-query.ts +7 -4
- package/src/utils/scope-counts.ts +2 -0
- package/src/utils/standard-actions.ts +41 -5
- package/src/utils/view-filters.ts +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,39 @@ All notable changes to `@everystack/admin` are documented here.
|
|
|
4
4
|
|
|
5
5
|
Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|
6
6
|
|
|
7
|
+
## [0.4.5] - 2026-10-15
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- the visual editor keeps data-* attributes on every node and mark
|
|
12
|
+
|
|
13
|
+
### Documentation
|
|
14
|
+
|
|
15
|
+
- InputContext.values is required since 0.4.3
|
|
16
|
+
|
|
17
|
+
## [0.4.4] - 2026-10-15
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- the Show screen renders html and markdown fields as sanitised HTML
|
|
22
|
+
- list and show can embed related rows with ResourceConfig.select
|
|
23
|
+
- relation fields show the related name as a link in the list and on Show
|
|
24
|
+
- relation fields get a searchable picker in the Create and Edit forms
|
|
25
|
+
- soft-deletable resources get a Deleted tab and Restore
|
|
26
|
+
- Delete shows how many rows link to the record before it confirms
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- a dashboard dimension named size, page or sort no longer clashes with view state
|
|
31
|
+
- clicking the default tab clears ?tab, and the tabs are a tablist
|
|
32
|
+
- the delete preview counts by exact id and ignores a second Confirm press
|
|
33
|
+
|
|
34
|
+
### Documentation
|
|
35
|
+
|
|
36
|
+
- the Show screen allowlist, the install step and the known sanitize gap
|
|
37
|
+
- RPC list search covers the first field, counts cover all of them
|
|
38
|
+
- use posts and comments in the Embeds and Association examples
|
|
39
|
+
|
|
7
40
|
## [0.4.3] - 2026-10-14
|
|
8
41
|
|
|
9
42
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/admin",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5",
|
|
4
4
|
"description": "Declarative admin dashboard framework for PostgREST APIs",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
52
|
"drizzle-orm": "0.41.0",
|
|
53
|
-
"@everystack/api": ">=0.
|
|
53
|
+
"@everystack/api": ">=0.4.4",
|
|
54
54
|
"@everystack/ui": ">=0.4.2",
|
|
55
55
|
"@tiptap/extension-code": ">=3.0.0",
|
|
56
56
|
"@tiptap/extension-code-block": ">=3.0.0",
|
|
@@ -117,8 +117,8 @@
|
|
|
117
117
|
"react-test-renderer": "19.2.0",
|
|
118
118
|
"ts-jest": "29.4.9",
|
|
119
119
|
"typescript": "5.9.3",
|
|
120
|
-
"@everystack/
|
|
121
|
-
"@everystack/
|
|
120
|
+
"@everystack/ui": "0.4.4",
|
|
121
|
+
"@everystack/api": "0.4.5"
|
|
122
122
|
},
|
|
123
123
|
"scripts": {
|
|
124
124
|
"test": "jest",
|
|
@@ -10,6 +10,7 @@ import { toCamelCaseKeys } from '../utils/naming';
|
|
|
10
10
|
import { mergeFilters, normalizeSearchText } from '../utils/list-query';
|
|
11
11
|
import type { DataAdapter } from './types';
|
|
12
12
|
import type {
|
|
13
|
+
DeletedMode,
|
|
13
14
|
ListParams,
|
|
14
15
|
ListResult,
|
|
15
16
|
DataRecord,
|
|
@@ -63,7 +64,7 @@ export interface EverystackAdapterOptions extends ClientOptions {
|
|
|
63
64
|
|
|
64
65
|
export class EverystackAdapter implements DataAdapter {
|
|
65
66
|
readonly supportsSearch = true;
|
|
66
|
-
|
|
67
|
+
protected readonly client: Client;
|
|
67
68
|
private listRpcs: Record<string, string>;
|
|
68
69
|
private crudRpcs: Record<string, string>;
|
|
69
70
|
|
|
@@ -98,6 +99,7 @@ export class EverystackAdapter implements DataAdapter {
|
|
|
98
99
|
sort: params.sort,
|
|
99
100
|
dir: params.order ?? 'asc',
|
|
100
101
|
filter,
|
|
102
|
+
...(params.deleted ? { deleted: params.deleted } : {}),
|
|
101
103
|
});
|
|
102
104
|
// Normalize row keys to camelCase so the list columns resolve regardless of whether
|
|
103
105
|
// the RPC returns snake_case (e.g. jobsEventList over ops.runs) or camelCase (S3 lists,
|
|
@@ -112,6 +114,8 @@ export class EverystackAdapter implements DataAdapter {
|
|
|
112
114
|
}
|
|
113
115
|
|
|
114
116
|
let builder = this.client.from(resource);
|
|
117
|
+
if (params.select) builder = builder.select(params.select);
|
|
118
|
+
if (params.deleted) builder = builder.deleted(params.deleted);
|
|
115
119
|
|
|
116
120
|
// Apply filters, then the search group beside them
|
|
117
121
|
builder = applyFilters(builder, params.filter);
|
|
@@ -142,16 +146,24 @@ export class EverystackAdapter implements DataAdapter {
|
|
|
142
146
|
async fetchOne<T extends DataRecord = DataRecord>(
|
|
143
147
|
resource: string,
|
|
144
148
|
id: string | number,
|
|
149
|
+
params?: { select?: string; deleted?: DeletedMode },
|
|
145
150
|
): Promise<T> {
|
|
146
151
|
// RPC-backed single read (e.g. DB-free alert rules from the S3 config object).
|
|
147
152
|
const crudRpc = this.crudRpcs[resource];
|
|
148
153
|
if (crudRpc) {
|
|
149
|
-
const record = await this.rpc<T | null>(crudRpc, {
|
|
154
|
+
const record = await this.rpc<T | null>(crudRpc, {
|
|
155
|
+
action: 'get',
|
|
156
|
+
id: String(id),
|
|
157
|
+
...(params?.deleted ? { deleted: params.deleted } : {}),
|
|
158
|
+
});
|
|
150
159
|
if (!record) throw new Error(`${resource}/${id} not found`);
|
|
151
160
|
return record;
|
|
152
161
|
}
|
|
153
162
|
|
|
154
|
-
|
|
163
|
+
let builder = this.client.from(resource).eq('id', String(id));
|
|
164
|
+
if (params?.select) builder = builder.select(params.select);
|
|
165
|
+
if (params?.deleted) builder = builder.deleted(params.deleted);
|
|
166
|
+
const result = await builder.single();
|
|
155
167
|
|
|
156
168
|
if (result.error) {
|
|
157
169
|
throw new Error(result.error.status === 406
|
|
@@ -184,23 +196,31 @@ export class EverystackAdapter implements DataAdapter {
|
|
|
184
196
|
resource: string,
|
|
185
197
|
id: string | number,
|
|
186
198
|
data: Partial<T>,
|
|
199
|
+
params?: { deleted?: DeletedMode },
|
|
187
200
|
): Promise<T> {
|
|
188
201
|
const crudRpc = this.crudRpcs[resource];
|
|
189
202
|
if (crudRpc) {
|
|
190
|
-
return this.rpcResult<T>(await this.rpc<T | { error: string }>(crudRpc, {
|
|
203
|
+
return this.rpcResult<T>(await this.rpc<T | { error: string }>(crudRpc, {
|
|
204
|
+
action: 'update',
|
|
205
|
+
id: String(id),
|
|
206
|
+
data,
|
|
207
|
+
...(params?.deleted ? { deleted: params.deleted } : {}),
|
|
208
|
+
}));
|
|
191
209
|
}
|
|
192
210
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
.update(data as Record<string, unknown>);
|
|
211
|
+
let builder = this.client.from(resource).eq('id', String(id));
|
|
212
|
+
if (params?.deleted) builder = builder.deleted(params.deleted);
|
|
213
|
+
const result = await builder.update(data as Record<string, unknown>);
|
|
197
214
|
|
|
198
215
|
if (result.error) {
|
|
199
216
|
throw new Error(result.error.message);
|
|
200
217
|
}
|
|
201
218
|
|
|
202
219
|
const rows = (result.data ?? []) as T[];
|
|
203
|
-
if (rows.length === 0)
|
|
220
|
+
if (rows.length === 0) {
|
|
221
|
+
// A restore that matched nothing: the row is not deleted, or not there.
|
|
222
|
+
throw new Error(params?.deleted ? `${resource}/${id} not found or not changed` : ZERO_ROW_UPDATE);
|
|
223
|
+
}
|
|
204
224
|
return rows[0];
|
|
205
225
|
}
|
|
206
226
|
|
|
@@ -258,6 +278,7 @@ export class EverystackAdapter implements DataAdapter {
|
|
|
258
278
|
}
|
|
259
279
|
|
|
260
280
|
let builder = this.client.from(resource).select(selectParts.join(','));
|
|
281
|
+
if (params.deleted) builder = builder.deleted(params.deleted);
|
|
261
282
|
|
|
262
283
|
// Apply filters, then the search group beside them (the same rules as a list)
|
|
263
284
|
builder = applyFilters(builder, params.filter);
|
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,
|
|
@@ -30,9 +31,15 @@ export interface DataAdapter {
|
|
|
30
31
|
): Promise<ListResult<T>>;
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
|
-
* Fetch a single record by ID
|
|
34
|
+
* Fetch a single record by ID. `params.select` is a PostgREST select string,
|
|
35
|
+
* embeds included. EverystackAdapter ignores it on the crudRpcs branch.
|
|
36
|
+
* `params.deleted` reads a soft-deleted row (see ListParams.deleted).
|
|
34
37
|
*/
|
|
35
|
-
fetchOne<T extends DataRecord = DataRecord>(
|
|
38
|
+
fetchOne<T extends DataRecord = DataRecord>(
|
|
39
|
+
resource: string,
|
|
40
|
+
id: string | number,
|
|
41
|
+
params?: { select?: string; deleted?: DeletedMode }
|
|
42
|
+
): Promise<T>;
|
|
36
43
|
|
|
37
44
|
/**
|
|
38
45
|
* Create a new record
|
|
@@ -40,12 +47,14 @@ export interface DataAdapter {
|
|
|
40
47
|
create<T extends DataRecord = DataRecord>(resource: string, data: Omit<T, 'id'>): Promise<T>;
|
|
41
48
|
|
|
42
49
|
/**
|
|
43
|
-
* Update an existing record
|
|
50
|
+
* Update an existing record. `params.deleted` lets the write match a
|
|
51
|
+
* soft-deleted row: Restore sends `{ [column]: null }` with `{ deleted: 'only' }`.
|
|
44
52
|
*/
|
|
45
53
|
update<T extends DataRecord = DataRecord>(
|
|
46
54
|
resource: string,
|
|
47
55
|
id: string | number,
|
|
48
|
-
data: Partial<T
|
|
56
|
+
data: Partial<T>,
|
|
57
|
+
params?: { deleted?: DeletedMode }
|
|
49
58
|
): Promise<T>;
|
|
50
59
|
|
|
51
60
|
/**
|
|
@@ -58,6 +67,8 @@ export interface DataAdapter {
|
|
|
58
67
|
* EverystackAdapter queries the table named after the resource; unlike its
|
|
59
68
|
* fetchList it has no listRpcs path. For a resource whose name is not a
|
|
60
69
|
* table, override aggregate() together with fetchList().
|
|
70
|
+
* Honour `params.deleted`: an aggregate that ignores it counts live rows on
|
|
71
|
+
* a Deleted tab, and the admin cannot tell.
|
|
61
72
|
*/
|
|
62
73
|
aggregate?(resource: string, params: AggregateParams): Promise<AggregateResult>;
|
|
63
74
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AdminLink — a link to an admin screen of another resource.
|
|
3
|
+
*
|
|
4
|
+
* The href comes from adminHref, the same builder AdminRouter uses. With no
|
|
5
|
+
* AdminNavigationContext, or a resource the admin does not configure, it
|
|
6
|
+
* renders the children as plain Text: there is nowhere to go.
|
|
7
|
+
*
|
|
8
|
+
* The link claims the press, so a press in a list row does not also fire the
|
|
9
|
+
* row's onRowPress. `href` lets react-native-web render an anchor.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import React, { type ReactNode } from 'react';
|
|
13
|
+
import { Pressable, Text } from 'react-native';
|
|
14
|
+
import { useAdminNavigation } from '../hooks/useAdminNavigation';
|
|
15
|
+
import { adminHref } from '../utils/admin-routing';
|
|
16
|
+
|
|
17
|
+
export interface AdminLinkProps {
|
|
18
|
+
to: { resource: string; id?: string | number; action?: 'show' | 'edit' };
|
|
19
|
+
children: ReactNode;
|
|
20
|
+
numberOfLines?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface PressEvent {
|
|
24
|
+
stopPropagation?: () => void;
|
|
25
|
+
preventDefault?: () => void;
|
|
26
|
+
metaKey?: boolean;
|
|
27
|
+
ctrlKey?: boolean;
|
|
28
|
+
shiftKey?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function AdminLink({ to, children, numberOfLines }: AdminLinkProps): React.ReactElement {
|
|
32
|
+
const navigation = useAdminNavigation();
|
|
33
|
+
if (!navigation || !navigation.resources.includes(to.resource)) {
|
|
34
|
+
return <Text className="text-sm text-gray-900" numberOfLines={numberOfLines}>{children}</Text>;
|
|
35
|
+
}
|
|
36
|
+
const href = adminHref(navigation.basePath, {
|
|
37
|
+
resource: to.resource,
|
|
38
|
+
id: to.id,
|
|
39
|
+
action: to.action === 'edit' ? 'edit' : undefined,
|
|
40
|
+
});
|
|
41
|
+
// react-native's Pressable types have no `href`; react-native-web reads it.
|
|
42
|
+
const anchor = { href } as Record<string, unknown>;
|
|
43
|
+
return (
|
|
44
|
+
<Pressable
|
|
45
|
+
accessibilityRole="link"
|
|
46
|
+
{...anchor}
|
|
47
|
+
onPress={(e?: PressEvent) => {
|
|
48
|
+
e?.stopPropagation?.();
|
|
49
|
+
// A modifier click is the browser's: the anchor opens a new tab.
|
|
50
|
+
if (e?.metaKey || e?.ctrlKey || e?.shiftKey) return;
|
|
51
|
+
// The anchor would load the page itself on web; the router navigates instead.
|
|
52
|
+
e?.preventDefault?.();
|
|
53
|
+
navigation.push(href);
|
|
54
|
+
}}
|
|
55
|
+
>
|
|
56
|
+
<Text className="text-sm text-blue-600 underline" numberOfLines={numberOfLines}>{children}</Text>
|
|
57
|
+
</Pressable>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeletePreview — before a delete, how many rows each declared association links
|
|
3
|
+
* to this record. The counts are information only: a count that fails shows `?`,
|
|
4
|
+
* and Confirm never waits for them or depends on them. It adds no write.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
8
|
+
import { View, Text } from 'react-native';
|
|
9
|
+
import { Button } from '@everystack/ui';
|
|
10
|
+
import type { DataAdapter } from '../adapters/types';
|
|
11
|
+
import type { AssociationConfig } from '../types';
|
|
12
|
+
import { safeRelationId } from '../hooks/useRelationLabels';
|
|
13
|
+
|
|
14
|
+
/** Each association costs one request, so the preview counts at most this many. */
|
|
15
|
+
export const MAX_ASSOCIATIONS = 12;
|
|
16
|
+
|
|
17
|
+
export interface DeletePreviewProps {
|
|
18
|
+
adapter: DataAdapter;
|
|
19
|
+
/** e.g. 'Contents' */
|
|
20
|
+
label: string;
|
|
21
|
+
id: string | number;
|
|
22
|
+
associations: AssociationConfig[];
|
|
23
|
+
/** The resource soft-deletes, so the linked rows stay as they are. */
|
|
24
|
+
softDelete: boolean;
|
|
25
|
+
/** The existing delete path. */
|
|
26
|
+
onConfirm: () => void | Promise<void>;
|
|
27
|
+
onCancel: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The linked rows of one association: a `count` aggregate when the adapter has one, else a one-row list's total. */
|
|
31
|
+
export async function countAssociation(adapter: DataAdapter, association: AssociationConfig, id: string | number): Promise<number> {
|
|
32
|
+
const safeId = safeRelationId(id);
|
|
33
|
+
if (safeId === null) throw new Error('Association count needs a safe record id');
|
|
34
|
+
// The adapter skips an undefined filter value, so a missing typeValue would count every type.
|
|
35
|
+
if (association.typeColumn && association.typeValue == null) {
|
|
36
|
+
throw new Error(`association "${association.label}": typeColumn needs a typeValue`);
|
|
37
|
+
}
|
|
38
|
+
const filter = {
|
|
39
|
+
[association.foreignKey]: `eq.${safeId}`,
|
|
40
|
+
...(association.typeColumn ? { [association.typeColumn]: association.typeValue } : {}),
|
|
41
|
+
};
|
|
42
|
+
if (adapter.aggregate) {
|
|
43
|
+
const result = await adapter.aggregate(association.resource, { aggregates: [{ function: 'count' }], filter });
|
|
44
|
+
return Number(result.data[0]?.count ?? 0);
|
|
45
|
+
}
|
|
46
|
+
return (await adapter.fetchList(association.resource, { page: 1, perPage: 1, filter })).total;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type Count = number | '?' | undefined;
|
|
50
|
+
|
|
51
|
+
export function DeletePreview({
|
|
52
|
+
adapter,
|
|
53
|
+
label,
|
|
54
|
+
id,
|
|
55
|
+
associations,
|
|
56
|
+
softDelete,
|
|
57
|
+
onConfirm,
|
|
58
|
+
onCancel,
|
|
59
|
+
}: DeletePreviewProps): React.ReactElement {
|
|
60
|
+
const shown = associations.slice(0, MAX_ASSOCIATIONS);
|
|
61
|
+
const [counts, setCounts] = useState<Count[]>([]);
|
|
62
|
+
const busy = useRef(false);
|
|
63
|
+
const [error, setError] = useState<string | null>(null);
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (associations.length > MAX_ASSOCIATIONS) {
|
|
67
|
+
console.warn(`[admin] ${label}: only the first ${MAX_ASSOCIATIONS} associations are counted`);
|
|
68
|
+
}
|
|
69
|
+
let live = true;
|
|
70
|
+
setCounts([]);
|
|
71
|
+
Promise.allSettled(associations.slice(0, MAX_ASSOCIATIONS).map(a => countAssociation(adapter, a, id)))
|
|
72
|
+
.then(results => {
|
|
73
|
+
if (live) setCounts(results.map(r => (r.status === 'fulfilled' ? r.value : '?')));
|
|
74
|
+
});
|
|
75
|
+
return () => { live = false; };
|
|
76
|
+
}, [adapter, associations, id, label]);
|
|
77
|
+
|
|
78
|
+
const confirm = async (): Promise<void> => {
|
|
79
|
+
if (busy.current) return;
|
|
80
|
+
busy.current = true;
|
|
81
|
+
setError(null);
|
|
82
|
+
try {
|
|
83
|
+
await onConfirm();
|
|
84
|
+
} catch (err) {
|
|
85
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
86
|
+
} finally {
|
|
87
|
+
busy.current = false;
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<View testID="delete-preview" className="mx-4 mb-4 p-3 rounded-md bg-red-50">
|
|
93
|
+
<Text className="text-base font-semibold text-red-800 mb-2">{`Delete ${label} #${String(id)}?`}</Text>
|
|
94
|
+
{shown.map((association, i) => (
|
|
95
|
+
<Text key={`${association.resource}:${association.foreignKey}:${i}`} className="text-sm text-gray-900">
|
|
96
|
+
{`${association.label}: ${counts[i] ?? '…'}`}
|
|
97
|
+
</Text>
|
|
98
|
+
))}
|
|
99
|
+
<Text className="text-xs text-gray-600 mt-2">
|
|
100
|
+
{softDelete ? 'Nothing is deleted from these tables.' : 'These rows will be left without their record.'}
|
|
101
|
+
</Text>
|
|
102
|
+
<View className="flex-row gap-2 mt-3">
|
|
103
|
+
<Button label="Confirm" variant="destructive" onPress={confirm} />
|
|
104
|
+
<Button label="Cancel" variant="outline" onPress={onCancel} />
|
|
105
|
+
</View>
|
|
106
|
+
{error && <Text className="text-xs text-red-600 mt-1">{error}</Text>}
|
|
107
|
+
</View>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RelationFieldInput
|
|
3
|
+
*
|
|
4
|
+
* Create/edit picker for `type: 'relation'`. Typing searches the related
|
|
5
|
+
* resource 250 ms after the last key: digits search by `relation.field`, other
|
|
6
|
+
* text searches `relation.displayField` through ListParams.search (the
|
|
7
|
+
* adapter escapes it). Without a displayField, text that passes
|
|
8
|
+
* safeRelationId is matched exactly by `relation.field`. A result
|
|
9
|
+
* reads `#id name`; a pick saves the fetched row's `relation.field` value, so
|
|
10
|
+
* a number stays a number.
|
|
11
|
+
*
|
|
12
|
+
* The current value shows as a chip with a Clear control. A picked row names
|
|
13
|
+
* its own chip; a stored value gets its name from useRelationLabels, one
|
|
14
|
+
* lookup on mount.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
18
|
+
import { View, Text, Pressable } from 'react-native';
|
|
19
|
+
import { TextInput } from '@everystack/ui';
|
|
20
|
+
import { useRelationLabels, safeRelationId } from '../hooks/useRelationLabels';
|
|
21
|
+
import type { DataAdapter } from '../adapters/types';
|
|
22
|
+
import type { DataRecord, FieldDefinition, ListParams } from '../types';
|
|
23
|
+
|
|
24
|
+
export interface RelationFieldInputProps {
|
|
25
|
+
field: FieldDefinition;
|
|
26
|
+
value: unknown;
|
|
27
|
+
onChange: (value: string | number | null) => void;
|
|
28
|
+
adapter: DataAdapter;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const DEBOUNCE_MS = 250;
|
|
32
|
+
const PAGE_SIZE = 10;
|
|
33
|
+
const DIGITS = /^\d+$/;
|
|
34
|
+
|
|
35
|
+
type Status = 'idle' | 'searching' | 'results' | 'none' | 'failed';
|
|
36
|
+
|
|
37
|
+
function relationLabel(id: unknown, name: unknown): string {
|
|
38
|
+
return name != null && name !== '' ? `#${String(id)} ${String(name)}` : `#${String(id)}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function RelationFieldInput({ field, value, onChange, adapter }: RelationFieldInputProps): React.ReactElement {
|
|
42
|
+
const relation = field.relation;
|
|
43
|
+
const [text, setText] = useState('');
|
|
44
|
+
const [status, setStatus] = useState<Status>('idle');
|
|
45
|
+
const [results, setResults] = useState<DataRecord[]>([]);
|
|
46
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
47
|
+
// Bumped by every search and every pick; a response for an older number is dropped.
|
|
48
|
+
const requestId = useRef(0);
|
|
49
|
+
const mounted = useRef(true);
|
|
50
|
+
// The last picked row, so its chip needs no lookup.
|
|
51
|
+
const [picked, setPicked] = useState<{ id: string; name: unknown } | null>(null);
|
|
52
|
+
|
|
53
|
+
const currentId = safeRelationId(value);
|
|
54
|
+
const pickedCurrent = picked !== null && picked.id === currentId;
|
|
55
|
+
const fields = useMemo(() => [field], [field]);
|
|
56
|
+
const rows = useMemo(
|
|
57
|
+
() => (pickedCurrent ? [] : [{ [field.name]: value }]),
|
|
58
|
+
[pickedCurrent, field.name, value],
|
|
59
|
+
);
|
|
60
|
+
const labels = useRelationLabels(adapter, fields, rows);
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
mounted.current = true;
|
|
64
|
+
return () => {
|
|
65
|
+
mounted.current = false;
|
|
66
|
+
if (timer.current) clearTimeout(timer.current);
|
|
67
|
+
};
|
|
68
|
+
}, []);
|
|
69
|
+
|
|
70
|
+
const search = (query: string): void => {
|
|
71
|
+
if (!relation) return;
|
|
72
|
+
const id = ++requestId.current;
|
|
73
|
+
let params: ListParams;
|
|
74
|
+
if (DIGITS.test(query) || (!relation.displayField && safeRelationId(query) !== null)) {
|
|
75
|
+
params = { page: 1, perPage: PAGE_SIZE, filter: { [relation.field]: `eq.${query}` } };
|
|
76
|
+
} else if (relation.displayField) {
|
|
77
|
+
params = { page: 1, perPage: PAGE_SIZE, search: query, searchFields: [relation.displayField] };
|
|
78
|
+
} else {
|
|
79
|
+
setResults([]);
|
|
80
|
+
setStatus('none');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
setStatus('searching');
|
|
84
|
+
adapter.fetchList(relation.resource, params).then(result => {
|
|
85
|
+
if (!mounted.current || id !== requestId.current) return;
|
|
86
|
+
setResults(result.data);
|
|
87
|
+
setStatus(result.data.length > 0 ? 'results' : 'none');
|
|
88
|
+
}).catch(() => {
|
|
89
|
+
if (!mounted.current || id !== requestId.current) return;
|
|
90
|
+
setResults([]);
|
|
91
|
+
setStatus('failed');
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const onChangeText = (next: string): void => {
|
|
96
|
+
setText(next);
|
|
97
|
+
if (timer.current) clearTimeout(timer.current);
|
|
98
|
+
const query = next.trim();
|
|
99
|
+
if (query === '') {
|
|
100
|
+
requestId.current++;
|
|
101
|
+
setResults([]);
|
|
102
|
+
setStatus('idle');
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
timer.current = setTimeout(() => search(query), DEBOUNCE_MS);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const pick = (row: DataRecord): void => {
|
|
109
|
+
if (!relation) return;
|
|
110
|
+
requestId.current++;
|
|
111
|
+
setText('');
|
|
112
|
+
setResults([]);
|
|
113
|
+
setStatus('idle');
|
|
114
|
+
const id = safeRelationId(row[relation.field]);
|
|
115
|
+
setPicked(id === null ? null : { id, name: relation.displayField ? row[relation.displayField] : undefined });
|
|
116
|
+
onChange(row[relation.field] as string | number);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const currentName = pickedCurrent
|
|
120
|
+
? picked.name
|
|
121
|
+
: currentId !== null ? labels.get(field.name)?.get(currentId) : undefined;
|
|
122
|
+
const placeholder = relation?.displayField ? 'Search by name or id' : 'Search by id';
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<View>
|
|
126
|
+
{value != null && value !== '' && (
|
|
127
|
+
<View className="flex-row items-center gap-2 mb-2">
|
|
128
|
+
<Text className="text-sm text-gray-900">{relationLabel(value, currentName)}</Text>
|
|
129
|
+
<Pressable accessibilityRole="button" accessibilityLabel="Clear" onPress={() => onChange(null)}>
|
|
130
|
+
<Text className="text-sm text-gray-500">×</Text>
|
|
131
|
+
</Pressable>
|
|
132
|
+
</View>
|
|
133
|
+
)}
|
|
134
|
+
<TextInput value={text} onChangeText={onChangeText} placeholder={placeholder} />
|
|
135
|
+
{status === 'searching' && <Text className="text-sm text-gray-500 mt-1">Searching…</Text>}
|
|
136
|
+
{status === 'none' && <Text className="text-sm text-gray-500 mt-1">No matches</Text>}
|
|
137
|
+
{status === 'failed' && <Text className="text-sm text-red-600 mt-1">Lookup failed</Text>}
|
|
138
|
+
{status === 'results' && relation && (
|
|
139
|
+
<View className="mt-1 border border-gray-200 rounded-lg">
|
|
140
|
+
{results.map(row => {
|
|
141
|
+
const id = row[relation.field];
|
|
142
|
+
const name = relation.displayField ? row[relation.displayField] : undefined;
|
|
143
|
+
return (
|
|
144
|
+
<Pressable
|
|
145
|
+
key={String(id)}
|
|
146
|
+
testID={`relation-${field.name}-option-${String(id)}`}
|
|
147
|
+
accessibilityRole="button"
|
|
148
|
+
onPress={() => pick(row)}
|
|
149
|
+
className="px-3 py-2"
|
|
150
|
+
>
|
|
151
|
+
<Text className="text-sm text-gray-900">{relationLabel(id, name)}</Text>
|
|
152
|
+
</Pressable>
|
|
153
|
+
);
|
|
154
|
+
})}
|
|
155
|
+
</View>
|
|
156
|
+
)}
|
|
157
|
+
</View>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Text } from 'react-native';
|
|
3
|
+
import { AdminLink } from './AdminLink';
|
|
4
|
+
import { safeRelationId } from '../hooks/useRelationLabels';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A relation value: the label (else the id) linked to the related show page.
|
|
8
|
+
* Null or empty shows nothing. An id that is unsafe in a path shows as plain text.
|
|
9
|
+
*/
|
|
10
|
+
export function RelationValue({ resource, value, labels, numberOfLines }: {
|
|
11
|
+
resource: string;
|
|
12
|
+
value: unknown;
|
|
13
|
+
labels: Map<string, string> | undefined;
|
|
14
|
+
numberOfLines?: number;
|
|
15
|
+
}): React.ReactElement | null {
|
|
16
|
+
if (value == null || value === '') return null;
|
|
17
|
+
const id = safeRelationId(value);
|
|
18
|
+
if (id === null) {
|
|
19
|
+
return <Text className="text-sm text-gray-900" numberOfLines={numberOfLines}>{String(value)}</Text>;
|
|
20
|
+
}
|
|
21
|
+
return (
|
|
22
|
+
<AdminLink to={{ resource, id }} numberOfLines={numberOfLines}>
|
|
23
|
+
{labels?.get(id) ?? id}
|
|
24
|
+
</AdminLink>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Node, type Extensions } from '@tiptap/react';
|
|
1
|
+
import { Extension, Node, getSchema, mergeAttributes, type Extensions } from '@tiptap/react';
|
|
2
2
|
import type { DOMOutputSpec, TagParseRule } from '@tiptap/pm/model';
|
|
3
3
|
import StarterKit from '@tiptap/starter-kit';
|
|
4
4
|
import { Code } from '@tiptap/extension-code';
|
|
@@ -21,8 +21,8 @@ export const Figure = Node.create({
|
|
|
21
21
|
getAttrs: (element: HTMLElement): null | false => element.querySelector('img[src]') ? null : false,
|
|
22
22
|
}];
|
|
23
23
|
},
|
|
24
|
-
renderHTML(): DOMOutputSpec {
|
|
25
|
-
return ['figure', 0];
|
|
24
|
+
renderHTML({ HTMLAttributes }): DOMOutputSpec {
|
|
25
|
+
return ['figure', mergeAttributes(HTMLAttributes), 0];
|
|
26
26
|
},
|
|
27
27
|
});
|
|
28
28
|
|
|
@@ -35,13 +35,38 @@ export const Figcaption = Node.create({
|
|
|
35
35
|
parseHTML(): TagParseRule[] {
|
|
36
36
|
return [{ tag: 'figcaption', context: 'figure/' }];
|
|
37
37
|
},
|
|
38
|
-
renderHTML(): DOMOutputSpec {
|
|
39
|
-
return ['figcaption', 0];
|
|
38
|
+
renderHTML({ HTMLAttributes }): DOMOutputSpec {
|
|
39
|
+
return ['figcaption', mergeAttributes(HTMLAttributes), 0];
|
|
40
40
|
},
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
-
export
|
|
44
|
-
|
|
43
|
+
export const DataAttributes = Extension.create<{ types: string[] }>({
|
|
44
|
+
name: 'dataAttributes',
|
|
45
|
+
addOptions() {
|
|
46
|
+
return { types: [] };
|
|
47
|
+
},
|
|
48
|
+
addGlobalAttributes() {
|
|
49
|
+
return [{
|
|
50
|
+
types: this.options.types,
|
|
51
|
+
attributes: {
|
|
52
|
+
dataAttributes: {
|
|
53
|
+
default: null,
|
|
54
|
+
keepOnSplit: false,
|
|
55
|
+
parseHTML: (element: HTMLElement): Record<string, string> | null => {
|
|
56
|
+
const entries = Array.from(element.attributes)
|
|
57
|
+
.filter(attribute => attribute.name.startsWith('data-'))
|
|
58
|
+
.map(attribute => [attribute.name, attribute.value]);
|
|
59
|
+
return entries.length ? Object.fromEntries(entries) : null;
|
|
60
|
+
},
|
|
61
|
+
renderHTML: (attributes): Record<string, string> => attributes.dataAttributes ?? {},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
}];
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
export function richHtmlExtensions(options?: { dataAttributes?: boolean }): Extensions {
|
|
69
|
+
const base: Extensions = [
|
|
45
70
|
StarterKit.configure({
|
|
46
71
|
code: false,
|
|
47
72
|
codeBlock: false,
|
|
@@ -60,4 +85,10 @@ export function richHtmlExtensions(): Extensions {
|
|
|
60
85
|
Subscript,
|
|
61
86
|
Superscript,
|
|
62
87
|
];
|
|
88
|
+
if (options?.dataAttributes === false) return base;
|
|
89
|
+
|
|
90
|
+
const schema = getSchema(base);
|
|
91
|
+
const types = [...Object.keys(schema.nodes), ...Object.keys(schema.marks)]
|
|
92
|
+
.filter(type => type !== 'doc' && type !== 'text');
|
|
93
|
+
return [...base, DataAttributes.configure({ types })];
|
|
63
94
|
}
|
|
@@ -126,6 +126,8 @@ export function useAdminData<T extends DataRecord = DataRecord>(
|
|
|
126
126
|
filter,
|
|
127
127
|
search: options.search ?? null,
|
|
128
128
|
searchFields: options.searchFields ?? null,
|
|
129
|
+
select: options.select ?? null,
|
|
130
|
+
deleted: options.deleted ?? null,
|
|
129
131
|
});
|
|
130
132
|
const reqKey = `${paramsKey}#${refetchIndex}`;
|
|
131
133
|
|
|
@@ -165,6 +167,8 @@ export function useAdminData<T extends DataRecord = DataRecord>(
|
|
|
165
167
|
filter: { ...(options.baseFilter ?? {}), ...filter },
|
|
166
168
|
...(options.search !== undefined ? { search: options.search } : {}),
|
|
167
169
|
...(options.searchFields !== undefined ? { searchFields: options.searchFields } : {}),
|
|
170
|
+
...(options.select ? { select: options.select } : {}),
|
|
171
|
+
...(options.deleted ? { deleted: options.deleted } : {}),
|
|
168
172
|
})
|
|
169
173
|
.then(result => {
|
|
170
174
|
if (seq !== seqRef.current) return;
|