@everystack/admin 0.4.2 → 0.4.3

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.
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Field columns for Show, Edit and Create: which field goes in which column,
3
+ * and how wide each column is. Pure; FieldColumns draws the result.
4
+ */
5
+
6
+ import { editorFor } from './render-resolution';
7
+ import type { EditorRegistry, FieldDefinition, LayoutColumn, ResourceConfig, ScreenLayout } from '../types';
8
+
9
+ /** Width of a 'narrow' column, in px. */
10
+ export const NARROW_COLUMN_WIDTH = 320;
11
+
12
+ /** A column ready to draw: 'wide' takes the space left over, a number is fixed px. */
13
+ export interface ResolvedColumn {
14
+ fields: FieldDefinition[];
15
+ width: 'wide' | number;
16
+ }
17
+
18
+ export type LayoutScreen = 'show' | 'edit' | 'create';
19
+
20
+ /**
21
+ * The layout a screen uses: Show `showLayout ?? layout`, Edit
22
+ * `editLayout ?? layout`, Create `createLayout ?? editLayout ?? layout`.
23
+ * Undefined means the screen renders its single column as before.
24
+ */
25
+ export function screenLayout(resource: ResourceConfig, screen: LayoutScreen): ScreenLayout | undefined {
26
+ switch (screen) {
27
+ case 'show':
28
+ return resource.showLayout ?? resource.layout;
29
+ case 'edit':
30
+ return resource.editLayout ?? resource.layout;
31
+ case 'create':
32
+ return resource.createLayout ?? resource.editLayout ?? resource.layout;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * 'auto' puts a field in the main column when it needs room: a custom render
38
+ * or input, json, image, file, tags, relation, rich text, or a string longer
39
+ * than 255 characters.
40
+ */
41
+ export function isMainField(field: FieldDefinition, editors?: EditorRegistry): boolean {
42
+ if (field.render || field.input) return true;
43
+ switch (field.type) {
44
+ case 'json':
45
+ case 'image':
46
+ case 'file':
47
+ case 'tags':
48
+ case 'relation':
49
+ return true;
50
+ }
51
+ if (field.format === 'html' || field.format === 'markdown') return true;
52
+ if (editorFor(field, editors)) return true;
53
+ return field.type === 'string' && (field.validation?.max ?? 0) > 255;
54
+ }
55
+
56
+ /**
57
+ * Place the screen's fields (already filtered and in screen order) into
58
+ * columns. A listed field the screen does not show is skipped; a field listed
59
+ * twice stays in its first column; a field no column lists goes at the end of
60
+ * the first column. Columns left empty are dropped. Default width: the first
61
+ * column wide, the others narrow.
62
+ */
63
+ export function resolveColumns(
64
+ fields: FieldDefinition[],
65
+ layout: ScreenLayout,
66
+ editors?: EditorRegistry,
67
+ ): ResolvedColumn[] {
68
+ const placed: Array<{ fields: FieldDefinition[]; width?: 'wide' | 'narrow' | number }> =
69
+ layout === 'auto' ? autoColumns(fields, editors) : explicitColumns(fields, layout.columns);
70
+
71
+ return placed
72
+ .filter(col => col.fields.length > 0)
73
+ .map((col, index) => ({ fields: col.fields, width: columnWidth(col.width, index) }));
74
+ }
75
+
76
+ function autoColumns(fields: FieldDefinition[], editors?: EditorRegistry) {
77
+ const main = fields.filter(f => isMainField(f, editors));
78
+ const side = fields.filter(f => !isMainField(f, editors));
79
+ return [{ fields: main }, { fields: side }];
80
+ }
81
+
82
+ function explicitColumns(fields: FieldDefinition[], columns: LayoutColumn[]) {
83
+ const byName = new Map(fields.map(f => [f.name, f]));
84
+ const used = new Set<string>();
85
+ // A column that lists no field is ignored, so it never becomes the first.
86
+ const placed = columns.filter(col => col.fields.length > 0).map(col => {
87
+ const picked: FieldDefinition[] = [];
88
+ for (const name of col.fields) {
89
+ const field = byName.get(name);
90
+ if (!field || used.has(name)) continue;
91
+ used.add(name);
92
+ picked.push(field);
93
+ }
94
+ return { fields: picked, width: col.width };
95
+ });
96
+ const unlisted = fields.filter(f => !used.has(f.name));
97
+ if (placed.length === 0) return [{ fields: unlisted }];
98
+ placed[0] = { ...placed[0], fields: [...placed[0].fields, ...unlisted] };
99
+ return placed;
100
+ }
101
+
102
+ function columnWidth(width: 'wide' | 'narrow' | number | undefined, index: number): 'wide' | number {
103
+ if (typeof width === 'number' && Number.isFinite(width) && width > 0) return width;
104
+ if (width === 'wide') return 'wide';
105
+ if (width === 'narrow') return NARROW_COLUMN_WIDTH;
106
+ return index === 0 ? 'wide' : NARROW_COLUMN_WIDTH;
107
+ }
@@ -19,3 +19,31 @@ export function visibleRowActions(
19
19
  return action.when.in.includes(String(value));
20
20
  });
21
21
  }
