@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/access/resolve.d.ts +1 -0
- package/dist/address-errors.d.ts +16 -0
- package/dist/address-errors.js +59 -0
- package/dist/address-template.d.ts +16 -0
- package/dist/address-template.js +38 -0
- package/dist/app-address.d.ts +26 -0
- package/dist/app-address.js +90 -0
- package/dist/bottom-bar-errors.d.ts +14 -0
- package/dist/bottom-bar-errors.js +33 -0
- package/dist/bottom-bar.d.ts +35 -0
- package/dist/bottom-bar.js +44 -0
- package/dist/field-ref.d.ts +19 -0
- package/dist/field-ref.js +29 -0
- package/dist/index.d.ts +10 -2
- package/dist/index.js +9 -1
- package/dist/layout.d.ts +11 -0
- package/dist/layout.js +14 -0
- package/dist/menu-href.js +5 -0
- package/dist/model.d.ts +94 -3
- package/dist/ref-shape.d.ts +80 -0
- package/dist/ref-shape.js +88 -0
- package/dist/refs.js +5 -1
- package/dist/user-menu.js +2 -1
- package/dist/view-actions.d.ts +10 -4
- package/dist/view-actions.js +24 -8
- package/dist/view-displays.d.ts +42 -0
- package/dist/view-displays.js +61 -0
- package/dist/view.d.ts +10 -3
- package/dist/view.js +22 -8
- package/dist/webhooks/webhook-resources.d.ts +50 -50
- package/dist/webhooks/webhook-resources.js +78 -78
- package/package.json +1 -1
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`)
|
|
158
|
-
* (`link
|
|
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 (
|
|
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,
|
package/dist/view-actions.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
20
|
-
|
|
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
|
package/dist/view-actions.js
CHANGED
|
@@ -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
|
-
/**
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
106
|
+
return createRefs(ref);
|
|
93
107
|
}
|
|
94
108
|
next(patch) {
|
|
95
109
|
return new ViewBuilder({
|