@protobase/schema 0.10.0 → 0.11.1
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/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/field-ref.d.ts +19 -0
- package/dist/field-ref.js +29 -0
- package/dist/index.d.ts +9 -2
- package/dist/index.js +8 -1
- package/dist/layout.d.ts +11 -0
- package/dist/layout.js +14 -0
- package/dist/model.d.ts +71 -3
- package/dist/plugin-version.d.ts +22 -0
- package/dist/plugin-version.js +48 -0
- package/dist/ref-shape.d.ts +80 -0
- package/dist/ref-shape.js +88 -0
- package/dist/refs.js +5 -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/package.json +1 -1
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { AppAddress, ResourceModel } from "./model.js";
|
|
2
|
+
//#region src/address-errors.d.ts
|
|
3
|
+
export type AddressCheck = {
|
|
4
|
+
/** The resources; against a caller's restricted models, the check says whether that caller can follow the link. */
|
|
5
|
+
models: Record<string, ResourceModel>;
|
|
6
|
+
/** The composed pages; without them a page's name is not checked. */
|
|
7
|
+
pages?: ReadonlySet<string>;
|
|
8
|
+
/** The resource of the record the link sits on, whose fields its `{field}` placeholders name. */
|
|
9
|
+
source?: ResourceModel;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* What is wrong with a link's address against these models: an unknown resource or page, a `{field}` the record it sits
|
|
13
|
+
* on does not have, or a filter or order naming fields the resource does not have. Empty when it can be followed.
|
|
14
|
+
*/
|
|
15
|
+
export declare const addressErrors: (to: AppAddress, { models, pages, source }: AddressCheck) => string[];
|
|
16
|
+
//#endregion
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { parseFilter } from "./filter/parse.js";
|
|
2
|
+
import { checkFilter } from "./filter/check.js";
|
|
3
|
+
import { checkOrderBy, parseOrderBy } from "./filter/order-by.js";
|
|
4
|
+
import { addressPlaceholders } from "./address-template.js";
|
|
5
|
+
//#region src/address-errors.ts
|
|
6
|
+
const nameErrors = [
|
|
7
|
+
"unknown-field",
|
|
8
|
+
"not-filterable",
|
|
9
|
+
"not-sortable",
|
|
10
|
+
"unsupported-traversal",
|
|
11
|
+
"invalid-traversal"
|
|
12
|
+
];
|
|
13
|
+
const withSamples = (filter) => filter.replace(/(["']?)\{[A-Za-z_][A-Za-z0-9_]*\}\1/g, (_, quote) => quote ? `${quote}x${quote}` : "0");
|
|
14
|
+
const filterErrors = (model, filter) => {
|
|
15
|
+
const parsed = parseFilter(withSamples(filter));
|
|
16
|
+
if (!parsed.ok) return parsed.errors.map((error) => `filter: ${error.message}`);
|
|
17
|
+
if (!parsed.ast) return [];
|
|
18
|
+
const checked = checkFilter(model, parsed.ast);
|
|
19
|
+
return checked.ok ? [] : checked.errors.filter((error) => nameErrors.includes(error.code)).map((error) => `filter: ${error.message}`);
|
|
20
|
+
};
|
|
21
|
+
const orderErrors = (model, orderBy) => {
|
|
22
|
+
if (addressPlaceholders({
|
|
23
|
+
kind: "list",
|
|
24
|
+
resource: model.name,
|
|
25
|
+
orderBy
|
|
26
|
+
}).length > 0) return [];
|
|
27
|
+
const parsed = parseOrderBy(orderBy);
|
|
28
|
+
if (!parsed.ok) return parsed.errors.map((error) => `orderBy: ${error.message}`);
|
|
29
|
+
const checked = checkOrderBy(model, parsed.items);
|
|
30
|
+
return checked.ok ? [] : checked.errors.map((error) => `orderBy: ${error.message}`);
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* What is wrong with a link's address against these models: an unknown resource or page, a `{field}` the record it sits
|
|
34
|
+
* on does not have, or a filter or order naming fields the resource does not have. Empty when it can be followed.
|
|
35
|
+
*/
|
|
36
|
+
const addressErrors = (to, { models, pages, source }) => {
|
|
37
|
+
const placeholders = addressPlaceholders(to).flatMap((name) => {
|
|
38
|
+
if (!source) return [`"{${name}}" takes a value from the record, but the link sits on none`];
|
|
39
|
+
return Object.hasOwn(source.fields, name) ? [] : [`"{${name}}" is not a field of ${source.name}`];
|
|
40
|
+
});
|
|
41
|
+
switch (to.kind) {
|
|
42
|
+
case "page": return pages && !pages.has(to.page) ? [`unknown page "${to.page}"`] : [];
|
|
43
|
+
case "list":
|
|
44
|
+
case "record":
|
|
45
|
+
case "create": {
|
|
46
|
+
const target = Object.hasOwn(models, to.resource) ? models[to.resource] : void 0;
|
|
47
|
+
if (!target) return [`unknown resource "${to.resource}"`];
|
|
48
|
+
if (to.kind !== "list") return placeholders;
|
|
49
|
+
return [
|
|
50
|
+
...placeholders,
|
|
51
|
+
...to.filter ? filterErrors(target, to.filter) : [],
|
|
52
|
+
...to.orderBy ? orderErrors(target, to.orderBy) : []
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
default: return [`an address's kind is list, record, create or page, not "${String(to.kind)}"`];
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
//#endregion
|
|
59
|
+
export { addressErrors };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { AppAddress } from "./model.js";
|
|
2
|
+
//#region src/address-template.d.ts
|
|
3
|
+
/** The fields whose values a link's address takes from the record, from its `{field}` placeholders. */
|
|
4
|
+
export declare const addressPlaceholders: (to: AppAddress) => string[];
|
|
5
|
+
/** The fields an href takes from the record, from its `{field}` placeholders. */
|
|
6
|
+
export declare const hrefPlaceholders: (href: string) => string[];
|
|
7
|
+
/** `to` with every `{field}` in its key, filter and order replaced by the record's value; a missing value becomes empty. */
|
|
8
|
+
export declare const fillAddress: (to: AppAddress, record?: Record<string, unknown>) => AppAddress;
|
|
9
|
+
/**
|
|
10
|
+
* Whether an href leaves the app: it starts with a scheme (`https:`, `mailto:`) or `//`. A path inside the app is an
|
|
11
|
+
* address, given as `to`, so that it is written one way everywhere.
|
|
12
|
+
*/
|
|
13
|
+
export declare const leavesApp: (href: string) => boolean;
|
|
14
|
+
/** Why an href cannot be a link's `href`: it points inside the app, which takes an address in `to`. */
|
|
15
|
+
export declare const appHrefError: (href: string) => string | undefined;
|
|
16
|
+
//#endregion
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
//#region src/address-template.ts
|
|
2
|
+
const placeholder = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
|
3
|
+
const names = (text) => [...text.matchAll(placeholder)].map((match) => match[1]);
|
|
4
|
+
const templated = (to) => {
|
|
5
|
+
if (to.kind === "record") return (Array.isArray(to.key) ? to.key : [to.key]).filter((part) => typeof part === "string");
|
|
6
|
+
if (to.kind === "list") return [to.filter, to.orderBy].filter((part) => part !== void 0);
|
|
7
|
+
return [];
|
|
8
|
+
};
|
|
9
|
+
/** The fields whose values a link's address takes from the record, from its `{field}` placeholders. */
|
|
10
|
+
const addressPlaceholders = (to) => [...new Set(templated(to).flatMap(names))];
|
|
11
|
+
/** The fields an href takes from the record, from its `{field}` placeholders. */
|
|
12
|
+
const hrefPlaceholders = (href) => [...new Set(names(href))];
|
|
13
|
+
const fill = (text, record) => text.replace(placeholder, (_, name) => record[name] === null || record[name] === void 0 ? "" : String(record[name]));
|
|
14
|
+
/** `to` with every `{field}` in its key, filter and order replaced by the record's value; a missing value becomes empty. */
|
|
15
|
+
const fillAddress = (to, record = {}) => {
|
|
16
|
+
if (to.kind === "record") {
|
|
17
|
+
const part = (value) => typeof value === "string" ? fill(value, record) : value;
|
|
18
|
+
return {
|
|
19
|
+
...to,
|
|
20
|
+
key: Array.isArray(to.key) ? to.key.map(part) : part(to.key)
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
if (to.kind !== "list") return to;
|
|
24
|
+
return {
|
|
25
|
+
...to,
|
|
26
|
+
...to.filter !== void 0 && { filter: fill(to.filter, record) },
|
|
27
|
+
...to.orderBy !== void 0 && { orderBy: fill(to.orderBy, record) }
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Whether an href leaves the app: it starts with a scheme (`https:`, `mailto:`) or `//`. A path inside the app is an
|
|
32
|
+
* address, given as `to`, so that it is written one way everywhere.
|
|
33
|
+
*/
|
|
34
|
+
const leavesApp = (href) => /^([a-z][a-z0-9+.-]*:|\/\/)/i.test(href);
|
|
35
|
+
/** Why an href cannot be a link's `href`: it points inside the app, which takes an address in `to`. */
|
|
36
|
+
const appHrefError = (href) => leavesApp(href) ? void 0 : `href "${href}" points inside the app; give the link \`to\` instead, an address such as { kind: 'record', resource: 'orders', key: '{order_id}' }`;
|
|
37
|
+
//#endregion
|
|
38
|
+
export { addressPlaceholders, appHrefError, fillAddress, hrefPlaceholders, leavesApp };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { AppAddress } from "./model.js";
|
|
2
|
+
//#region src/app-address.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* The words of the app's addresses. Only `appPath` and `readAppPath` write and read them, so the shape of an address
|
|
5
|
+
* changes in this module alone.
|
|
6
|
+
*/
|
|
7
|
+
export declare const addressWords: {
|
|
8
|
+
/** A list's filter (AIP-160), as the list API takes it. */
|
|
9
|
+
readonly filter: "filter";
|
|
10
|
+
/** A list's order (AIP-132). */
|
|
11
|
+
readonly orderBy: "order_by";
|
|
12
|
+
/** The list display shown, when it is not the view's default. */
|
|
13
|
+
readonly display: "display";
|
|
14
|
+
/** Where a list's filters sit: `bar` (the default) or `panel`. */
|
|
15
|
+
readonly filterLayout: "layout";
|
|
16
|
+
/** The second path segment that opens a resource's create form instead of a record. */
|
|
17
|
+
readonly create: "new";
|
|
18
|
+
};
|
|
19
|
+
/** The path of `address` below `basePath` (such as `/admin`), every name and key encoded the one way the app reads them. */
|
|
20
|
+
export declare const appPath: (address: AppAddress, basePath?: string) => string;
|
|
21
|
+
/**
|
|
22
|
+
* The address `pathAndQuery` (below the base path) shows, or `undefined` for anything else: the root, the app's own
|
|
23
|
+
* pages under `/-/`, or a path that is not one. A first segment in `pages` is a composed page, otherwise a resource.
|
|
24
|
+
*/
|
|
25
|
+
export declare const readAppPath: (pathAndQuery: string, pages: ReadonlySet<string>) => AppAddress | undefined;
|
|
26
|
+
//#endregion
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { encodeKey } from "./keys.js";
|
|
2
|
+
//#region src/app-address.ts
|
|
3
|
+
/**
|
|
4
|
+
* The words of the app's addresses. Only `appPath` and `readAppPath` write and read them, so the shape of an address
|
|
5
|
+
* changes in this module alone.
|
|
6
|
+
*/
|
|
7
|
+
const addressWords = {
|
|
8
|
+
/** A list's filter (AIP-160), as the list API takes it. */
|
|
9
|
+
filter: "filter",
|
|
10
|
+
/** A list's order (AIP-132). */
|
|
11
|
+
orderBy: "order_by",
|
|
12
|
+
/** The list display shown, when it is not the view's default. */
|
|
13
|
+
display: "display",
|
|
14
|
+
/** Where a list's filters sit: `bar` (the default) or `panel`. */
|
|
15
|
+
filterLayout: "layout",
|
|
16
|
+
/** The second path segment that opens a resource's create form instead of a record. */
|
|
17
|
+
create: "new"
|
|
18
|
+
};
|
|
19
|
+
const listWords = [
|
|
20
|
+
"display",
|
|
21
|
+
"filter",
|
|
22
|
+
"orderBy",
|
|
23
|
+
"filterLayout"
|
|
24
|
+
];
|
|
25
|
+
const listQuery = (address) => {
|
|
26
|
+
const query = new URLSearchParams();
|
|
27
|
+
for (const name of listWords) {
|
|
28
|
+
const value = address[name];
|
|
29
|
+
if (value !== void 0 && value !== "") query.set(addressWords[name], value);
|
|
30
|
+
}
|
|
31
|
+
const text = query.toString();
|
|
32
|
+
return text ? `?${text}` : "";
|
|
33
|
+
};
|
|
34
|
+
/** The path of `address` below `basePath` (such as `/admin`), every name and key encoded the one way the app reads them. */
|
|
35
|
+
const appPath = (address, basePath = "") => {
|
|
36
|
+
switch (address.kind) {
|
|
37
|
+
case "page": return `${basePath}/${encodeURIComponent(address.page)}`;
|
|
38
|
+
case "create": return `${basePath}/${encodeURIComponent(address.resource)}/${addressWords.create}`;
|
|
39
|
+
case "record": return `${basePath}/${encodeURIComponent(address.resource)}/${encodeURIComponent(encodeKey(address.key))}`;
|
|
40
|
+
case "list": return `${basePath}/${encodeURIComponent(address.resource)}${listQuery(address)}`;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
const decodeSegments = (pathname) => {
|
|
44
|
+
try {
|
|
45
|
+
return pathname.split("/").filter(Boolean).map(decodeURIComponent);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (error instanceof URIError) return void 0;
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const filterLayout = (value) => value === "bar" || value === "panel" ? value : void 0;
|
|
52
|
+
/**
|
|
53
|
+
* The address `pathAndQuery` (below the base path) shows, or `undefined` for anything else: the root, the app's own
|
|
54
|
+
* pages under `/-/`, or a path that is not one. A first segment in `pages` is a composed page, otherwise a resource.
|
|
55
|
+
*/
|
|
56
|
+
const readAppPath = (pathAndQuery, pages) => {
|
|
57
|
+
const url = new URL(pathAndQuery, "http://app");
|
|
58
|
+
const segments = decodeSegments(url.pathname);
|
|
59
|
+
if (!segments || segments.length === 0 || segments.length > 2) return void 0;
|
|
60
|
+
const [first, second] = segments;
|
|
61
|
+
if (first === "-") return void 0;
|
|
62
|
+
if (pages.has(first)) return second === void 0 ? {
|
|
63
|
+
kind: "page",
|
|
64
|
+
page: first
|
|
65
|
+
} : void 0;
|
|
66
|
+
if (second === addressWords.create) return {
|
|
67
|
+
kind: "create",
|
|
68
|
+
resource: first
|
|
69
|
+
};
|
|
70
|
+
if (second !== void 0) return {
|
|
71
|
+
kind: "record",
|
|
72
|
+
resource: first,
|
|
73
|
+
key: second
|
|
74
|
+
};
|
|
75
|
+
const params = url.searchParams;
|
|
76
|
+
const display = params.get(addressWords.display) || void 0;
|
|
77
|
+
const filter = params.get(addressWords.filter) || void 0;
|
|
78
|
+
const orderBy = params.get(addressWords.orderBy) || void 0;
|
|
79
|
+
const layout = filterLayout(params.get(addressWords.filterLayout));
|
|
80
|
+
return {
|
|
81
|
+
kind: "list",
|
|
82
|
+
resource: first,
|
|
83
|
+
...display && { display },
|
|
84
|
+
...filter && { filter },
|
|
85
|
+
...orderBy && { orderBy },
|
|
86
|
+
...layout && { filterLayout: layout }
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
//#endregion
|
|
90
|
+
export { addressWords, appPath, readAppPath };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { JsonValue } from "./model.js";
|
|
2
|
+
//#region src/field-ref.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* A field named inside free-form data, such as a custom component's props: `{ $field: 'status' }`, or a path through
|
|
5
|
+
* relations (`plan.name`) on a page. Marked, so that the walker leaving out hidden fields finds it wherever it is.
|
|
6
|
+
*/
|
|
7
|
+
export type FieldRefValue = {
|
|
8
|
+
readonly $field: string;
|
|
9
|
+
};
|
|
10
|
+
/** A field reference for a layout's custom component props, such as `<UsageChart field={fieldRef('total')} />`. */
|
|
11
|
+
export declare const fieldRef: (path: string) => FieldRefValue;
|
|
12
|
+
export declare const isFieldRef: (value: unknown) => value is FieldRefValue;
|
|
13
|
+
/** Props as a view stores them: every `r.field` in them, at any depth, becomes a field reference. */
|
|
14
|
+
export declare const markFieldRefs: (value: unknown) => JsonValue;
|
|
15
|
+
/** Props as a custom component gets them: every field reference as its field's name. */
|
|
16
|
+
export declare const unmarkFieldRefs: <T>(value: T) => T;
|
|
17
|
+
/** Every field the value names through field references, at any depth. */
|
|
18
|
+
export declare const fieldRefsIn: (value: unknown) => string[];
|
|
19
|
+
//#endregion
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { isRef } from "./refs.js";
|
|
2
|
+
//#region src/field-ref.ts
|
|
3
|
+
/** A field reference for a layout's custom component props, such as `<UsageChart field={fieldRef('total')} />`. */
|
|
4
|
+
const fieldRef = (path) => ({ $field: path });
|
|
5
|
+
const isFieldRef = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length === 1 && typeof value.$field === "string";
|
|
6
|
+
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7
|
+
/** Props as a view stores them: every `r.field` in them, at any depth, becomes a field reference. */
|
|
8
|
+
const markFieldRefs = (value) => {
|
|
9
|
+
if (isRef(value)) return fieldRef(value.name);
|
|
10
|
+
if (Array.isArray(value)) return value.map(markFieldRefs);
|
|
11
|
+
if (isObject(value)) return Object.fromEntries(Object.entries(value).map(([name, entry]) => [name, markFieldRefs(entry)]));
|
|
12
|
+
return value;
|
|
13
|
+
};
|
|
14
|
+
/** Props as a custom component gets them: every field reference as its field's name. */
|
|
15
|
+
const unmarkFieldRefs = (value) => {
|
|
16
|
+
if (isFieldRef(value)) return value.$field;
|
|
17
|
+
if (Array.isArray(value)) return value.map(unmarkFieldRefs);
|
|
18
|
+
if (isObject(value)) return Object.fromEntries(Object.entries(value).map(([name, entry]) => [name, unmarkFieldRefs(entry)]));
|
|
19
|
+
return value;
|
|
20
|
+
};
|
|
21
|
+
/** Every field the value names through field references, at any depth. */
|
|
22
|
+
const fieldRefsIn = (value) => {
|
|
23
|
+
if (isFieldRef(value)) return [value.$field];
|
|
24
|
+
if (Array.isArray(value)) return value.flatMap(fieldRefsIn);
|
|
25
|
+
if (isObject(value)) return Object.values(value).flatMap(fieldRefsIn);
|
|
26
|
+
return [];
|
|
27
|
+
};
|
|
28
|
+
//#endregion
|
|
29
|
+
export { fieldRef, fieldRefsIn, isFieldRef, markFieldRefs, unmarkFieldRefs };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ActionModel, ActionRun, BottomBarItemModel, BottomBarModel, ChartGranularity, ChartModel, ChartRange, CheckedFilter, CompareOp, DatePreset, DurationLiteral, DurationUnit, EventModel, EventOperation, FieldFormat, FieldModel, FieldPath, FieldType, FieldViewModel, FileFieldModel, FilterError, FilterErrorCode, FilterExpr, FilterOf, FilterValue, FilterWidgetModel, KeyValue, LayoutItemModel, ListModel, ListQuery, Literal, NavModel, NavRecentModel, NowValue, OrderByItem, RelatedModel, ResourceModel, SaveFeedback, SearchMatch, SearchResultModel, SortSpec, Span, StatusTone, UserMenuItemModel, UserMenuModel, ViewModel } from "./model.js";
|
|
1
|
+
import { ActionModel, ActionRun, AppAddress, BottomBarItemModel, BottomBarModel, ChartGranularity, ChartModel, ChartRange, CheckedFilter, CompareOp, CustomSectionModel, DatePreset, DurationLiteral, DurationUnit, EventModel, EventOperation, FieldFormat, FieldModel, FieldPath, FieldType, FieldViewModel, FileFieldModel, FilterError, FilterErrorCode, FilterExpr, FilterLayout, FilterOf, FilterValue, FilterWidgetModel, JsonValue, KeyValue, LayoutItemModel, ListDisplayModel, ListModel, ListQuery, Literal, NavModel, NavRecentModel, NowValue, OrderByItem, RelatedModel, ResourceModel, SaveFeedback, SearchMatch, SearchResultModel, SortSpec, Span, StatusTone, UserMenuItemModel, UserMenuModel, ViewModel } from "./model.js";
|
|
2
2
|
import { AccessAction, AccessContext, AccessFn, AccessResult, AccessUser, FieldAccess, FieldRule } from "./access/types.js";
|
|
3
3
|
import { FileProcessor, FileProcessorInput, checkedAccept, checkedProvider, parseSize } from "./files/file-options.js";
|
|
4
4
|
import { Field } from "./field.js";
|
|
@@ -8,8 +8,11 @@ import { EventActor, EventContext, EventHelpers, EventMatch, EventSpec } from ".
|
|
|
8
8
|
import { InferRecord, RecordKey } from "./resource-types.js";
|
|
9
9
|
import { resource } from "./resource.js";
|
|
10
10
|
import { checkEventFilter } from "./events/event-models.js";
|
|
11
|
+
import { ComponentRef, ViewProps, displayFields, tableDisplay } from "./view-displays.js";
|
|
11
12
|
import { view } from "./view.js";
|
|
12
13
|
import { webhookResources } from "./webhooks/webhook-resources.js";
|
|
14
|
+
import { FieldRefValue, fieldRef, fieldRefsIn, isFieldRef, markFieldRefs, unmarkFieldRefs } from "./field-ref.js";
|
|
15
|
+
import { PropShapes, RefShape, pruneByShape, shapes } from "./ref-shape.js";
|
|
13
16
|
import { enumLabel } from "./enum-label.js";
|
|
14
17
|
import { SearchTerm, matchedSearchField, matchesSearch, searchTerms, termMatches } from "./search-match.js";
|
|
15
18
|
import { defaultRecentLimit, navRecentErrors, statusTones } from "./nav-recent.js";
|
|
@@ -18,6 +21,10 @@ import { BottomBarSource, bottomBar, bottomBarFor, maxBottomBarItems } from "./b
|
|
|
18
21
|
import { BottomBarNames, bottomBarErrors } from "./bottom-bar-errors.js";
|
|
19
22
|
import { layout } from "./layout.js";
|
|
20
23
|
import { KeyType, decodeKey, encodeKey, keyTypes } from "./keys.js";
|
|
24
|
+
import { addressWords, appPath, readAppPath } from "./app-address.js";
|
|
25
|
+
import { PluginHost, PluginRequirement, inRange, pluginProblem } from "./plugin-version.js";
|
|
26
|
+
import { addressPlaceholders, appHrefError, fillAddress, hrefPlaceholders, leavesApp } from "./address-template.js";
|
|
27
|
+
import { AddressCheck, addressErrors } from "./address-errors.js";
|
|
21
28
|
import { FilterLimits, FilterParse, parseFilter } from "./filter/parse.js";
|
|
22
29
|
import { CheckResult, checkFilter } from "./filter/check.js";
|
|
23
30
|
import { EvaluateOptions, evaluateFilter } from "./filter/evaluate.js";
|
|
@@ -35,4 +42,4 @@ import "./access/index.js";
|
|
|
35
42
|
import { AssistantAction, AssistantActionRequest, AssistantCardPart, AssistantDiff, AssistantEvent, AssistantMessage, AssistantMessageRequest, AssistantPart, AssistantState, AssistantStep, AssistantStepState, AssistantTablePart, AssistantTextPart, AssistantTone, AssistantWidgetPart } from "./assistant/protocol.js";
|
|
36
43
|
import { applyAssistantEvent, emptyAssistantState } from "./assistant/apply-event.js";
|
|
37
44
|
import { readEventStream } from "./assistant/event-stream.js";
|
|
38
|
-
export { type AccessAction, type AccessContext, type AccessFn, type AccessResult, type AccessSource, type AccessUser, type ActionModel, type ActionRun, type AssistantAction, type AssistantActionRequest, type AssistantCardPart, type AssistantDiff, type AssistantEvent, type AssistantMessage, type AssistantMessageRequest, type AssistantPart, type AssistantState, type AssistantStep, type AssistantStepState, type AssistantTablePart, type AssistantTextPart, type AssistantTone, type AssistantWidgetPart, type BottomBarItemModel, type BottomBarModel, type BottomBarNames, type BottomBarSource, type Capability, type CapabilityAction, type CapabilityScope, type ChartGranularity, type ChartModel, type ChartRange, type CheckResult, type CheckedFilter, type CompareOp, type DatePreset, type DurationLiteral, type DurationUnit, type EvaluateOptions, type EventActor, type EventContext, type EventHelpers, type EventMatch, type EventModel, type EventOperation, type EventSpec, type Field, type FieldAccess, type FieldFormat, type FieldModel, type FieldPath, type FieldRule, type FieldType, type FieldViewModel, type FileField, type FileFieldModel, type FileProcessor, type FileProcessorInput, type FilterError, type FilterErrorCode, type FilterExpr, type FilterLimits, type FilterOf, type FilterParse, type FilterValue, type FilterWidgetModel, type InferRecord, type KeyType, type KeyValue, type LayoutItemModel, type ListModel, type ListQuery, type Literal, type NavModel, type NavRecentModel, type NowValue, type OrderByCheck, type OrderByItem, type OrderByParse, type RecordKey, type RelatedModel, type ResolveOptions, type ResolvedAccess, type ResourceModel, type Roles, type RolesOptions, type RowFilterOperation, type Rule, type SaveFeedback, type SearchMatch, type SearchResultModel, type SearchTerm, type SortSpec, type Span, type StatusTone, type UserMenuItemModel, type UserMenuModel, type UserMenuSource, type ViewModel, accessWarnings, applyAssistantEvent, bottomBar, bottomBarErrors, bottomBarFor, checkEventFilter, checkFilter, checkOrderBy, checkRecord, checkedAccept, checkedProvider, createWhere, decodeKey, defaultRecentLimit, defineRoles, emptyAssistantState, encodeKey, enumLabel, evaluateFilter, f, fieldsUsedBy, keyTypes, layout as l, matchedSearchField, matchesSearch, maxBottomBarItems, navRecentErrors, parseFilter, parseOrderBy, parseSize, pickView, printFilter, printOrderBy, readEventStream, resolveAccess, resource, restrictModel, rule, searchTerms, statusTones, termMatches, userMenu, view, webhookResources, where };
|
|
45
|
+
export { type AccessAction, type AccessContext, type AccessFn, type AccessResult, type AccessSource, type AccessUser, type ActionModel, type ActionRun, type AddressCheck, type AppAddress, type AssistantAction, type AssistantActionRequest, type AssistantCardPart, type AssistantDiff, type AssistantEvent, type AssistantMessage, type AssistantMessageRequest, type AssistantPart, type AssistantState, type AssistantStep, type AssistantStepState, type AssistantTablePart, type AssistantTextPart, type AssistantTone, type AssistantWidgetPart, type BottomBarItemModel, type BottomBarModel, type BottomBarNames, type BottomBarSource, type Capability, type CapabilityAction, type CapabilityScope, type ChartGranularity, type ChartModel, type ChartRange, type CheckResult, type CheckedFilter, type CompareOp, type ComponentRef, type CustomSectionModel, type DatePreset, type DurationLiteral, type DurationUnit, type EvaluateOptions, type EventActor, type EventContext, type EventHelpers, type EventMatch, type EventModel, type EventOperation, type EventSpec, type Field, type FieldAccess, type FieldFormat, type FieldModel, type FieldPath, type FieldRefValue, type FieldRule, type FieldType, type FieldViewModel, type FileField, type FileFieldModel, type FileProcessor, type FileProcessorInput, type FilterError, type FilterErrorCode, type FilterExpr, type FilterLayout, type FilterLimits, type FilterOf, type FilterParse, type FilterValue, type FilterWidgetModel, type InferRecord, type JsonValue, type KeyType, type KeyValue, type LayoutItemModel, type ListDisplayModel, type ListModel, type ListQuery, type Literal, type NavModel, type NavRecentModel, type NowValue, type OrderByCheck, type OrderByItem, type OrderByParse, type PluginHost, type PluginRequirement, type PropShapes, type RecordKey, type RefShape, type RelatedModel, type ResolveOptions, type ResolvedAccess, type ResourceModel, type Roles, type RolesOptions, type RowFilterOperation, type Rule, type SaveFeedback, type SearchMatch, type SearchResultModel, type SearchTerm, type SortSpec, type Span, type StatusTone, type UserMenuItemModel, type UserMenuModel, type UserMenuSource, type ViewModel, type ViewProps, accessWarnings, addressErrors, addressPlaceholders, addressWords, appHrefError, appPath, applyAssistantEvent, bottomBar, bottomBarErrors, bottomBarFor, checkEventFilter, checkFilter, checkOrderBy, checkRecord, checkedAccept, checkedProvider, createWhere, decodeKey, defaultRecentLimit, defineRoles, displayFields, emptyAssistantState, encodeKey, enumLabel, evaluateFilter, f, fieldRef, fieldRefsIn, fieldsUsedBy, fillAddress, hrefPlaceholders, inRange, isFieldRef, keyTypes, layout as l, leavesApp, markFieldRefs, matchedSearchField, matchesSearch, maxBottomBarItems, navRecentErrors, parseFilter, parseOrderBy, parseSize, pickView, pluginProblem, printFilter, printOrderBy, pruneByShape, readAppPath, readEventStream, resolveAccess, resource, restrictModel, rule, searchTerms, shapes, statusTones, tableDisplay, termMatches, unmarkFieldRefs, userMenu, view, webhookResources, where };
|
package/dist/index.js
CHANGED
|
@@ -9,20 +9,27 @@ import { createWhere, where } from "./filter/where.js";
|
|
|
9
9
|
import { accessWarnings, checkRecord, fieldsUsedBy, resolveAccess } from "./access/resolve.js";
|
|
10
10
|
import { checkEventFilter } from "./events/event-models.js";
|
|
11
11
|
import { resource } from "./resource.js";
|
|
12
|
+
import { fieldRef, fieldRefsIn, isFieldRef, markFieldRefs, unmarkFieldRefs } from "./field-ref.js";
|
|
13
|
+
import { displayFields, tableDisplay } from "./view-displays.js";
|
|
12
14
|
import { layout } from "./layout.js";
|
|
13
15
|
import { defaultRecentLimit, navRecentErrors, statusTones } from "./nav-recent.js";
|
|
16
|
+
import { addressPlaceholders, appHrefError, fillAddress, hrefPlaceholders, leavesApp } from "./address-template.js";
|
|
14
17
|
import { view } from "./view.js";
|
|
15
18
|
import { webhookResources } from "./webhooks/webhook-resources.js";
|
|
19
|
+
import { pruneByShape, shapes } from "./ref-shape.js";
|
|
16
20
|
import { enumLabel } from "./enum-label.js";
|
|
17
21
|
import { matchedSearchField, matchesSearch, searchTerms, termMatches } from "./search-match.js";
|
|
18
22
|
import { userMenu } from "./user-menu.js";
|
|
19
23
|
import { bottomBar, bottomBarFor, maxBottomBarItems } from "./bottom-bar.js";
|
|
20
24
|
import { bottomBarErrors } from "./bottom-bar-errors.js";
|
|
21
25
|
import { decodeKey, encodeKey, keyTypes } from "./keys.js";
|
|
26
|
+
import { addressWords, appPath, readAppPath } from "./app-address.js";
|
|
27
|
+
import { inRange, pluginProblem } from "./plugin-version.js";
|
|
28
|
+
import { addressErrors } from "./address-errors.js";
|
|
22
29
|
import { rule } from "./access/rule.js";
|
|
23
30
|
import { defineRoles } from "./access/roles.js";
|
|
24
31
|
import { pickView } from "./access/pick-view.js";
|
|
25
32
|
import { restrictModel } from "./access/restrict.js";
|
|
26
33
|
import { applyAssistantEvent, emptyAssistantState } from "./assistant/apply-event.js";
|
|
27
34
|
import { readEventStream } from "./assistant/event-stream.js";
|
|
28
|
-
export { accessWarnings, applyAssistantEvent, bottomBar, bottomBarErrors, bottomBarFor, checkEventFilter, checkFilter, checkOrderBy, checkRecord, checkedAccept, checkedProvider, createWhere, decodeKey, defaultRecentLimit, defineRoles, emptyAssistantState, encodeKey, enumLabel, evaluateFilter, f, fieldsUsedBy, keyTypes, layout as l, matchedSearchField, matchesSearch, maxBottomBarItems, navRecentErrors, parseFilter, parseOrderBy, parseSize, pickView, printFilter, printOrderBy, readEventStream, resolveAccess, resource, restrictModel, rule, searchTerms, statusTones, termMatches, userMenu, view, webhookResources, where };
|
|
35
|
+
export { accessWarnings, addressErrors, addressPlaceholders, addressWords, appHrefError, appPath, applyAssistantEvent, bottomBar, bottomBarErrors, bottomBarFor, checkEventFilter, checkFilter, checkOrderBy, checkRecord, checkedAccept, checkedProvider, createWhere, decodeKey, defaultRecentLimit, defineRoles, displayFields, emptyAssistantState, encodeKey, enumLabel, evaluateFilter, f, fieldRef, fieldRefsIn, fieldsUsedBy, fillAddress, hrefPlaceholders, inRange, isFieldRef, keyTypes, layout as l, leavesApp, markFieldRefs, matchedSearchField, matchesSearch, maxBottomBarItems, navRecentErrors, parseFilter, parseOrderBy, parseSize, pickView, pluginProblem, printFilter, printOrderBy, pruneByShape, readAppPath, readEventStream, resolveAccess, resource, restrictModel, rule, searchTerms, shapes, statusTones, tableDisplay, termMatches, unmarkFieldRefs, userMenu, view, webhookResources, where };
|
package/dist/layout.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { LayoutItemModel, RelatedModel } from "./model.js";
|
|
2
2
|
import { Ref } from "./refs.js";
|
|
3
|
+
import { ComponentRef, ViewProps } from "./view-displays.js";
|
|
3
4
|
//#region src/layout.d.ts
|
|
4
5
|
export declare const layout: {
|
|
5
6
|
section: (title: string, fields: Ref[], o?: {
|
|
@@ -8,5 +9,15 @@ export declare const layout: {
|
|
|
8
9
|
sidebar: (fields: Ref[]) => LayoutItemModel;
|
|
9
10
|
/** A table of the records of another resource that point at this one, directly or through a join table. */
|
|
10
11
|
related: (title: string, o: Omit<RelatedModel, "kind" | "title">) => LayoutItemModel;
|
|
12
|
+
/**
|
|
13
|
+
* A component of the project, registered in `protobase.ui.tsx` under its name, drawn where it is listed: in the main
|
|
14
|
+
* column, or in the sidebar with `sidebar: true`. The record page stays around it, with its title, breadcrumbs,
|
|
15
|
+
* actions and save; the component reads the record with `useLayoutRecord()`.
|
|
16
|
+
*/
|
|
17
|
+
custom: <P extends object>(component: ComponentRef<P>, o?: {
|
|
18
|
+
title?: string;
|
|
19
|
+
props?: ViewProps<P>;
|
|
20
|
+
sidebar?: boolean;
|
|
21
|
+
}) => LayoutItemModel;
|
|
11
22
|
};
|
|
12
23
|
//#endregion
|
package/dist/layout.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { markFieldRefs } from "./field-ref.js";
|
|
2
|
+
import { customComponent } from "./view-displays.js";
|
|
1
3
|
//#region src/layout.ts
|
|
2
4
|
const layout = {
|
|
3
5
|
section: (title, fields, o = {}) => ({
|
|
@@ -15,6 +17,18 @@ const layout = {
|
|
|
15
17
|
kind: "related",
|
|
16
18
|
title,
|
|
17
19
|
...structuredClone(o)
|
|
20
|
+
}),
|
|
21
|
+
/**
|
|
22
|
+
* A component of the project, registered in `protobase.ui.tsx` under its name, drawn where it is listed: in the main
|
|
23
|
+
* column, or in the sidebar with `sidebar: true`. The record page stays around it, with its title, breadcrumbs,
|
|
24
|
+
* actions and save; the component reads the record with `useLayoutRecord()`.
|
|
25
|
+
*/
|
|
26
|
+
custom: (component, o = {}) => ({
|
|
27
|
+
kind: "custom",
|
|
28
|
+
component: customComponent(component, "l.custom"),
|
|
29
|
+
...o.title !== void 0 && { title: o.title },
|
|
30
|
+
...o.props && { props: markFieldRefs(o.props) },
|
|
31
|
+
...o.sidebar && { sidebar: true }
|
|
18
32
|
})
|
|
19
33
|
};
|
|
20
34
|
//#endregion
|
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;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
//#region src/plugin-version.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Whether `version` is in `range`: comparators such as `>=0.12.0 <1.0.0`, separated by spaces, all of which must hold.
|
|
4
|
+
* Undefined when the range is not one of those, or the version is no x.y.z version.
|
|
5
|
+
*/
|
|
6
|
+
export declare const inRange: (version: string, range: string) => boolean | undefined;
|
|
7
|
+
/** What a plugin's plugin.json says it is built for: a plugin API, and the Protobase versions it runs on. */
|
|
8
|
+
export type PluginRequirement = {
|
|
9
|
+
api: number;
|
|
10
|
+
protobase: string;
|
|
11
|
+
};
|
|
12
|
+
/** An app that loads plugins: the plugin API its @protobase/ui provides, and its Protobase version. */
|
|
13
|
+
export type PluginHost = {
|
|
14
|
+
api: number;
|
|
15
|
+
version: string;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Why an app does not load a plugin built for `requirement`, or undefined when it does: the plugin API must be the
|
|
19
|
+
* app's, and the app's Protobase version in the plugin's range.
|
|
20
|
+
*/
|
|
21
|
+
export declare const pluginProblem: (requirement: PluginRequirement, host: PluginHost) => string | undefined;
|
|
22
|
+
//#endregion
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
//#region src/plugin-version.ts
|
|
2
|
+
const versionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
3
|
+
const parseVersion = (text) => {
|
|
4
|
+
const match = versionPattern.exec(text);
|
|
5
|
+
return match ? [
|
|
6
|
+
Number(match[1]),
|
|
7
|
+
Number(match[2]),
|
|
8
|
+
Number(match[3])
|
|
9
|
+
] : void 0;
|
|
10
|
+
};
|
|
11
|
+
const compare = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
|
|
12
|
+
const comparatorPattern = /^(>=|<=|>|<|=)?(.+)$/;
|
|
13
|
+
const holds = {
|
|
14
|
+
">=": (order) => order >= 0,
|
|
15
|
+
"<=": (order) => order <= 0,
|
|
16
|
+
">": (order) => order > 0,
|
|
17
|
+
"<": (order) => order < 0,
|
|
18
|
+
"=": (order) => order === 0
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Whether `version` is in `range`: comparators such as `>=0.12.0 <1.0.0`, separated by spaces, all of which must hold.
|
|
22
|
+
* Undefined when the range is not one of those, or the version is no x.y.z version.
|
|
23
|
+
*/
|
|
24
|
+
const inRange = (version, range) => {
|
|
25
|
+
const own = parseVersion(version);
|
|
26
|
+
const comparators = range.trim().split(/\s+/);
|
|
27
|
+
if (!own || range.trim() === "") return void 0;
|
|
28
|
+
let result = true;
|
|
29
|
+
for (const comparator of comparators) {
|
|
30
|
+
const [, operator = "=", text = ""] = comparatorPattern.exec(comparator) ?? [];
|
|
31
|
+
const bound = parseVersion(text);
|
|
32
|
+
if (!bound) return void 0;
|
|
33
|
+
result &&= holds[operator](compare(own, bound));
|
|
34
|
+
}
|
|
35
|
+
return result;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Why an app does not load a plugin built for `requirement`, or undefined when it does: the plugin API must be the
|
|
39
|
+
* app's, and the app's Protobase version in the plugin's range.
|
|
40
|
+
*/
|
|
41
|
+
const pluginProblem = (requirement, host) => {
|
|
42
|
+
if (requirement.api !== host.api) return `it is built for plugin API ${requirement.api}, and this app has plugin API ${host.api}`;
|
|
43
|
+
const fits = inRange(host.version, requirement.protobase);
|
|
44
|
+
if (fits === void 0) return `its Protobase range "${requirement.protobase}" is not one this app reads, such as >=0.12.0`;
|
|
45
|
+
if (!fits) return `it needs Protobase ${requirement.protobase}, and this app runs ${host.version}`;
|
|
46
|
+
};
|
|
47
|
+
//#endregion
|
|
48
|
+
export { inRange, pluginProblem };
|
|
@@ -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/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({
|