@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
|
@@ -5,19 +5,23 @@
|
|
|
5
5
|
* Fetches via adapter.fetchOne() and displays field values.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import React, { useState, useEffect, useLayoutEffect, useCallback, useRef } from 'react';
|
|
8
|
+
import React, { useState, useEffect, useLayoutEffect, useCallback, useMemo, useRef } from 'react';
|
|
9
9
|
import { View, Text, ScrollView } from 'react-native';
|
|
10
10
|
import { useAdminConfig } from '../hooks/useAdminConfig';
|
|
11
11
|
import { useDataAdapter } from '../hooks/useAdminData';
|
|
12
|
-
import { LoadingState, ErrorState, Button, Card, Divider } from '@everystack/ui';
|
|
12
|
+
import { LoadingState, ErrorState, Button, Card, Divider, RichContent } from '@everystack/ui';
|
|
13
13
|
import { ImageDisplay } from '../components/ImageDisplay';
|
|
14
14
|
import { TagsDisplay } from '../components/TagsDisplay';
|
|
15
15
|
import { RowActionButton } from '../components/RowActionButton';
|
|
16
|
+
import { DeletePreview } from '../components/DeletePreview';
|
|
16
17
|
import { FieldColumns } from '../components/FieldColumns';
|
|
18
|
+
import { RelationValue } from '../components/RelationValue';
|
|
19
|
+
import { useRelationLabels, type RelationLabels } from '../hooks/useRelationLabels';
|
|
17
20
|
import { useEditors } from '../hooks/useEditors';
|
|
18
21
|
import { resolveColumns, screenLayout } from '../utils/resolve-columns';
|
|
19
22
|
import { wrapBareText } from '../components/CellText';
|
|
20
23
|
import { visibleRowActions } from '../utils/row-actions';
|
|
24
|
+
import { restoreAction, softDeleteColumn } from '../utils/standard-actions';
|
|
21
25
|
import { toCamelCase } from '../utils/naming';
|
|
22
26
|
import { formatDateOnly } from '../utils/format-cell-value';
|
|
23
27
|
import type { FieldDefinition, DataRecord, ResourceConfig } from '../types';
|
|
@@ -30,10 +34,15 @@ export interface ResourceShowProps {
|
|
|
30
34
|
id: string | number;
|
|
31
35
|
/** Called when the edit button is pressed */
|
|
32
36
|
onEditPress?: () => void;
|
|
33
|
-
/**
|
|
34
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Called when the delete button is pressed. When the resource declares
|
|
39
|
+
* `associations`, Delete opens the delete preview and its Confirm calls this.
|
|
40
|
+
*/
|
|
41
|
+
onDeletePress?: () => void | Promise<void>;
|
|
35
42
|
/** Called when the back/list button is pressed */
|
|
36
43
|
onBackPress?: () => void;
|
|
44
|
+
/** Open the delete preview on mount (a resource with `associations` only). */
|
|
45
|
+
confirmDelete?: boolean;
|
|
37
46
|
}
|
|
38
47
|
|
|
39
48
|
export function ResourceShow({
|
|
@@ -42,6 +51,7 @@ export function ResourceShow({
|
|
|
42
51
|
onEditPress,
|
|
43
52
|
onDeletePress,
|
|
44
53
|
onBackPress,
|
|
54
|
+
confirmDelete,
|
|
45
55
|
}: ResourceShowProps): React.ReactElement {
|
|
46
56
|
const config = useAdminConfig();
|
|
47
57
|
const adapter = useDataAdapter();
|
|
@@ -51,6 +61,8 @@ export function ResourceShow({
|
|
|
51
61
|
const [record, setRecord] = useState<DataRecord | null>(null);
|
|
52
62
|
const [loading, setLoading] = useState(true);
|
|
53
63
|
const [error, setError] = useState<Error | null>(null);
|
|
64
|
+
// Declared associations put a preview of the linked rows between Delete and the delete.
|
|
65
|
+
const [previewOpen, setPreviewOpen] = useState(Boolean(confirmDelete));
|
|
54
66
|
|
|
55
67
|
// Latest-request guard: only the most recent load may write state, so an older
|
|
56
68
|
// id's or an older request's response can't land over a newer one.
|
|
@@ -90,6 +102,15 @@ export function ResourceShow({
|
|
|
90
102
|
fetchRecord();
|
|
91
103
|
}, [fetchRecord]);
|
|
92
104
|
|
|
105
|
+
// Relation fields print the related row's displayField (one request per related resource).
|
|
106
|
+
const relationFields = useMemo(() => {
|
|
107
|
+
if (!resourceConfig) return [];
|
|
108
|
+
const shown = new Set(resourceConfig.showFields ?? resourceConfig.fields.map(f => f.name));
|
|
109
|
+
return resourceConfig.fields.filter(f => shown.has(f.name) && f.type === 'relation' && !f.render);
|
|
110
|
+
}, [resourceConfig]);
|
|
111
|
+
const relationRows = useMemo(() => (record ? [record] : []), [record]);
|
|
112
|
+
const relationLabels = useRelationLabels(adapter, relationFields, relationRows);
|
|
113
|
+
|
|
93
114
|
if (!resourceConfig) {
|
|
94
115
|
return <ErrorState message={`Resource "${resource}" not found in admin configuration`} />;
|
|
95
116
|
}
|
|
@@ -109,8 +130,14 @@ export function ResourceShow({
|
|
|
109
130
|
const label = resourceConfig.label ?? resourceConfig.name;
|
|
110
131
|
const showFields = resourceConfig.showFields ?? resourceConfig.fields.map(f => f.name);
|
|
111
132
|
const fieldMap = new Map(resourceConfig.fields.map(f => [f.name, f]));
|
|
112
|
-
|
|
113
|
-
const
|
|
133
|
+
// A soft-deleted row shows a banner with Restore instead of Edit and Delete.
|
|
134
|
+
const deletedColumn = softDeleteColumn(resourceConfig);
|
|
135
|
+
const deletedAt = deletedColumn ? record[deletedColumn] : undefined;
|
|
136
|
+
const isDeleted = deletedAt != null;
|
|
137
|
+
const restore = isDeleted ? restoreAction(resourceConfig) : undefined;
|
|
138
|
+
const canEdit = resourceConfig.actions?.edit !== false && !isDeleted;
|
|
139
|
+
const canDelete = resourceConfig.actions?.delete !== false && !isDeleted;
|
|
140
|
+
const associations = resourceConfig.associations?.length ? resourceConfig.associations : undefined;
|
|
114
141
|
const rowActions = visibleRowActions(resourceConfig.rowActions, record);
|
|
115
142
|
const layout = screenLayout(resourceConfig, 'show');
|
|
116
143
|
const columns = layout
|
|
@@ -148,11 +175,30 @@ export function ResourceShow({
|
|
|
148
175
|
<Button label="Edit" variant="outline" onPress={onEditPress} />
|
|
149
176
|
)}
|
|
150
177
|
{canDelete && onDeletePress && (
|
|
151
|
-
<Button label="Delete" variant="destructive" onPress={onDeletePress} />
|
|
178
|
+
<Button label="Delete" variant="destructive" onPress={associations ? () => setPreviewOpen(true) : onDeletePress} />
|
|
152
179
|
)}
|
|
153
180
|
</View>
|
|
154
181
|
</View>
|
|
155
182
|
|
|
183
|
+
{previewOpen && associations && canDelete && onDeletePress && (
|
|
184
|
+
<DeletePreview
|
|
185
|
+
adapter={adapter}
|
|
186
|
+
label={label}
|
|
187
|
+
id={id}
|
|
188
|
+
associations={associations}
|
|
189
|
+
softDelete={Boolean(deletedColumn)}
|
|
190
|
+
onConfirm={async () => { await onDeletePress(); setPreviewOpen(false); }}
|
|
191
|
+
onCancel={() => setPreviewOpen(false)}
|
|
192
|
+
/>
|
|
193
|
+
)}
|
|
194
|
+
|
|
195
|
+
{restore && (
|
|
196
|
+
<View testID="soft-delete-banner" className="flex-row items-center justify-between mx-4 mb-4 p-3 rounded-md bg-red-50">
|
|
197
|
+
<Text className="text-sm text-red-700">{`Deleted ${formatDateTime(deletedAt)}`}</Text>
|
|
198
|
+
<RowActionButton action={restore} record={record} recordId={id} adapter={adapter} refetch={refetchQuietly} />
|
|
199
|
+
</View>
|
|
200
|
+
)}
|
|
201
|
+
|
|
156
202
|
{/* Fields */}
|
|
157
203
|
<Card style={{ marginHorizontal: 16, marginBottom: 16 }}>
|
|
158
204
|
{columns ? (
|
|
@@ -161,7 +207,7 @@ export function ResourceShow({
|
|
|
161
207
|
renderField={(fieldDef, idx) => (
|
|
162
208
|
<>
|
|
163
209
|
{idx > 0 && <Divider />}
|
|
164
|
-
<FieldRow field={fieldDef} value={record[fieldDef.name]} record={record} />
|
|
210
|
+
<FieldRow field={fieldDef} value={record[fieldDef.name]} record={record} labels={relationLabels} />
|
|
165
211
|
</>
|
|
166
212
|
)}
|
|
167
213
|
/>
|
|
@@ -172,7 +218,7 @@ export function ResourceShow({
|
|
|
172
218
|
return (
|
|
173
219
|
<React.Fragment key={fieldName}>
|
|
174
220
|
{idx > 0 && <Divider />}
|
|
175
|
-
<FieldRow field={fieldDef} value={value} record={record} />
|
|
221
|
+
<FieldRow field={fieldDef} value={value} record={record} labels={relationLabels} />
|
|
176
222
|
</React.Fragment>
|
|
177
223
|
);
|
|
178
224
|
})}
|
|
@@ -204,7 +250,12 @@ async function fetchDetail(
|
|
|
204
250
|
}
|
|
205
251
|
return (await adapter.rpc(`${toCamelCase(resource)}EventDetail`, { id })) as DataRecord;
|
|
206
252
|
}
|
|
207
|
-
|
|
253
|
+
const select = resourceConfig?.select;
|
|
254
|
+
// A soft-delete resource loads the row even when it is deleted, so Show can offer Restore.
|
|
255
|
+
if (resourceConfig?.softDelete) {
|
|
256
|
+
return adapter.fetchOne(resource, id, { ...(select ? { select } : {}), deleted: 'include' });
|
|
257
|
+
}
|
|
258
|
+
return select ? adapter.fetchOne(resource, id, { select }) : adapter.fetchOne(resource, id);
|
|
208
259
|
}
|
|
209
260
|
|
|
210
261
|
// =============================================================================
|
|
@@ -215,9 +266,10 @@ interface FieldRowProps {
|
|
|
215
266
|
field: FieldDefinition;
|
|
216
267
|
value: unknown;
|
|
217
268
|
record: DataRecord;
|
|
269
|
+
labels: RelationLabels;
|
|
218
270
|
}
|
|
219
271
|
|
|
220
|
-
function FieldRow({ field, value, record }: FieldRowProps): React.ReactElement {
|
|
272
|
+
function FieldRow({ field, value, record, labels }: FieldRowProps): React.ReactElement {
|
|
221
273
|
return (
|
|
222
274
|
<View className="py-3">
|
|
223
275
|
<Text className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-1">
|
|
@@ -229,6 +281,11 @@ function FieldRow({ field, value, record }: FieldRowProps): React.ReactElement {
|
|
|
229
281
|
<ImageDisplay field={field} value={value} view="show" />
|
|
230
282
|
) : field.type === 'tags' ? (
|
|
231
283
|
<TagsDisplay field={field} value={value} />
|
|
284
|
+
) : field.type === 'relation' && field.relation && value != null && value !== '' ? (
|
|
285
|
+
<RelationValue resource={field.relation.resource} value={value} labels={labels.get(field.name)} />
|
|
286
|
+
) : value != null && (field.format === 'html' || field.format === 'markdown') ? (
|
|
287
|
+
// Stored rich text is sanitised at render by ui RichContent (web-only).
|
|
288
|
+
<RichContent value={value} format={field.format} className="text-sm text-gray-900" />
|
|
232
289
|
) : (
|
|
233
290
|
<Text className="text-sm text-gray-900">{formatFieldValue(value, field)}</Text>
|
|
234
291
|
)}
|
|
@@ -236,6 +293,11 @@ function FieldRow({ field, value, record }: FieldRowProps): React.ReactElement {
|
|
|
236
293
|
);
|
|
237
294
|
}
|
|
238
295
|
|
|
296
|
+
function formatDateTime(value: unknown): string {
|
|
297
|
+
const date = new Date(String(value));
|
|
298
|
+
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString();
|
|
299
|
+
}
|
|
300
|
+
|
|
239
301
|
function formatFieldValue(value: unknown, field: FieldDefinition): string {
|
|
240
302
|
if (value == null) return '\u2014';
|
|
241
303
|
|
|
@@ -244,10 +306,8 @@ function formatFieldValue(value: unknown, field: FieldDefinition): string {
|
|
|
244
306
|
return value ? 'Yes' : 'No';
|
|
245
307
|
case 'date':
|
|
246
308
|
return formatDateOnly(value);
|
|
247
|
-
case 'datetime':
|
|
248
|
-
|
|
249
|
-
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString();
|
|
250
|
-
}
|
|
309
|
+
case 'datetime':
|
|
310
|
+
return formatDateTime(value);
|
|
251
311
|
case 'json':
|
|
252
312
|
return JSON.stringify(value, null, 2);
|
|
253
313
|
case 'url':
|
package/src/types.ts
CHANGED
|
@@ -60,6 +60,8 @@ export interface InputContext {
|
|
|
60
60
|
/**
|
|
61
61
|
* The whole form's current values, keyed by field name (what Save would
|
|
62
62
|
* send). Read another field here to render a block that depends on it.
|
|
63
|
+
* Required since 0.4.3: code that builds an InputContext by hand, such as a
|
|
64
|
+
* test, must pass it.
|
|
63
65
|
*/
|
|
64
66
|
values: Record<string, unknown>;
|
|
65
67
|
}
|
|
@@ -314,6 +316,12 @@ export interface ResourceConfig {
|
|
|
314
316
|
editFields?: string[];
|
|
315
317
|
/** Primary key field name */
|
|
316
318
|
primaryKey?: string;
|
|
319
|
+
/**
|
|
320
|
+
* PostgREST select string for list, show and edit, embeds included, e.g.
|
|
321
|
+
* '*,user:users(name)'. One string for all three. Show an embedded value
|
|
322
|
+
* through a field `render`. RPC-backed resources ignore it.
|
|
323
|
+
*/
|
|
324
|
+
select?: string;
|
|
317
325
|
/** How the show view fetches one record. 'rpc' calls the role-gated
|
|
318
326
|
* {camelResource}EventDetail RPC (the resource stays off the REST surface);
|
|
319
327
|
* 'rest' (default) uses adapter.fetchOne. Lets a plain list (no dashboard) —
|
|
@@ -328,6 +336,21 @@ export interface ResourceConfig {
|
|
|
328
336
|
rowActions?: RowActionConfig[];
|
|
329
337
|
/** Tabs above the list, each a named filter. Without this the list shows no tabs. */
|
|
330
338
|
scopes?: ScopeConfig[];
|
|
339
|
+
/**
|
|
340
|
+
* The table soft-deletes. Turns on Restore for deleted rows (in a scope with
|
|
341
|
+
* `deleted: 'only'`, and on Show) and makes Show load a deleted row. Delete
|
|
342
|
+
* stays shown unless `actions.delete` is false.
|
|
343
|
+
*/
|
|
344
|
+
softDelete?: {
|
|
345
|
+
/** The soft-delete column, as the API names it. Default 'deletedAt'. */
|
|
346
|
+
column?: string;
|
|
347
|
+
};
|
|
348
|
+
/**
|
|
349
|
+
* Rows in other tables that point at this record. Before a delete, Show lists
|
|
350
|
+
* each one with a count of the linked rows (one request each, at most 12).
|
|
351
|
+
* Without it, Delete works as before.
|
|
352
|
+
*/
|
|
353
|
+
associations?: AssociationConfig[];
|
|
331
354
|
/**
|
|
332
355
|
* Show a row count on each tab. Off unless set: counts cost queries. The values of one
|
|
333
356
|
* enum column share one grouped query; every other tab costs one count. Counts follow the
|
|
@@ -564,6 +587,24 @@ export interface AdminConfig {
|
|
|
564
587
|
// Data Types
|
|
565
588
|
// =============================================================================
|
|
566
589
|
|
|
590
|
+
/**
|
|
591
|
+
* A table that links to a record, counted in the delete preview. A polymorphic
|
|
592
|
+
* link (`resource_id` + `resource_type`, often with no foreign key) sets
|
|
593
|
+
* `typeColumn` and `typeValue` as well.
|
|
594
|
+
*/
|
|
595
|
+
export interface AssociationConfig {
|
|
596
|
+
/** Shown in the preview, e.g. 'Bookmarks'. */
|
|
597
|
+
label: string;
|
|
598
|
+
/** The related table or resource name, as the adapter knows it. */
|
|
599
|
+
resource: string;
|
|
600
|
+
/** The column on the related table that holds this record's id. */
|
|
601
|
+
foreignKey: string;
|
|
602
|
+
/** The polymorphic type column on the related table, e.g. 'resource_type'. */
|
|
603
|
+
typeColumn?: string;
|
|
604
|
+
/** The value of `typeColumn` that means this resource, e.g. 'Content'. */
|
|
605
|
+
typeValue?: string;
|
|
606
|
+
}
|
|
607
|
+
|
|
567
608
|
/**
|
|
568
609
|
* A named filter shown as a tab above a list ("Current", "Draft"). Scopes are
|
|
569
610
|
* ANDed with the search and any column filters.
|
|
@@ -576,8 +617,13 @@ export interface ScopeConfig {
|
|
|
576
617
|
filter?: Record<string, unknown>;
|
|
577
618
|
/** The scope active when the URL names none. If none is marked, the first is. */
|
|
578
619
|
default?: boolean;
|
|
620
|
+
/** Soft-deleted rows: 'include' adds them, 'only' lists nothing else. See ListParams.deleted. */
|
|
621
|
+
deleted?: DeletedMode;
|
|
579
622
|
}
|
|
580
623
|
|
|
624
|
+
/** Soft-delete reads: 'include' returns live and deleted rows, 'only' deleted rows. */
|
|
625
|
+
export type DeletedMode = 'include' | 'only';
|
|
626
|
+
|
|
581
627
|
export interface ListParams {
|
|
582
628
|
/** Page number (1-indexed) */
|
|
583
629
|
page?: number;
|
|
@@ -597,6 +643,17 @@ export interface ListParams {
|
|
|
597
643
|
* returning unfiltered rows. Plain column names only.
|
|
598
644
|
*/
|
|
599
645
|
searchFields?: string[];
|
|
646
|
+
/**
|
|
647
|
+
* PostgREST select string, embeds included, e.g. '*,user:users(name)'.
|
|
648
|
+
* Passed verbatim to the query builder. Ignored by RPC-backed resources
|
|
649
|
+
* (listRpcs); an RPC decides its own shape.
|
|
650
|
+
*/
|
|
651
|
+
select?: string;
|
|
652
|
+
/**
|
|
653
|
+
* Soft-deleted rows, sent as `?deleted=` to the API. RPC-backed resources
|
|
654
|
+
* get it in the RPC body, and the RPC owner decides.
|
|
655
|
+
*/
|
|
656
|
+
deleted?: DeletedMode;
|
|
600
657
|
}
|
|
601
658
|
|
|
602
659
|
export interface ListResult<T = Record<string, unknown>> {
|
|
@@ -643,6 +700,8 @@ export interface AggregateParams {
|
|
|
643
700
|
search?: string;
|
|
644
701
|
/** Columns `search` looks in. Required with `search`. Plain column names only. */
|
|
645
702
|
searchFields?: string[];
|
|
703
|
+
/** Soft-deleted rows, exactly as `ListParams.deleted`, so a Deleted tab counts deleted rows. */
|
|
704
|
+
deleted?: DeletedMode;
|
|
646
705
|
}
|
|
647
706
|
|
|
648
707
|
export interface AggregateResult {
|
package/src/utils/list-query.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import type { ViewParams } from '../hooks/useViewState';
|
|
18
|
-
import type { ListParams } from '../types';
|
|
18
|
+
import type { DeletedMode, ListParams } from '../types';
|
|
19
19
|
|
|
20
20
|
/** Longest search text kept. A pasted paragraph is not a search. */
|
|
21
21
|
export const MAX_SEARCH_LENGTH = 200;
|
|
@@ -86,15 +86,15 @@ export function parseListQuery(params: ViewParams): ListQuery {
|
|
|
86
86
|
/**
|
|
87
87
|
* List state → a `setParams` delta. Every key is present, so a cleared field
|
|
88
88
|
* removes its key from the URL. Defaults (page 1, no search, the resource's
|
|
89
|
-
* `defaultPerPage`) are not written.
|
|
89
|
+
* `defaultPerPage` and `defaultScope`) are not written.
|
|
90
90
|
*/
|
|
91
91
|
export function listQueryToParams(
|
|
92
92
|
query: ListQuery,
|
|
93
|
-
options: { defaultPerPage?: number } = {},
|
|
93
|
+
options: { defaultPerPage?: number; defaultScope?: string } = {},
|
|
94
94
|
): Record<string, string | undefined> {
|
|
95
95
|
return {
|
|
96
96
|
q: query.search || undefined,
|
|
97
|
-
tab: query.scope
|
|
97
|
+
tab: query.scope && query.scope !== options.defaultScope ? query.scope : undefined,
|
|
98
98
|
sort: query.sort || undefined,
|
|
99
99
|
dir: query.order,
|
|
100
100
|
page: query.page && query.page > 1 ? String(query.page) : undefined,
|
|
@@ -145,6 +145,8 @@ export interface BuildListParamsOptions {
|
|
|
145
145
|
baseFilter?: Record<string, unknown>;
|
|
146
146
|
/** The active scope's filter, already resolved from the scope id. */
|
|
147
147
|
scopeFilter?: Record<string, unknown>;
|
|
148
|
+
/** The active scope's `deleted`, copied into ListParams beside the filter. */
|
|
149
|
+
scopeDeleted?: DeletedMode;
|
|
148
150
|
/** Columns a user may sort by. A sort outside this set is dropped. */
|
|
149
151
|
sortableFields?: ReadonlySet<string>;
|
|
150
152
|
/** Used when the query has no valid sort of its own. */
|
|
@@ -172,6 +174,7 @@ export function buildListParams(query: ListQuery, options: BuildListParamsOption
|
|
|
172
174
|
sort,
|
|
173
175
|
order,
|
|
174
176
|
filter: mergeFilters(options.baseFilter, options.scopeFilter),
|
|
177
|
+
...(options.scopeDeleted ? { deleted: options.scopeDeleted } : {}),
|
|
175
178
|
};
|
|
176
179
|
}
|
|
177
180
|
|
|
@@ -21,6 +21,8 @@ export interface ScopeCountPlan {
|
|
|
21
21
|
|
|
22
22
|
/** `{ state: 'eq.draft' }` is the one shape that can ride a grouped query. */
|
|
23
23
|
function equality(scope: ScopeConfig): { column: string; value: string } | null {
|
|
24
|
+
// The grouped query reads live rows only, so a scope over deleted rows counts alone.
|
|
25
|
+
if (scope.deleted) return null;
|
|
24
26
|
const entries = Object.entries(scope.filter ?? {});
|
|
25
27
|
if (entries.length !== 1) return null;
|
|
26
28
|
const [column, raw] = entries[0];
|
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
* the same actions column as custom rowActions and share the confirm/busy/error
|
|
7
7
|
* chrome. Show/Edit navigate via the supplied callbacks; Delete self-serves
|
|
8
8
|
* through adapter.delete and lets RowActionButton refetch afterward.
|
|
9
|
+
*
|
|
10
|
+
* In a scope with `deleted: 'only'` the rows are soft-deleted: Edit and Delete
|
|
11
|
+
* are hidden, and a resource that declares `softDelete` gets Restore instead.
|
|
9
12
|
*/
|
|
10
13
|
|
|
11
14
|
import type { ResourceConfig, RowActionConfig, DataRecord } from '../types';
|
|
@@ -15,32 +18,65 @@ export interface StandardActionNav {
|
|
|
15
18
|
onShow?: (record: DataRecord) => void;
|
|
16
19
|
/** Navigate to the record's edit screen. */
|
|
17
20
|
onEdit?: (record: DataRecord) => void;
|
|
21
|
+
/**
|
|
22
|
+
* Navigate to the record's Show with its delete preview open. Used by Delete
|
|
23
|
+
* when the resource declares `associations`; without it Delete keeps its two taps.
|
|
24
|
+
*/
|
|
25
|
+
onConfirmDelete?: (record: DataRecord) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const idOf = (resourceConfig: ResourceConfig, record: DataRecord): string | number =>
|
|
29
|
+
(record[resourceConfig.primaryKey ?? 'id'] as string | number) ?? (record.id as string | number);
|
|
30
|
+
|
|
31
|
+
/** The soft-delete column of a resource that declares `softDelete`, else undefined. */
|
|
32
|
+
export function softDeleteColumn(resourceConfig: ResourceConfig): string | undefined {
|
|
33
|
+
return resourceConfig.softDelete ? resourceConfig.softDelete.column ?? 'deletedAt' : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Restore a soft-deleted row: clear its column, matching deleted rows only. Undefined without `softDelete`. */
|
|
37
|
+
export function restoreAction(resourceConfig: ResourceConfig): RowActionConfig | undefined {
|
|
38
|
+
const column = softDeleteColumn(resourceConfig);
|
|
39
|
+
if (!column) return undefined;
|
|
40
|
+
return {
|
|
41
|
+
id: '__restore',
|
|
42
|
+
label: 'Restore',
|
|
43
|
+
variant: 'outline',
|
|
44
|
+
confirm: 'Confirm restore',
|
|
45
|
+
onPress: async ({ record, adapter }) => {
|
|
46
|
+
await adapter.update(resourceConfig.name, idOf(resourceConfig, record), { [column]: null }, { deleted: 'only' });
|
|
47
|
+
},
|
|
48
|
+
};
|
|
18
49
|
}
|
|
19
50
|
|
|
20
51
|
export function standardRowActions(
|
|
21
52
|
resourceConfig: ResourceConfig,
|
|
22
53
|
nav: StandardActionNav,
|
|
54
|
+
scope: { deletedOnly?: boolean } = {},
|
|
23
55
|
): RowActionConfig[] {
|
|
24
56
|
const actions = resourceConfig.actions ?? {};
|
|
25
|
-
const primaryKey = resourceConfig.primaryKey ?? 'id';
|
|
26
|
-
const idOf = (record: DataRecord): string | number =>
|
|
27
|
-
(record[primaryKey] as string | number) ?? (record.id as string | number);
|
|
28
57
|
|
|
29
58
|
const out: RowActionConfig[] = [];
|
|
30
59
|
|
|
31
60
|
if (actions.show !== false && nav.onShow) {
|
|
32
61
|
out.push({ id: '__show', label: 'Show', variant: 'outline', onPress: ({ record }) => nav.onShow!(record) });
|
|
33
62
|
}
|
|
63
|
+
if (scope.deletedOnly) {
|
|
64
|
+
const restore = restoreAction(resourceConfig);
|
|
65
|
+
if (restore) out.push(restore);
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
34
68
|
if (actions.edit !== false && nav.onEdit) {
|
|
35
69
|
out.push({ id: '__edit', label: 'Edit', variant: 'outline', onPress: ({ record }) => nav.onEdit!(record) });
|
|
36
70
|
}
|
|
37
|
-
if (actions.delete !== false) {
|
|
71
|
+
if (actions.delete !== false && resourceConfig.associations?.length && nav.onConfirmDelete) {
|
|
72
|
+
out.push({ id: '__delete', label: 'Delete', variant: 'destructive', onPress: ({ record }) => nav.onConfirmDelete!(record) });
|
|
73
|
+
} else if (actions.delete !== false) {
|
|
38
74
|
out.push({
|
|
39
75
|
id: '__delete',
|
|
40
76
|
label: 'Delete',
|
|
41
77
|
variant: 'destructive',
|
|
42
78
|
confirm: 'Confirm delete',
|
|
43
|
-
onPress: ({ record, adapter }) => adapter.delete(resourceConfig.name, idOf(record)),
|
|
79
|
+
onPress: ({ record, adapter }) => adapter.delete(resourceConfig.name, idOf(resourceConfig, record)),
|
|
44
80
|
});
|
|
45
81
|
}
|
|
46
82
|
|
|
@@ -15,6 +15,7 @@ export function paramsToFilters(params: ViewParams, dimensions: DimensionConfig[
|
|
|
15
15
|
const filters: DashboardFilters = {};
|
|
16
16
|
if (params.from && params.to) filters.time = [params.from, params.to];
|
|
17
17
|
for (const dim of dimensions) {
|
|
18
|
+
if (RESERVED_PARAMS.has(dim.column)) continue;
|
|
18
19
|
const v = params[dim.column];
|
|
19
20
|
if (v) filters[dim.column] = v.split(',');
|
|
20
21
|
}
|
|
@@ -24,12 +25,13 @@ export function paramsToFilters(params: ViewParams, dimensions: DimensionConfig[
|
|
|
24
25
|
/** Analytics filters → a query-param delta that clears then sets controlled keys. */
|
|
25
26
|
export function filtersToParams(filters: DashboardFilters, dimensions: DimensionConfig[]): Record<string, string | undefined> {
|
|
26
27
|
const update: Record<string, string | undefined> = { from: undefined, to: undefined };
|
|
27
|
-
for (const dim of dimensions) update[dim.column] = undefined;
|
|
28
|
+
for (const dim of dimensions) if (!RESERVED_PARAMS.has(dim.column)) update[dim.column] = undefined;
|
|
28
29
|
if (filters.time && filters.time[0] && filters.time[1]) {
|
|
29
30
|
update.from = filters.time[0];
|
|
30
31
|
update.to = filters.time[1];
|
|
31
32
|
}
|
|
32
33
|
for (const dim of dimensions) {
|
|
34
|
+
if (RESERVED_PARAMS.has(dim.column)) continue;
|
|
33
35
|
const v = filters[dim.column] as string[] | undefined;
|
|
34
36
|
if (v && v.length) update[dim.column] = v.join(',');
|
|
35
37
|
}
|