@protobase/schema 0.9.0 → 0.11.0

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/dist/model.d.ts CHANGED
@@ -71,6 +71,10 @@ export type EventModel = {
71
71
  match?: true;
72
72
  };
73
73
  export type SortSpec = Array<[field: string, direction: 'asc' | 'desc']>;
74
+ /** A value that survives `JSON.stringify` unchanged, as a custom component's props in a view must be. */
75
+ export type JsonValue = string | number | boolean | null | JsonValue[] | {
76
+ [key: string]: JsonValue;
77
+ };
74
78
  export type KeyValue = string | number;
75
79
  export type ListQuery = {
76
80
  filter?: CheckedFilter;
@@ -80,6 +84,29 @@ export type ListQuery = {
80
84
  direction?: 'after' | 'before';
81
85
  limit: number;
82
86
  };
87
+ export type FilterLayout = 'bar' | 'panel';
88
+ /**
89
+ * A place in the app: a resource's list, one of its records, its create form, or a composed page. A record's `key` is
90
+ * its one value or its parts, as the record API takes them; `readAppPath` gives it back as the API's text form.
91
+ */
92
+ export type AppAddress = {
93
+ kind: 'list';
94
+ resource: string;
95
+ filter?: string;
96
+ orderBy?: string;
97
+ display?: string;
98
+ filterLayout?: FilterLayout;
99
+ } | {
100
+ kind: 'record';
101
+ resource: string;
102
+ key: KeyValue | readonly KeyValue[];
103
+ } | {
104
+ kind: 'create';
105
+ resource: string;
106
+ } | {
107
+ kind: 'page';
108
+ page: string;
109
+ };
83
110
  export type FieldFormat = 'relative' | 'absolute' | 'compact' | 'percent' | 'badge' | 'code';
84
111
  export type FieldViewModel = {
85
112
  label?: string;
@@ -90,11 +117,37 @@ export type FieldViewModel = {
90
117
  /** What an enum value reads as wherever it is shown; a value without one reads as itself in words (`enumLabel`). */
91
118
  valueLabels?: Record<string, string>;
92
119
  };
120
+ /**
121
+ * A way to show a list besides its table: a card per record, or a project component (`custom`) drawing the rows the
122
+ * list page loads. `name` is what the address names (`?display=cards`); `label` and `icon` are its switch's.
123
+ */
124
+ export type ListDisplayModel = {
125
+ kind: 'cards';
126
+ name: string;
127
+ label?: string;
128
+ icon?: string;
129
+ title?: string;
130
+ subtitle?: string;
131
+ badge?: string;
132
+ meta?: string[];
133
+ } | {
134
+ kind: 'custom';
135
+ name: string;
136
+ label: string;
137
+ icon?: string;
138
+ component: string;
139
+ fields: string[];
140
+ props?: Record<string, JsonValue>;
141
+ };
93
142
  export type ListModel = {
94
143
  columns: string[];
95
144
  sort?: SortSpec;
96
145
  /** Defaults for the UI's search box. `ResourceModel.search` is authoritative for what `search(...)` matches on the server. */
97
146
  search?: string[];
147
+ /** Other ways to show the list besides its table, in the order the switch shows them after it. */
148
+ displays?: ListDisplayModel[];
149
+ /** The display the list opens in when the address names none; the table when omitted. */
150
+ default?: string;
98
151
  };
99
152
  export type DatePreset = 'today' | 'yesterday' | '7d' | '30d' | '90d' | 'month' | 'quarter' | 'year';
100
153
  export type FilterWidgetModel = {
@@ -136,6 +189,17 @@ export type RelatedModel = {
136
189
  pageSize?: number;
137
190
  empty?: string;
138
191
  };
192
+ /**
193
+ * A project component drawn on the record page where it is listed, in the main column or with `sidebar` in the
194
+ * sidebar, under `title` when it has one. It gets `props` and reads the record with `useLayoutRecord()`.
195
+ */
196
+ export type CustomSectionModel = {
197
+ kind: 'custom';
198
+ component: string;
199
+ title?: string;
200
+ props?: Record<string, JsonValue>;
201
+ sidebar?: true;
202
+ };
139
203
  export type LayoutItemModel = {
140
204
  kind: 'section';
141
205
  title: string;
@@ -144,7 +208,7 @@ export type LayoutItemModel = {
144
208
  } | {
145
209
  kind: 'sidebar';
146
210
  fields: string[];
147
- } | RelatedModel;
211
+ } | RelatedModel | CustomSectionModel;
148
212
  export type ChartRange = '7d' | '30d' | '90d' | '1y' | 'all';
149
213
  export type ChartGranularity = 'hour' | 'day' | 'week' | 'month';
150
214
  export type ChartModel = {
@@ -154,14 +218,18 @@ export type ChartModel = {
154
218
  };
155
219
  export type SaveFeedback = 'toast' | 'button';
156
220
  /**
157
- * What a named action does in the app: set fields of the record (`update`), delete it (`delete`) or open a link
158
- * (`link`, with `{field}` replaced by the record's values). Without it the app runs a handler it registered by name.
221
+ * What a named action does in the app: set fields of the record (`update`), delete it (`delete`), go to an address in
222
+ * the app (`link` with `to`) or open a link that leaves it (`link` with `href`), `{field}` replaced by the record's
223
+ * values. Without it the app runs a handler it registered by name.
159
224
  */
160
225
  export type ActionRun = {
161
226
  kind: 'update';
162
227
  values: Record<string, unknown>;
163
228
  } | {
164
229
  kind: 'delete';
230
+ } | {
231
+ kind: 'link';
232
+ to: AppAddress;
165
233
  } | {
166
234
  kind: 'link';
167
235
  href: string;
@@ -218,6 +286,29 @@ export type UserMenuItemModel = {
218
286
  export type UserMenuModel = {
219
287
  items: UserMenuItemModel[];
220
288
  };
289
+ /** A tab of a bottom bar on phones: icon above an explicit label. */
290
+ export type BottomBarItemModel = {
291
+ kind: 'resource';
292
+ resource: string;
293
+ label: string;
294
+ icon?: string;
295
+ } | {
296
+ kind: 'page';
297
+ page: string;
298
+ label: string;
299
+ icon?: string;
300
+ } | {
301
+ kind: 'link';
302
+ label: string;
303
+ href: string;
304
+ icon?: string;
305
+ };
306
+ /** A bottom bar for the users holding one of `roles`; `label` names it in the role view switcher. */
307
+ export type BottomBarModel = {
308
+ label: string;
309
+ roles: string[];
310
+ items: BottomBarItemModel[];
311
+ };
221
312
  /**
222
313
  * The title replaces `title` in search results; `subtitle` is the second line unless the search matched another search
223
314
  * field, which then shows instead. Without a subtitle, the first search field not in the title is the default.
@@ -0,0 +1,80 @@
1
+ //#region src/ref-shape.d.ts
2
+ /**
3
+ * Where a part of a model names fields, and what a field the caller cannot read does to it. A shape declares every part
4
+ * of the model it walks: a part it does not declare is left out, so a new part cannot reach a caller unchecked.
5
+ */
6
+ export type RefShape =
7
+ /** Plain data that names no field: kept as it is. */
8
+ {
9
+ kind: 'data';
10
+ } |
11
+ /** A field's name: left out when hidden. */
12
+ {
13
+ kind: 'field';
14
+ } |
15
+ /** Free-form data, such as a custom component's props, with field references (`{ $field }`) anywhere: left out when one is hidden. */
16
+ {
17
+ kind: 'refs';
18
+ } |
19
+ /** An object keyed by field names, such as field hints: hidden keys are left out, or with `all` the whole object. */
20
+ {
21
+ kind: 'keys';
22
+ all: boolean;
23
+ } |
24
+ /** A list: entries left out are dropped, and with `nonEmpty` a list left empty is left out too. */
25
+ {
26
+ kind: 'list';
27
+ of: RefShape;
28
+ nonEmpty: boolean;
29
+ } |
30
+ /** A fixed list, such as `[field, direction]`: left out when any entry is. */
31
+ {
32
+ kind: 'tuple';
33
+ items: RefShape[];
34
+ } |
35
+ /** An object: a part left out is dropped, a `requires` part left out leaves out the object; `after` tidies the result. */
36
+ {
37
+ kind: 'object';
38
+ props: Record<string, RefShape>;
39
+ requires: string[];
40
+ after?: (value: never) => unknown;
41
+ } |
42
+ /** One shape per value of `kind`, as layout items and displays are told apart; an unknown kind is left out. */
43
+ {
44
+ kind: 'oneOf';
45
+ cases: Record<string, RefShape>;
46
+ } |
47
+ /** A part pruned by code, such as one naming another resource's fields: `prune` returns it narrowed, or `undefined`. */
48
+ {
49
+ kind: 'map';
50
+ prune: (value: never) => unknown;
51
+ };
52
+ /** One shape per property of `T`, so the compiler names a property the shape forgets. */
53
+ export type PropShapes<T> = { [K in keyof T]-?: RefShape; };
54
+ /** The shapes, typed against the model they describe. */
55
+ export declare const shapes: {
56
+ data: RefShape;
57
+ field: RefShape;
58
+ refs: RefShape;
59
+ keys: (o?: {
60
+ all?: boolean;
61
+ }) => RefShape;
62
+ list: (of: RefShape, o?: {
63
+ nonEmpty?: boolean;
64
+ }) => RefShape;
65
+ tuple: (...items: RefShape[]) => RefShape;
66
+ object: <T extends object>(props: PropShapes<T>, o?: {
67
+ requires?: Array<keyof T & string>;
68
+ after?: (value: T) => T;
69
+ }) => RefShape;
70
+ oneOf: <T extends {
71
+ kind: string;
72
+ }>(cases: { [K in T["kind"]]: RefShape; }) => RefShape;
73
+ map: <T>(prune: (value: T) => T | undefined) => RefShape;
74
+ };
75
+ /**
76
+ * `value` with every part naming a field `visible` refuses left out, as `shape` says, or `undefined` when the whole
77
+ * value goes. One walker for every model the app sends: views, custom component props and composed pages' custom nodes.
78
+ */
79
+ export declare const pruneByShape: <T>(value: T, shape: RefShape, visible: (name: string) => boolean) => T | undefined;
80
+ //#endregion
@@ -0,0 +1,88 @@
1
+ import { fieldRefsIn } from "./field-ref.js";
2
+ //#region src/ref-shape.ts
3
+ /** The shapes, typed against the model they describe. */
4
+ const shapes = {
5
+ data: { kind: "data" },
6
+ field: { kind: "field" },
7
+ refs: { kind: "refs" },
8
+ keys: (o = {}) => ({
9
+ kind: "keys",
10
+ all: o.all ?? false
11
+ }),
12
+ list: (of, o = {}) => ({
13
+ kind: "list",
14
+ of,
15
+ nonEmpty: o.nonEmpty ?? false
16
+ }),
17
+ tuple: (...items) => ({
18
+ kind: "tuple",
19
+ items
20
+ }),
21
+ object: (props, o = {}) => ({
22
+ kind: "object",
23
+ props,
24
+ requires: o.requires ?? [],
25
+ ...o.after && { after: o.after }
26
+ }),
27
+ oneOf: (cases) => ({
28
+ kind: "oneOf",
29
+ cases
30
+ }),
31
+ map: (prune) => ({
32
+ kind: "map",
33
+ prune
34
+ })
35
+ };
36
+ const gone = Symbol("gone");
37
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
38
+ const walk = (value, shape, visible) => {
39
+ switch (shape.kind) {
40
+ case "data": return value;
41
+ case "field": return typeof value === "string" && visible(value) ? value : gone;
42
+ case "refs": return fieldRefsIn(value).every(visible) ? value : gone;
43
+ case "keys": {
44
+ if (!isObject(value)) return gone;
45
+ const entries = Object.entries(value);
46
+ const kept = entries.filter(([name]) => visible(name));
47
+ return shape.all && kept.length < entries.length ? gone : Object.fromEntries(kept);
48
+ }
49
+ case "list": {
50
+ if (!Array.isArray(value)) return gone;
51
+ const kept = value.map((entry) => walk(entry, shape.of, visible)).filter((entry) => entry !== gone);
52
+ return shape.nonEmpty && kept.length === 0 ? gone : kept;
53
+ }
54
+ case "tuple": {
55
+ if (!Array.isArray(value) || value.length !== shape.items.length) return gone;
56
+ const items = value.map((entry, index) => walk(entry, shape.items[index], visible));
57
+ return items.includes(gone) ? gone : items;
58
+ }
59
+ case "object": {
60
+ if (!isObject(value)) return gone;
61
+ const kept = {};
62
+ for (const [name, entry] of Object.entries(value)) {
63
+ const part = Object.hasOwn(shape.props, name) ? shape.props[name] : void 0;
64
+ if (!part || entry === void 0) continue;
65
+ const pruned = walk(entry, part, visible);
66
+ if (pruned === gone && shape.requires.includes(name)) return gone;
67
+ if (pruned !== gone) kept[name] = pruned;
68
+ }
69
+ return shape.after ? shape.after(kept) : kept;
70
+ }
71
+ case "oneOf": {
72
+ const kind = isObject(value) && typeof value.kind === "string" ? value.kind : void 0;
73
+ const shaped = kind !== void 0 && Object.hasOwn(shape.cases, kind) ? shape.cases[kind] : void 0;
74
+ return shaped ? walk(value, shaped, visible) : gone;
75
+ }
76
+ case "map": return shape.prune(value) ?? gone;
77
+ }
78
+ };
79
+ /**
80
+ * `value` with every part naming a field `visible` refuses left out, as `shape` says, or `undefined` when the whole
81
+ * value goes. One walker for every model the app sends: views, custom component props and composed pages' custom nodes.
82
+ */
83
+ const pruneByShape = (value, shape, visible) => {
84
+ const pruned = walk(value, shape, visible);
85
+ return pruned === gone ? void 0 : pruned;
86
+ };
87
+ //#endregion
88
+ export { pruneByShape, shapes };
package/dist/refs.js CHANGED
@@ -1,8 +1,12 @@
1
1
  //#region src/refs.ts
2
+ const refMark = Symbol("protobase.ref");
3
+ /** A field reference as a view builder hands it out (`r.status`), recognisable wherever it is passed, props included. */
4
+ const ref = (name) => Object.defineProperty({ name }, refMark, { value: true });
5
+ const isRef = (value) => typeof value === "object" && value !== null && value[refMark] === true;
2
6
  const createRefs = (make, known) => new Proxy({}, { get: (_, name) => {
3
7
  if (typeof name === "symbol") return void 0;
4
8
  if (known && !known.includes(name)) throw new Error(`Unknown field "${name}"`);
5
9
  return make(name);
6
10
  } });
7
11
  //#endregion
8
- export { createRefs };
12
+ export { createRefs, isRef, ref };
package/dist/user-menu.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { isMenuHref } from "./menu-href.js";
1
2
  //#region src/user-menu.ts
2
3
  const items = {
3
4
  /** Opens the resource's list. A resource in the user menu is left out of the sidebar. */
@@ -14,7 +15,7 @@ const items = {
14
15
  }),
15
16
  /** An address outside the admin, opened in a new tab: `https:`, `http:` or `mailto:`. */
16
17
  link: (label, href, options = {}) => {
17
- if (!/^(https?:\/\/|mailto:)/i.test(href)) throw new Error(`User menu link "${label}": href must start with https://, http:// or mailto:`);
18
+ if (!isMenuHref(href)) throw new Error(`User menu link "${label}": href must start with https://, http:// or mailto:`);
18
19
  return {
19
20
  kind: "link",
20
21
  label,
@@ -1,4 +1,4 @@
1
- import { ActionModel } from "./model.js";
1
+ import { ActionModel, AppAddress } from "./model.js";
2
2
  //#region src/view-actions.d.ts
3
3
  type ActionOptions = {
4
4
  label: string;
@@ -16,9 +16,15 @@ export declare const actions: {
16
16
  }) => ActionModel;
17
17
  /** Deletes the record (soft delete when the resource has one). */
18
18
  remove: (name: string, o: ActionOptions) => ActionModel;
19
- /** Opens `href`: a path in the app or a URL; `{field}` is replaced by the record's value, such as `/orders/{orderId}`. */
20
- link: (name: string, o: Omit<ActionOptions, "confirm"> & {
19
+ /**
20
+ * Goes `to` an address in the app, such as `{ kind: 'record', resource: 'orders', key: '{order_id}' }`, or opens an
21
+ * `href` that leaves it (`https:`, `mailto:`); `{field}` is replaced by the record's value. A path in the app is
22
+ * refused as an `href`, so every address in the app is written one way.
23
+ */
24
+ link: (name: string, o: Omit<ActionOptions, "confirm"> & ({
25
+ to: AppAddress;
26
+ } | {
21
27
  href: string;
22
- }) => ActionModel;
28
+ })) => ActionModel;
23
29
  };
24
30
  //#endregion
@@ -1,3 +1,4 @@
1
+ import { appHrefError } from "./address-template.js";
1
2
  //#region src/view-actions.ts
2
3
  const named = (name, o) => ({
3
4
  name,
@@ -22,14 +23,29 @@ const actions = {
22
23
  ...named(name, o),
23
24
  run: { kind: "delete" }
24
25
  }),
25
- /** Opens `href`: a path in the app or a URL; `{field}` is replaced by the record's value, such as `/orders/{orderId}`. */
26
- link: (name, o) => ({
27
- ...named(name, o),
28
- run: {
29
- kind: "link",
30
- href: o.href
31
- }
32
- })
26
+ /**
27
+ * Goes `to` an address in the app, such as `{ kind: 'record', resource: 'orders', key: '{order_id}' }`, or opens an
28
+ * `href` that leaves it (`https:`, `mailto:`); `{field}` is replaced by the record's value. A path in the app is
29
+ * refused as an `href`, so every address in the app is written one way.
30
+ */
31
+ link: (name, o) => {
32
+ if ("to" in o) return {
33
+ ...named(name, o),
34
+ run: {
35
+ kind: "link",
36
+ to: structuredClone(o.to)
37
+ }
38
+ };
39
+ const error = appHrefError(o.href);
40
+ if (error) throw new Error(`Action "${name}": ${error}`);
41
+ return {
42
+ ...named(name, o),
43
+ run: {
44
+ kind: "link",
45
+ href: o.href
46
+ }
47
+ };
48
+ }
33
49
  };
34
50
  //#endregion
35
51
  export { actions };
@@ -0,0 +1,42 @@
1
+ import { ListDisplayModel } from "./model.js";
2
+ import { Ref } from "./refs.js";
3
+ //#region src/view-displays.d.ts
4
+ /** A custom component as `component(name)` from `@protobase/layout` declares it. */
5
+ export type ComponentRef<P> = ((props: P) => unknown) & {
6
+ readonly blockName: string;
7
+ readonly custom: boolean;
8
+ };
9
+ /** A custom component's props as a view gives them: plain data, or `r.field` where it takes a field's name. */
10
+ export type ViewProps<P> = { [K in keyof Omit<P, 'children'>]: P[K] extends string ? P[K] | Ref : P[K]; };
11
+ type Naming = {
12
+ name?: string;
13
+ label?: string;
14
+ icon?: string;
15
+ };
16
+ /** The name of the list's own display, which every list has. */
17
+ export declare const tableDisplay = "table";
18
+ export declare const displays: {
19
+ /**
20
+ * A card per record: `title` (default: the view's title) over `subtitle`, `badge` drawn as a badge with its value
21
+ * labels, and a few small `meta` fields such as dates. Named `cards` unless `name` says otherwise.
22
+ */
23
+ cards: (fields: {
24
+ title?: Ref;
25
+ subtitle?: Ref;
26
+ badge?: Ref;
27
+ meta?: Ref[];
28
+ }, o?: Naming) => ListDisplayModel;
29
+ /**
30
+ * The list drawn by a component of the project, registered in `protobase.ui.tsx` under its name. It gets `props` and
31
+ * reads the rows with `useListDisplay()`; `fields` are the fields it shows, fetched with relation names.
32
+ */
33
+ custom: <P extends object>(component: ComponentRef<P>, o: Naming & {
34
+ name: string;
35
+ label: string;
36
+ fields: Ref[];
37
+ props?: ViewProps<P>;
38
+ }) => ListDisplayModel;
39
+ };
40
+ /** Every field a display names: what it shows, and the field references in a custom display's props. */
41
+ export declare const displayFields: (display: ListDisplayModel) => string[];
42
+ //#endregion
@@ -0,0 +1,61 @@
1
+ import { fieldRefsIn, markFieldRefs } from "./field-ref.js";
2
+ //#region src/view-displays.ts
3
+ const naming = ({ label, icon }) => ({
4
+ ...label !== void 0 && { label },
5
+ ...icon !== void 0 && { icon }
6
+ });
7
+ /** The name of the list's own display, which every list has. */
8
+ const tableDisplay = "table";
9
+ /** The component of a custom display or section, which must be a project's, not a built-in block. */
10
+ const customComponent = (component, where) => {
11
+ if (!component.custom) throw new Error(`${where} takes a component declared with component(...), not the built-in block ${component.blockName}`);
12
+ return component.blockName;
13
+ };
14
+ const displays = {
15
+ /**
16
+ * A card per record: `title` (default: the view's title) over `subtitle`, `badge` drawn as a badge with its value
17
+ * labels, and a few small `meta` fields such as dates. Named `cards` unless `name` says otherwise.
18
+ */
19
+ cards: (fields, o = {}) => ({
20
+ kind: "cards",
21
+ name: o.name ?? "cards",
22
+ ...naming(o),
23
+ ...fields.title && { title: fields.title.name },
24
+ ...fields.subtitle && { subtitle: fields.subtitle.name },
25
+ ...fields.badge && { badge: fields.badge.name },
26
+ ...fields.meta && { meta: fields.meta.map((ref) => ref.name) }
27
+ }),
28
+ /**
29
+ * The list drawn by a component of the project, registered in `protobase.ui.tsx` under its name. It gets `props` and
30
+ * reads the rows with `useListDisplay()`; `fields` are the fields it shows, fetched with relation names.
31
+ */
32
+ custom: (component, o) => ({
33
+ kind: "custom",
34
+ name: o.name,
35
+ label: o.label,
36
+ ...naming({ icon: o.icon }),
37
+ component: customComponent(component, "d.custom"),
38
+ fields: o.fields.map((ref) => ref.name),
39
+ ...o.props && { props: markFieldRefs(o.props) }
40
+ })
41
+ };
42
+ const displayName = /^[a-z][a-zA-Z0-9-]*$/;
43
+ /** What is wrong with a list's displays and default, by their names alone. */
44
+ const displayErrors = (list) => {
45
+ const names = (list.displays ?? []).map((display) => display.name);
46
+ return [
47
+ ...names.filter((name) => !displayName.test(name)).map((name) => `display name "${name}" must start with a lowercase letter and hold only letters, digits and dashes`),
48
+ ...names.filter((name) => name === tableDisplay).map(() => `"${tableDisplay}" is the list's own display; give the display another name`),
49
+ ...names.filter((name, index) => names.indexOf(name) !== index).map((name) => `display "${name}" is defined twice`),
50
+ ...list.default === void 0 || list.default === "table" || names.includes(list.default) ? [] : [`default "${list.default}" is not one of the displays (${[tableDisplay, ...names].join(", ")})`]
51
+ ];
52
+ };
53
+ /** Every field a display names: what it shows, and the field references in a custom display's props. */
54
+ const displayFields = (display) => display.kind === "cards" ? [
55
+ display.title,
56
+ display.subtitle,
57
+ display.badge,
58
+ ...display.meta ?? []
59
+ ].filter((name) => name !== void 0) : [...display.fields, ...fieldRefsIn(display.props)];
60
+ //#endregion
61
+ export { customComponent, displayErrors, displayFields, displays, tableDisplay };
package/dist/view.d.ts CHANGED
@@ -1,6 +1,7 @@
1
- import { ActionModel, ChartGranularity, ChartRange, FilterWidgetModel, LayoutItemModel, NavModel, NavRecentModel, ResourceModel, SaveFeedback, ViewModel } from "./model.js";
1
+ import { ActionModel, ChartGranularity, ChartRange, FilterWidgetModel, LayoutItemModel, ListDisplayModel, NavModel, NavRecentModel, ResourceModel, SaveFeedback, ViewModel } from "./model.js";
2
2
  import { FieldRefs, Ref } from "./refs.js";
3
3
  import { actions } from "./view-actions.js";
4
+ import { displays } from "./view-displays.js";
4
5
  import { FieldView } from "./view-field.js";
5
6
  import { widgets } from "./view-widgets.js";
6
7
  //#region src/view.d.ts
@@ -44,10 +45,16 @@ export declare class ViewBuilder<R extends {
44
45
  nav(nav: NavInput<R>): ViewBuilder<R>;
45
46
  help(help: string): ViewBuilder<R>;
46
47
  fields(build: (r: FieldViews<R>) => Record<string, FieldView>): ViewBuilder<R>;
47
- list(build: (r: FieldRefs<FieldsOf<R>>) => {
48
+ /**
49
+ * The list: its table's `columns`, `sort` and `search`, and other `displays` to switch to (`d.cards`, `d.custom`), one
50
+ * of which may be the `default` the list opens in. Throws when display names collide or `default` names none.
51
+ */
52
+ list(build: (r: FieldRefs<FieldsOf<R>>, d: typeof displays) => {
48
53
  columns: Ref[];
49
54
  sort?: Array<[Ref, 'asc' | 'desc']>;
50
55
  search?: Ref[];
56
+ displays?: ListDisplayModel[];
57
+ default?: string;
51
58
  }): ViewBuilder<R>;
52
59
  filters(build: (r: FieldRefs<FieldsOf<R>>, w: typeof widgets) => FilterWidgetModel[]): ViewBuilder<R>;
53
60
  layout(build: (r: FieldRefs<FieldsOf<R>>) => LayoutItemModel[]): ViewBuilder<R>;
@@ -58,7 +65,7 @@ export declare class ViewBuilder<R extends {
58
65
  }): ViewBuilder<R>;
59
66
  saveFeedback(saveFeedback: SaveFeedback): ViewBuilder<R>;
60
67
  actions(build: (a: typeof actions) => ActionModel[]): ViewBuilder<R>;
61
- /** With the resource's model, also verifies the title and search result fields and the sidebar's `recent` group. */
68
+ /** With the resource's model, also verifies the title, search result and list display fields and the sidebar's `recent` group. */
62
69
  toModel(resource?: ResourceModel): ViewModel;
63
70
  private refs;
64
71
  private next;
package/dist/view.js CHANGED
@@ -1,4 +1,5 @@
1
- import { createRefs } from "./refs.js";
1
+ import { createRefs, ref } from "./refs.js";
2
+ import { displayErrors, displayFields, displays } from "./view-displays.js";
2
3
  import { navRecentErrors } from "./nav-recent.js";
3
4
  import { actions } from "./view-actions.js";
4
5
  import { FieldView } from "./view-field.js";
@@ -50,13 +51,22 @@ var ViewBuilder = class ViewBuilder {
50
51
  ...fields
51
52
  } });
52
53
  }
54
+ /**
55
+ * The list: its table's `columns`, `sort` and `search`, and other `displays` to switch to (`d.cards`, `d.custom`), one
56
+ * of which may be the `default` the list opens in. Throws when display names collide or `default` names none.
57
+ */
53
58
  list(build) {
54
- const { columns, sort, search } = build(this.refs());
55
- return this.next({ list: {
59
+ const { columns, sort, search, displays: shown = [], default: opens } = build(this.refs(), displays);
60
+ const list = {
56
61
  columns: columns.map((ref) => ref.name),
57
62
  ...sort && { sort: sort.map(([ref, dir]) => [ref.name, dir]) },
58
- ...search && { search: search.map((ref) => ref.name) }
59
- } });
63
+ ...search && { search: search.map((ref) => ref.name) },
64
+ ...shown.length > 0 && { displays: shown },
65
+ ...opens !== void 0 && { default: opens }
66
+ };
67
+ const errors = displayErrors(list);
68
+ if (errors.length > 0) throw new Error(`View "${this.state.resource}": ${errors.join("; ")}`);
69
+ return this.next({ list });
60
70
  }
61
71
  filters(build) {
62
72
  return this.next({ filters: build(this.refs(), widgets) });
@@ -78,18 +88,22 @@ var ViewBuilder = class ViewBuilder {
78
88
  actions(build) {
79
89
  return this.next({ actions: build(actions) });
80
90
  }
81
- /** With the resource's model, also verifies the title and search result fields and the sidebar's `recent` group. */
91
+ /** With the resource's model, also verifies the title, search result and list display fields and the sidebar's `recent` group. */
82
92
  toModel(resource) {
83
- const { title, nav, searchResult } = this.state;
93
+ const { title, nav, searchResult, list } = this.state;
84
94
  if (resource && title !== void 0 && !Object.hasOwn(resource.fields, title)) throw new Error(`View "${this.state.resource}": title field "${title}" does not exist on the resource`);
85
95
  const missing = resource ? [...searchResult?.title ?? [], ...searchResult?.subtitle ? [searchResult.subtitle] : []].filter((name) => !Object.hasOwn(resource.fields, name)) : [];
86
96
  if (missing.length > 0) throw new Error(`View "${this.state.resource}": search result field "${missing[0]}" does not exist on the resource`);
97
+ for (const display of resource ? list?.displays ?? [] : []) {
98
+ const unknown = displayFields(display).find((name) => !Object.hasOwn(resource.fields, name));
99
+ if (unknown !== void 0) throw new Error(`View "${this.state.resource}": display "${display.name}" names field "${unknown}", which the resource does not have`);
100
+ }
87
101
  const recentErrors = resource && nav?.recent ? navRecentErrors(nav.recent, resource) : [];
88
102
  if (recentErrors.length > 0) throw new Error(`View "${this.state.resource}": nav.recent ${recentErrors.join("; ")}`);
89
103
  return structuredClone(this.state);
90
104
  }
91
105
  refs() {
92
- return createRefs((name) => ({ name }));
106
+ return createRefs(ref);
93
107
  }
94
108
  next(patch) {
95
109
  return new ViewBuilder({