@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +96 -1
  2. package/package.json +46 -12
  3. package/src/adapters/EverystackAdapter.ts +127 -12
  4. package/src/adapters/types.ts +22 -5
  5. package/src/components/AdminLink.tsx +59 -0
  6. package/src/components/CellText.tsx +18 -0
  7. package/src/components/DeletePreview.tsx +109 -0
  8. package/src/components/FieldColumns.tsx +86 -0
  9. package/src/components/FormActionBar.tsx +45 -0
  10. package/src/components/InlineEnumCell.tsx +98 -0
  11. package/src/components/RelationFieldInput.tsx +159 -0
  12. package/src/components/RelationValue.tsx +26 -0
  13. package/src/components/RowActionButton.tsx +24 -9
  14. package/src/components/TagsDisplay.tsx +22 -0
  15. package/src/components/TagsFieldInput.tsx +95 -0
  16. package/src/editors/rich-html-extensions.ts +63 -0
  17. package/src/editors/tiptap.tsx +121 -20
  18. package/src/editors/unsupported-markup.ts +150 -0
  19. package/src/hooks/useAdminData.ts +108 -24
  20. package/src/hooks/useAdminNavigation.ts +19 -0
  21. package/src/hooks/useListQuery.ts +47 -0
  22. package/src/hooks/useRelationLabels.ts +131 -0
  23. package/src/hooks/useScopeCounts.ts +77 -0
  24. package/src/hooks/useScrollToFirstError.ts +74 -0
  25. package/src/index.ts +17 -0
  26. package/src/screens/AdminRouter.tsx +30 -4
  27. package/src/screens/ResourceCreate.tsx +87 -37
  28. package/src/screens/ResourceEdit.tsx +93 -42
  29. package/src/screens/ResourceList.tsx +241 -53
  30. package/src/screens/ResourceShow.tsx +130 -22
  31. package/src/types.ts +197 -4
  32. package/src/utils/format-cell-value.ts +5 -0
  33. package/src/utils/list-query.ts +202 -0
  34. package/src/utils/required.ts +10 -0
  35. package/src/utils/resolve-columns.ts +107 -0
  36. package/src/utils/row-actions.ts +28 -0
  37. package/src/utils/scope-counts.ts +65 -0
  38. package/src/utils/scopes.ts +38 -0
  39. package/src/utils/standard-actions.ts +41 -5
  40. package/src/utils/tags.ts +40 -0
  41. package/src/utils/view-filters.ts +4 -2
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Scope counts, planned. Tabs that are the values of one enum column (`state: 'eq.draft'`,
3
+ * `state: 'eq.published'`) share ONE grouped query; every other tab costs one count of its
4
+ * own. Pure, so the hook only has to run the plan.
5
+ */
6
+
7
+ import type { ScopeConfig } from '../types';
8
+
9
+ /** One grouped query: count rows by `column`, then hand each value's count to its tab. */
10
+ export interface GroupedCount {
11
+ column: string;
12
+ /** Column value -> id of the tab that shows it. */
13
+ scopeIdByValue: Record<string, string>;
14
+ }
15
+
16
+ export interface ScopeCountPlan {
17
+ grouped: GroupedCount[];
18
+ /** Tabs that need a count query of their own, in the order given. */
19
+ single: ScopeConfig[];
20
+ }
21
+
22
+ /** `{ state: 'eq.draft' }` is the one shape that can ride a grouped query. */
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;
26
+ const entries = Object.entries(scope.filter ?? {});
27
+ if (entries.length !== 1) return null;
28
+ const [column, raw] = entries[0];
29
+ if (typeof raw !== 'string' || !raw.startsWith('eq.') || raw.length === 3) return null;
30
+ return { column, value: raw.slice(3) };
31
+ }
32
+
33
+ export function planScopeCounts(scopes: ScopeConfig[]): ScopeCountPlan {
34
+ const byColumn = new Map<string, Array<{ scope: ScopeConfig; value: string }>>();
35
+ for (const scope of scopes) {
36
+ const eq = equality(scope);
37
+ if (!eq) continue;
38
+ const members = byColumn.get(eq.column) ?? [];
39
+ // Two tabs on one value cannot share a row of the answer: the later one counts alone.
40
+ if (!members.some(m => m.value === eq.value)) members.push({ scope, value: eq.value });
41
+ byColumn.set(eq.column, members);
42
+ }
43
+
44
+ const grouped: GroupedCount[] = [];
45
+ const inGroup = new Set<ScopeConfig>();
46
+ for (const [column, members] of byColumn) {
47
+ if (members.length < 2) continue;
48
+ grouped.push({
49
+ column,
50
+ scopeIdByValue: Object.fromEntries(members.map(m => [m.value, m.scope.id])),
51
+ });
52
+ for (const m of members) inGroup.add(m.scope);
53
+ }
54
+ return { grouped, single: scopes.filter(scope => !inGroup.has(scope)) };
55
+ }
56
+
57
+ /** Each tab of the group gets its value's count; a value with no rows is zero, not missing. */
58
+ export function countsFromGrouped(rows: Record<string, unknown>[], group: GroupedCount): Record<string, number> {
59
+ const counts = Object.fromEntries(Object.values(group.scopeIdByValue).map(id => [id, 0]));
60
+ for (const row of rows) {
61
+ const id = group.scopeIdByValue[String(row[group.column])];
62
+ if (id !== undefined) counts[id] = Number(row.count ?? 0);
63
+ }
64
+ return counts;
65
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Scope rules: which scope is active, and building scope tabs from an enum.
3
+ * Pure, so the list screen only has to render the answer.
4
+ */
5
+
6
+ import type { FieldDefinition, ScopeConfig } from '../types';
7
+
8
+ /**
9
+ * The active scope: the one the URL names; else the one marked default (the
10
+ * first, if several are); else the first scope. A URL naming a scope that no
11
+ * longer exists falls back the same way, so an old link still opens a list.
12
+ */
13
+ export function resolveScope(scopes: ScopeConfig[] | undefined, id: string | undefined): ScopeConfig | undefined {
14
+ if (!scopes || scopes.length === 0) return undefined;
15
+ return (
16
+ scopes.find(scope => scope.id === id) ??
17
+ scopes.find(scope => scope.default) ??
18
+ scopes[0]
19
+ );
20
+ }
21
+
22
+ /** `in_review` → `In review`. */
23
+ const labelFor = (value: string): string => {
24
+ const words = value.replace(/_/g, ' ');
25
+ return words.charAt(0).toUpperCase() + words.slice(1);
26
+ };
27
+
28
+ /** One scope per value of an enum field, filtering on equality. Combine with other scopes in the config. */
29
+ export function enumScopes(field: FieldDefinition): ScopeConfig[] {
30
+ if (!field.enumValues || field.enumValues.length === 0) {
31
+ throw new Error(`enumScopes needs an enum field with enumValues; "${field.name}" has none`);
32
+ }
33
+ return field.enumValues.map(value => ({
34
+ id: value,
35
+ label: labelFor(value),
36
+ filter: { [field.name]: `eq.${value}` },
37
+ }));
38
+ }
@@ -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
 
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Value handling for `type: 'tags'` fields (a text[] column).
3
+ *
4
+ * The rule is: never drop or reorder a stored value implicitly. A toggle adds
5
+ * or removes the one value pressed and leaves the rest of the array as it was.
6
+ */
7
+
8
+ import type { FieldDefinition } from '../types';
9
+
10
+ /** The stored value as a string array. null/undefined is []; a lone scalar is kept as one value. */
11
+ export function tagValues(value: unknown): string[] {
12
+ if (value == null) return [];
13
+ if (Array.isArray(value)) return value.map(v => String(v));
14
+ return [String(value)];
15
+ }
16
+
17
+ /** Add `tag` when absent (at the end), remove it when present. Every other value keeps its place. */
18
+ export function toggleTag(values: readonly string[], tag: string): string[] {
19
+ return values.includes(tag) ? values.filter(v => v !== tag) : [...values, tag];
20
+ }
21
+
22
+ /** Every value the field's groups list. */
23
+ function listedTags(field: FieldDefinition): Set<string> {
24
+ return new Set((field.tagGroups ?? []).flatMap(g => g.options.map(o => o.value)));
25
+ }
26
+
27
+ /** The label of `tag` from the field's groups, or the raw value when no group lists it. */
28
+ export function tagLabel(field: FieldDefinition, tag: string): string {
29
+ for (const group of field.tagGroups ?? []) {
30
+ const option = group.options.find(o => o.value === tag);
31
+ if (option) return option.label ?? option.value;
32
+ }
33
+ return tag;
34
+ }
35
+
36
+ /** Stored values no group lists, first occurrence order, once each. */
37
+ export function unlistedTags(field: FieldDefinition, values: readonly string[]): string[] {
38
+ const listed = listedTags(field);
39
+ return [...new Set(values.filter(v => !listed.has(v)))];
40
+ }
@@ -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
  }
@@ -37,7 +39,7 @@ export function filtersToParams(filters: DashboardFilters, dimensions: Dimension
37
39
  }
38
40
 
39
41
  /** Query params reserved for view state (not column filters). */
40
- const RESERVED_PARAMS = new Set(['from', 'to', 'sort', 'order', 'page', 'q']);
42
+ export const RESERVED_PARAMS: ReadonlySet<string> = new Set(['from', 'to', 'sort', 'dir', 'page', 'size', 'q', 'tab']);
41
43
 
42
44
  /**
43
45
  * Pass-through column filters: any query param that names a real resource field