22
+
23
+ /**
24
+ * Estimated horizontal chrome of one action Button, in px: px-4 padding on both
25
+ * sides (2 x 16) plus a 1px border on both sides. An estimate, not a measurement.
26
+ */
27
+ const ACTION_BUTTON_CHROME = 34;
28
+ /**
29
+ * Estimated average width of one character in the Button's 14px medium label,
30
+ * in px. Measured in a browser on Show, Edit and Delete (70.3, 59.6 and 74.7px
31
+ * wide), which total 220.5px with the gaps; this gives 237px.
32
+ */
33
+ const ACTION_CHAR_WIDTH = 8.5;
34
+ /** Space between action buttons (the cell's gap-2), in px. */
35
+ const ACTION_GAP = 8;
36
+
37
+ /**
38
+ * Estimated width, in px, of a row of action buttons, each sized from its idle
39
+ * label. A two-step `confirm` label is not counted: it replaces the label only
40
+ * after the first press, and the Actions cell wraps if it does not fit. A
41
+ * resource's actionsWidth overrides this estimate.
42
+ */
43
+ export function estimateActionsWidth(actions: RowActionConfig[]): number {
44
+ const buttons = actions.reduce(
45
+ (sum, action) => sum + ACTION_BUTTON_CHROME + Math.ceil(ACTION_CHAR_WIDTH * action.label.length),
46
+ 0,
47
+ );
48
+ return buttons + Math.max(0, actions.length - 1) * ACTION_GAP;
49
+ }
@@ -0,0 +1,63 @@
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
+ const entries = Object.entries(scope.filter ?? {});
25
+ if (entries.length !== 1) return null;
26
+ const [column, raw] = entries[0];
27
+ if (typeof raw !== 'string' || !raw.startsWith('eq.') || raw.length === 3) return null;
28
+ return { column, value: raw.slice(3) };
29
+ }
30
+
31
+ export function planScopeCounts(scopes: ScopeConfig[]): ScopeCountPlan {
32
+ const byColumn = new Map<string, Array<{ scope: ScopeConfig; value: string }>>();
33
+ for (const scope of scopes) {
34
+ const eq = equality(scope);
35
+ if (!eq) continue;
36
+ const members = byColumn.get(eq.column) ?? [];
37
+ // Two tabs on one value cannot share a row of the answer: the later one counts alone.
38
+ if (!members.some(m => m.value === eq.value)) members.push({ scope, value: eq.value });
39
+ byColumn.set(eq.column, members);
40
+ }
41
+
42
+ const grouped: GroupedCount[] = [];
43
+ const inGroup = new Set<ScopeConfig>();
44
+ for (const [column, members] of byColumn) {
45
+ if (members.length < 2) continue;
46
+ grouped.push({
47
+ column,
48
+ scopeIdByValue: Object.fromEntries(members.map(m => [m.value, m.scope.id])),
49
+ });
50
+ for (const m of members) inGroup.add(m.scope);
51
+ }
52
+ return { grouped, single: scopes.filter(scope => !inGroup.has(scope)) };
53
+ }
54
+
55
+ /** Each tab of the group gets its value's count; a value with no rows is zero, not missing. */
56
+ export function countsFromGrouped(rows: Record<string, unknown>[], group: GroupedCount): Record<string, number> {
57
+ const counts = Object.fromEntries(Object.values(group.scopeIdByValue).map(id => [id, 0]));
58
+ for (const row of rows) {
59
+ const id = group.scopeIdByValue[String(row[group.column])];
60
+ if (id !== undefined) counts[id] = Number(row.count ?? 0);
61
+ }
62
+ return counts;
63
+ }
@@ -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
+ }
@@ -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
+ }
@@ -37,7 +37,7 @@ export function filtersToParams(filters: DashboardFilters, dimensions: Dimension
37
37
  }
38
38
 
39
39
  /** Query params reserved for view state (not column filters). */
40
- const RESERVED_PARAMS = new Set(['from', 'to', 'sort', 'order', 'page', 'q']);
40
+ export const RESERVED_PARAMS: ReadonlySet<string> = new Set(['from', 'to', 'sort', 'dir', 'page', 'size', 'q', 'tab']);
41
41
 
42
42
  /**
43
43
  * Pass-through column filters: any query param that names a real resource field