@everystack/admin 0.4.3 → 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 +23 -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/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 +57 -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/src/types.ts
CHANGED
|
@@ -314,6 +314,12 @@ export interface ResourceConfig {
|
|
|
314
314
|
editFields?: string[];
|
|
315
315
|
/** Primary key field name */
|
|
316
316
|
primaryKey?: string;
|
|
317
|
+
/**
|
|
318
|
+
* PostgREST select string for list, show and edit, embeds included, e.g.
|
|
319
|
+
* '*,user:users(name)'. One string for all three. Show an embedded value
|
|
320
|
+
* through a field `render`. RPC-backed resources ignore it.
|
|
321
|
+
*/
|
|
322
|
+
select?: string;
|
|
317
323
|
/** How the show view fetches one record. 'rpc' calls the role-gated
|
|
318
324
|
* {camelResource}EventDetail RPC (the resource stays off the REST surface);
|
|
319
325
|
* 'rest' (default) uses adapter.fetchOne. Lets a plain list (no dashboard) —
|
|
@@ -328,6 +334,21 @@ export interface ResourceConfig {
|
|
|
328
334
|
rowActions?: RowActionConfig[];
|
|
329
335
|
/** Tabs above the list, each a named filter. Without this the list shows no tabs. */
|
|
330
336
|
scopes?: ScopeConfig[];
|
|
337
|
+
/**
|
|
338
|
+
* The table soft-deletes. Turns on Restore for deleted rows (in a scope with
|
|
339
|
+
* `deleted: 'only'`, and on Show) and makes Show load a deleted row. Delete
|
|
340
|
+
* stays shown unless `actions.delete` is false.
|
|
341
|
+
*/
|
|
342
|
+
softDelete?: {
|
|
343
|
+
/** The soft-delete column, as the API names it. Default 'deletedAt'. */
|
|
344
|
+
column?: string;
|
|
345
|
+
};
|
|
346
|
+
/**
|
|
347
|
+
* Rows in other tables that point at this record. Before a delete, Show lists
|
|
348
|
+
* each one with a count of the linked rows (one request each, at most 12).
|
|
349
|
+
* Without it, Delete works as before.
|
|
350
|
+
*/
|
|
351
|
+
associations?: AssociationConfig[];
|
|
331
352
|
/**
|
|
332
353
|
* Show a row count on each tab. Off unless set: counts cost queries. The values of one
|
|
333
354
|
* enum column share one grouped query; every other tab costs one count. Counts follow the
|
|
@@ -564,6 +585,24 @@ export interface AdminConfig {
|
|
|
564
585
|
// Data Types
|
|
565
586
|
// =============================================================================
|
|
566
587
|
|
|
588
|
+
/**
|
|
589
|
+
* A table that links to a record, counted in the delete preview. A polymorphic
|
|
590
|
+
* link (`resource_id` + `resource_type`, often with no foreign key) sets
|
|
591
|
+
* `typeColumn` and `typeValue` as well.
|
|
592
|
+
*/
|
|
593
|
+
export interface AssociationConfig {
|
|
594
|
+
/** Shown in the preview, e.g. 'Bookmarks'. */
|
|
595
|
+
label: string;
|
|
596
|
+
/** The related table or resource name, as the adapter knows it. */
|
|
597
|
+
resource: string;
|
|
598
|
+
/** The column on the related table that holds this record's id. */
|
|
599
|
+
foreignKey: string;
|
|
600
|
+
/** The polymorphic type column on the related table, e.g. 'resource_type'. */
|
|
601
|
+
typeColumn?: string;
|
|
602
|
+
/** The value of `typeColumn` that means this resource, e.g. 'Content'. */
|
|
603
|
+
typeValue?: string;
|
|
604
|
+
}
|
|
605
|
+
|
|
567
606
|
/**
|
|
568
607
|
* A named filter shown as a tab above a list ("Current", "Draft"). Scopes are
|
|
569
608
|
* ANDed with the search and any column filters.
|
|
@@ -576,8 +615,13 @@ export interface ScopeConfig {
|
|
|
576
615
|
filter?: Record<string, unknown>;
|
|
577
616
|
/** The scope active when the URL names none. If none is marked, the first is. */
|
|
578
617
|
default?: boolean;
|
|
618
|
+
/** Soft-deleted rows: 'include' adds them, 'only' lists nothing else. See ListParams.deleted. */
|
|
619
|
+
deleted?: DeletedMode;
|
|
579
620
|
}
|
|
580
621
|
|
|
622
|
+
/** Soft-delete reads: 'include' returns live and deleted rows, 'only' deleted rows. */
|
|
623
|
+
export type DeletedMode = 'include' | 'only';
|
|
624
|
+
|
|
581
625
|
export interface ListParams {
|
|
582
626
|
/** Page number (1-indexed) */
|
|
583
627
|
page?: number;
|
|
@@ -597,6 +641,17 @@ export interface ListParams {
|
|
|
597
641
|
* returning unfiltered rows. Plain column names only.
|
|
598
642
|
*/
|
|
599
643
|
searchFields?: string[];
|
|
644
|
+
/**
|
|
645
|
+
* PostgREST select string, embeds included, e.g. '*,user:users(name)'.
|
|
646
|
+
* Passed verbatim to the query builder. Ignored by RPC-backed resources
|
|
647
|
+
* (listRpcs); an RPC decides its own shape.
|
|
648
|
+
*/
|
|
649
|
+
select?: string;
|
|
650
|
+
/**
|
|
651
|
+
* Soft-deleted rows, sent as `?deleted=` to the API. RPC-backed resources
|
|
652
|
+
* get it in the RPC body, and the RPC owner decides.
|
|
653
|
+
*/
|
|
654
|
+
deleted?: DeletedMode;
|
|
600
655
|
}
|
|
601
656
|
|
|
602
657
|
export interface ListResult<T = Record<string, unknown>> {
|
|
@@ -643,6 +698,8 @@ export interface AggregateParams {
|
|
|
643
698
|
search?: string;
|
|
644
699
|
/** Columns `search` looks in. Required with `search`. Plain column names only. */
|
|
645
700
|
searchFields?: string[];
|
|
701
|
+
/** Soft-deleted rows, exactly as `ListParams.deleted`, so a Deleted tab counts deleted rows. */
|
|
702
|
+
deleted?: DeletedMode;
|
|
646
703
|
}
|
|
647
704
|
|
|
648
705
|
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
|
}
|