@protobase/layout 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/block-specs.js +6 -2
- package/dist/blocks.d.ts +11 -2
- package/dist/check-page.d.ts +2 -0
- package/dist/check-page.js +30 -6
- package/dist/check-structure.js +7 -0
- package/dist/component.d.ts +9 -2
- package/dist/component.js +5 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/node.js +3 -1
- package/dist/prune-page.d.ts +3 -2
- package/dist/prune-page.js +18 -5
- package/package.json +2 -2
package/dist/block-specs.js
CHANGED
|
@@ -127,7 +127,10 @@ const blockSpecs = {
|
|
|
127
127
|
},
|
|
128
128
|
Link: {
|
|
129
129
|
children: true,
|
|
130
|
-
props: {
|
|
130
|
+
props: {
|
|
131
|
+
href: optional("text"),
|
|
132
|
+
to: optional("address")
|
|
133
|
+
}
|
|
131
134
|
}
|
|
132
135
|
};
|
|
133
136
|
const formats = [
|
|
@@ -158,7 +161,8 @@ const checks = {
|
|
|
158
161
|
mode: [(value) => value === "create" || value === "edit", "create or edit"],
|
|
159
162
|
label: [(value) => typeof value === "string" || value === false, "text, or false for none"],
|
|
160
163
|
amount: [(value) => typeof value === "string" || typeof value === "number" && Number.isFinite(value), "a number or a field name"],
|
|
161
|
-
scalar: [(value) => typeof value === "string" || typeof value === "number", "text or a number"]
|
|
164
|
+
scalar: [(value) => typeof value === "string" || typeof value === "number", "text or a number"],
|
|
165
|
+
address: [(value) => isObject(value) && !isLayoutNode(value) && typeof value.kind === "string", "an address, such as { kind: 'list', resource: 'orders' }"]
|
|
162
166
|
};
|
|
163
167
|
/** Problems with one element's props and children against its block's spec. */
|
|
164
168
|
const propProblems = (spec, props, childCount) => {
|
package/dist/blocks.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Block, LayoutChildren, LayoutNode, LayoutValue } from "./node.js";
|
|
2
|
-
import { FieldFormat } from "@protobase/schema";
|
|
2
|
+
import { AppAddress, FieldFormat } from "@protobase/schema";
|
|
3
3
|
//#region src/blocks.d.ts
|
|
4
4
|
/** One element or several, for slots such as a card's header `actions`. */
|
|
5
5
|
export type Elements = LayoutNode | LayoutNode[];
|
|
@@ -100,8 +100,17 @@ export type ShowProps = {
|
|
|
100
100
|
when: string;
|
|
101
101
|
children?: LayoutChildren;
|
|
102
102
|
};
|
|
103
|
-
|
|
103
|
+
/**
|
|
104
|
+
* A link: `to` an address in the app, `{field}` in it filled from the record around it, or an `href` that leaves the
|
|
105
|
+
* app (`https:`, `mailto:`). One of the two.
|
|
106
|
+
*/
|
|
107
|
+
export type LinkProps = ({
|
|
108
|
+
to: AppAddress;
|
|
109
|
+
href?: never;
|
|
110
|
+
} | {
|
|
104
111
|
href: string;
|
|
112
|
+
to?: never;
|
|
113
|
+
}) & {
|
|
105
114
|
children?: LayoutChildren;
|
|
106
115
|
};
|
|
107
116
|
export declare const Page: Block<PageProps>;
|
package/dist/check-page.d.ts
CHANGED
|
@@ -5,6 +5,8 @@ export type PageCheckInput = {
|
|
|
5
5
|
models: Record<string, ResourceModel>;
|
|
6
6
|
/** The named actions of a resource, from all of its views. */
|
|
7
7
|
actions: (resource: string) => ReadonlySet<string>;
|
|
8
|
+
/** The composed pages' names, which links may go to; `checkPages` fills them in. */
|
|
9
|
+
pages?: ReadonlySet<string>;
|
|
8
10
|
};
|
|
9
11
|
/** Throws one error listing every problem of every page, including names that collide with each other or a resource. */
|
|
10
12
|
export declare const checkPages: (pages: PageModel[], input: PageCheckInput) => void;
|
package/dist/check-page.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { dataProps } from "./node.js";
|
|
1
2
|
import { countedResource, parseCondition } from "./condition.js";
|
|
2
3
|
import { walkTree } from "./walk.js";
|
|
3
4
|
import { resolveFieldPath } from "./field-path.js";
|
|
4
|
-
import { checkFilter, checkOrderBy, parseFilter, parseOrderBy } from "@protobase/schema";
|
|
5
|
+
import { addressErrors, checkFilter, checkOrderBy, fieldRefsIn, parseFilter, parseOrderBy } from "@protobase/schema";
|
|
5
6
|
//#region src/check-page.ts
|
|
6
7
|
const filterProblem = (model, source) => {
|
|
7
8
|
const parsed = parseFilter(source);
|
|
@@ -19,7 +20,9 @@ const sortProblem = (model, source) => {
|
|
|
19
20
|
const text = (value) => typeof value === "string" ? value : void 0;
|
|
20
21
|
/** The resource an element reads or writes: its own `resource` prop, or the record around it. */
|
|
21
22
|
const elementResource = (node, scope) => text(node.props.resource) ?? (scope.record ? scope.resource : void 0);
|
|
22
|
-
|
|
23
|
+
/** The record a link's `{field}` placeholders read: the one around it, when there is one. */
|
|
24
|
+
const linkSource = (models, scope) => scope.record && scope.resource !== void 0 ? models[scope.resource] : void 0;
|
|
25
|
+
const nodeProblems = (node, scope, { models, actions, pages }) => {
|
|
23
26
|
const problems = [];
|
|
24
27
|
const resource = elementResource(node, scope);
|
|
25
28
|
const own = text(node.props.resource);
|
|
@@ -63,6 +66,13 @@ const nodeProblems = (node, scope, { models, actions }) => {
|
|
|
63
66
|
case "Action":
|
|
64
67
|
if (resource !== void 0 && !actions(resource).has(String(node.props.name))) problems.push(`no action "${node.props.name}" on ${resource}; declare it with view('${resource}').actions(...)`);
|
|
65
68
|
break;
|
|
69
|
+
case "Link":
|
|
70
|
+
if (node.props.to !== void 0) problems.push(...addressErrors(node.props.to, {
|
|
71
|
+
models,
|
|
72
|
+
pages,
|
|
73
|
+
source: linkSource(models, scope)
|
|
74
|
+
}).map((error) => `to: ${error}`));
|
|
75
|
+
break;
|
|
66
76
|
case "Show": {
|
|
67
77
|
const parsed = parseCondition(String(node.props.when));
|
|
68
78
|
if (!parsed.ok) break;
|
|
@@ -79,12 +89,22 @@ const nodeProblems = (node, scope, { models, actions }) => {
|
|
|
79
89
|
}
|
|
80
90
|
return problems;
|
|
81
91
|
};
|
|
92
|
+
const customProblems = (node, scope, { models }) => {
|
|
93
|
+
const own = text(node.props.resource);
|
|
94
|
+
if (own !== void 0 && !models[own]) return [`unknown resource "${own}"`];
|
|
95
|
+
const resource = elementResource(node, scope);
|
|
96
|
+
return fieldRefsIn(dataProps(node)).flatMap((path) => {
|
|
97
|
+
if (resource === void 0) return [`field reference "${path}" needs a record around it, or a "resource" prop`];
|
|
98
|
+
const resolved = resolveFieldPath(models, resource, path);
|
|
99
|
+
return resolved.ok ? [] : [resolved.message];
|
|
100
|
+
});
|
|
101
|
+
};
|
|
82
102
|
/** Everything a page names that the models do not have: resources, fields, filters, sorts, actions and conditions. */
|
|
83
103
|
const pageProblems = (page, input) => {
|
|
84
104
|
const problems = [];
|
|
85
105
|
walkTree(page.tree, (node, { path, scope }) => {
|
|
86
|
-
|
|
87
|
-
for (const problem of
|
|
106
|
+
const found = node.custom ? customProblems(node, scope, input) : nodeProblems(node, scope, input);
|
|
107
|
+
for (const problem of found) problems.push(`${path}: ${problem}`);
|
|
88
108
|
});
|
|
89
109
|
return problems;
|
|
90
110
|
};
|
|
@@ -92,13 +112,17 @@ const pageProblems = (page, input) => {
|
|
|
92
112
|
const checkPages = (pages, input) => {
|
|
93
113
|
const problems = [];
|
|
94
114
|
const seen = /* @__PURE__ */ new Set();
|
|
115
|
+
const named = {
|
|
116
|
+
...input,
|
|
117
|
+
pages: input.pages ?? new Set(pages.map((page) => page.name))
|
|
118
|
+
};
|
|
95
119
|
for (const page of pages) {
|
|
96
120
|
if (seen.has(page.name)) problems.push(`Page "${page.name}" is defined twice`);
|
|
97
121
|
if (input.models[page.name]) problems.push(`Page "${page.name}" has the name of a resource; both would be at /${page.name}`);
|
|
98
122
|
seen.add(page.name);
|
|
99
|
-
for (const problem of pageProblems(page,
|
|
123
|
+
for (const problem of pageProblems(page, named)) problems.push(`Page "${page.name}": ${problem}`);
|
|
100
124
|
}
|
|
101
125
|
if (problems.length > 0) throw new Error(`The pages do not match the resources:\n${problems.map((problem) => ` - ${problem}`).join("\n")}`);
|
|
102
126
|
};
|
|
103
127
|
//#endregion
|
|
104
|
-
export { checkPages, elementResource, pageProblems };
|
|
128
|
+
export { checkPages, elementResource, linkSource, pageProblems };
|
package/dist/check-structure.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { blockSpecs, propProblems } from "./block-specs.js";
|
|
2
2
|
import { parseCondition } from "./condition.js";
|
|
3
3
|
import { walkTree } from "./walk.js";
|
|
4
|
+
import { appHrefError } from "@protobase/schema";
|
|
4
5
|
//#region src/check-structure.ts
|
|
5
6
|
/** Problems that need a record around the element, which only a `RecordCard` or a `CardRow` card provides. */
|
|
6
7
|
const recordProblems = (node, scope) => {
|
|
@@ -24,6 +25,11 @@ const statProblems = ({ props }) => {
|
|
|
24
25
|
].filter((name) => props[name] !== void 0).length !== 1) return ["takes exactly one of \"value\", \"resource\" (a count) and \"field\""];
|
|
25
26
|
return props.filter !== void 0 && props.resource === void 0 ? ["\"filter\" needs \"resource\""] : [];
|
|
26
27
|
};
|
|
28
|
+
const linkProblems = ({ props }) => {
|
|
29
|
+
if (props.to === void 0 === (props.href === void 0)) return ["takes exactly one of \"to\" (an address in the app) and \"href\" (a link that leaves it)"];
|
|
30
|
+
const error = typeof props.href === "string" ? appHrefError(props.href) : void 0;
|
|
31
|
+
return error ? [error] : [];
|
|
32
|
+
};
|
|
27
33
|
/**
|
|
28
34
|
* Checks a page's tree without the models: the root is one `Page`, every built-in element has known props of the right
|
|
29
35
|
* kind, elements that show a record's fields sit inside one, and conditions parse. Returns the problems, each with
|
|
@@ -41,6 +47,7 @@ const structureProblems = (tree) => {
|
|
|
41
47
|
propProblems(spec, node.props, node.children.length).forEach(report);
|
|
42
48
|
recordProblems(node, scope).forEach(report);
|
|
43
49
|
if (node.type === "Stat") statProblems(node).forEach(report);
|
|
50
|
+
if (node.type === "Link") linkProblems(node).forEach(report);
|
|
44
51
|
if (node.type === "Show" && typeof node.props.when === "string") {
|
|
45
52
|
const parsed = parseCondition(node.props.when);
|
|
46
53
|
if (!parsed.ok) report(`"when" is not a valid condition: ${parsed.message}`);
|
package/dist/component.d.ts
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import { Block, LayoutChildren } from "./node.js";
|
|
2
2
|
//#region src/component.d.ts
|
|
3
|
+
/** `plugin`: the plugin that provides the component at runtime, instead of the project's protobase.ui.tsx. */
|
|
4
|
+
export type ComponentOptions = {
|
|
5
|
+
plugin?: string;
|
|
6
|
+
};
|
|
3
7
|
/**
|
|
4
8
|
* Declares a custom component for layouts: `<UsageChart metric="cpu" />` becomes an element of type `UsageChart`,
|
|
5
9
|
* which the app renders with the React component registered under that name. Its props must be static values too.
|
|
10
|
+
* With `plugin`, a plugin provides it, so `protobase build` does not look for it in protobase.ui.tsx.
|
|
6
11
|
*/
|
|
7
|
-
export declare const component: <P extends object = Record<string, never>>(name: string) => Block<P & {
|
|
12
|
+
export declare const component: <P extends object = Record<string, never>>(name: string, options?: ComponentOptions) => Block<P & {
|
|
8
13
|
children?: LayoutChildren;
|
|
9
|
-
}
|
|
14
|
+
}> & {
|
|
15
|
+
readonly plugin?: string;
|
|
16
|
+
};
|
|
10
17
|
//#endregion
|
package/dist/component.js
CHANGED
|
@@ -5,11 +5,14 @@ const componentName = /^[A-Z][A-Za-z0-9]*$/;
|
|
|
5
5
|
/**
|
|
6
6
|
* Declares a custom component for layouts: `<UsageChart metric="cpu" />` becomes an element of type `UsageChart`,
|
|
7
7
|
* which the app renders with the React component registered under that name. Its props must be static values too.
|
|
8
|
+
* With `plugin`, a plugin provides it, so `protobase build` does not look for it in protobase.ui.tsx.
|
|
8
9
|
*/
|
|
9
|
-
const component = (name) => {
|
|
10
|
+
const component = (name, options = {}) => {
|
|
10
11
|
if (!componentName.test(name)) throw new Error(`Component name "${name}" must be PascalCase, such as UsageChart`);
|
|
11
12
|
if (Object.hasOwn(blockSpecs, name) || name === "Fragment") throw new Error(`"${name}" is a built-in block; give the custom component another name`);
|
|
12
|
-
|
|
13
|
+
if (options.plugin !== void 0 && options.plugin.trim() === "") throw new Error(`component('${name}') names an empty plugin; name the plugin that provides it`);
|
|
14
|
+
const block = defineBlock(name, true);
|
|
15
|
+
return options.plugin === void 0 ? block : Object.assign(block, { plugin: options.plugin });
|
|
13
16
|
};
|
|
14
17
|
//#endregion
|
|
15
18
|
export { component };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { Block, LayoutChild, LayoutChildren, LayoutNode, LayoutValue, isLayoutNode } from "./node.js";
|
|
2
2
|
import { Action, ActionProps, ButtonVariant, Card, CardProps, CardRow, CardRowProps, DataProps, Elements, Field, FieldProps, Grid, GridProps, GridSpan, Link, LinkProps, ModalForm, ModalFormProps, Page, PageProps, Progress, ProgressProps, RecordCard, RecordCardProps, Show, ShowProps, Stat, StatProps, Table, TableProps } from "./blocks.js";
|
|
3
|
-
import { component } from "./component.js";
|
|
3
|
+
import { ComponentOptions, component } from "./component.js";
|
|
4
4
|
import { PageDefinition, PageModel, PageOptions, isPageDefinition, page } from "./page.js";
|
|
5
5
|
import { checkPages } from "./check-page.js";
|
|
6
6
|
import { prunePage } from "./prune-page.js";
|
|
7
7
|
import { countedResource, evaluateCondition, parseCondition } from "./condition.js";
|
|
8
8
|
import { PathStep, resolveFieldPath } from "./field-path.js";
|
|
9
9
|
import { jsx_runtime_d_exports } from "./jsx-runtime.js";
|
|
10
|
-
|
|
10
|
+
import { FieldRefValue, fieldRef } from "@protobase/schema";
|
|
11
|
+
export { Action, type ActionProps, type Block, type ButtonVariant, Card, type CardProps, CardRow, type CardRowProps, type ComponentOptions, type DataProps, type Elements, Field, type FieldProps, type FieldRefValue, Grid, type GridProps, type GridSpan, type LayoutChild, type LayoutChildren, type LayoutNode, type LayoutValue, Link, type LinkProps, ModalForm, type ModalFormProps, Page, type PageDefinition, type PageModel, type PageOptions, type PageProps, type PathStep, Progress, type ProgressProps, RecordCard, type RecordCardProps, Show, type ShowProps, Stat, type StatProps, Table, type TableProps, checkPages, component, countedResource, evaluateCondition, fieldRef, isLayoutNode, isPageDefinition, jsx_runtime_d_exports as jsxRuntime, page, parseCondition, prunePage, resolveFieldPath };
|
package/dist/index.js
CHANGED
|
@@ -7,4 +7,5 @@ import { resolveFieldPath } from "./field-path.js";
|
|
|
7
7
|
import { checkPages } from "./check-page.js";
|
|
8
8
|
import { prunePage } from "./prune-page.js";
|
|
9
9
|
import { jsx_runtime_exports } from "./jsx-runtime.js";
|
|
10
|
-
|
|
10
|
+
import { fieldRef } from "@protobase/schema";
|
|
11
|
+
export { Action, Card, CardRow, Field, Grid, Link, ModalForm, Page, Progress, RecordCard, Show, Stat, Table, checkPages, component, countedResource, evaluateCondition, fieldRef, isLayoutNode, isPageDefinition, jsx_runtime_exports as jsxRuntime, page, parseCondition, prunePage, resolveFieldPath };
|
package/dist/node.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
//#region src/node.ts
|
|
2
2
|
const isLayoutNode = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && typeof value.type === "string" && Array.isArray(value.children);
|
|
3
|
+
/** An element's props without the elements it holds (`actions={...}`), which are walked as elements of their own. */
|
|
4
|
+
const dataProps = (node) => Object.fromEntries(Object.entries(node.props).filter(([, value]) => !isLayoutNode(value) && !(Array.isArray(value) && value.length > 0 && value.every(isLayoutNode))));
|
|
3
5
|
//#endregion
|
|
4
|
-
export { isLayoutNode };
|
|
6
|
+
export { dataProps, isLayoutNode };
|
package/dist/prune-page.d.ts
CHANGED
|
@@ -13,8 +13,9 @@ export type CallerAccess = {
|
|
|
13
13
|
};
|
|
14
14
|
/**
|
|
15
15
|
* The page as one caller may see it, or `undefined` when its roles leave the caller out. Every element that names a
|
|
16
|
-
* resource, field, filter, sort or action the caller cannot use is left out, with what it contains
|
|
17
|
-
*
|
|
16
|
+
* resource, field, filter, sort or action the caller cannot use is left out, with what it contains, custom components
|
|
17
|
+
* by their `resource` and field references (`fieldRef`) too; table columns and form fields are narrowed to what the
|
|
18
|
+
* caller may read and write. Data still comes from the API, which applies
|
|
18
19
|
* the same access rules (row filters included) to every request.
|
|
19
20
|
*/
|
|
20
21
|
export declare const prunePage: (page: PageModel, roles: readonly string[], access: CallerAccess) => PageModel | undefined;
|
package/dist/prune-page.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { dataProps } from "./node.js";
|
|
1
2
|
import { countedResource, parseCondition } from "./condition.js";
|
|
2
3
|
import { mapTree } from "./walk.js";
|
|
3
4
|
import { resolveFieldPath } from "./field-path.js";
|
|
4
|
-
import { elementResource } from "./check-page.js";
|
|
5
|
-
import { checkFilter, checkOrderBy, parseFilter, parseOrderBy } from "@protobase/schema";
|
|
5
|
+
import { elementResource, linkSource } from "./check-page.js";
|
|
6
|
+
import { addressErrors, checkFilter, checkOrderBy, parseFilter, parseOrderBy, pruneByShape, shapes } from "@protobase/schema";
|
|
6
7
|
//#region src/prune-page.ts
|
|
7
8
|
const text = (value) => typeof value === "string" ? value : void 0;
|
|
8
9
|
const filterFits = (model, source) => {
|
|
@@ -16,8 +17,15 @@ const sortFits = (model, source) => {
|
|
|
16
17
|
return parsed.ok && checkOrderBy(model, parsed.items).ok;
|
|
17
18
|
};
|
|
18
19
|
const writable = (model, name) => Object.hasOwn(model.fields, name) && !model.fields[name].readOnly;
|
|
20
|
+
const pruneCustom = (node, scope, { models }) => {
|
|
21
|
+
const own = text(node.props.resource);
|
|
22
|
+
if (own !== void 0 && !models[own]) return null;
|
|
23
|
+
const resource = elementResource(node, scope);
|
|
24
|
+
const readable = (path) => resource !== void 0 && resolveFieldPath(models, resource, path).ok;
|
|
25
|
+
return pruneByShape(dataProps(node), shapes.refs, readable) === void 0 ? null : node;
|
|
26
|
+
};
|
|
19
27
|
const prune = (node, scope, access) => {
|
|
20
|
-
if (node.custom) return node;
|
|
28
|
+
if (node.custom) return pruneCustom(node, scope, access);
|
|
21
29
|
const { models } = access;
|
|
22
30
|
const own = text(node.props.resource);
|
|
23
31
|
if (own !== void 0 && !models[own]) return null;
|
|
@@ -56,6 +64,10 @@ const prune = (node, scope, access) => {
|
|
|
56
64
|
} : null;
|
|
57
65
|
}
|
|
58
66
|
case "Action": return resource !== void 0 && model && access.actions(resource).has(String(node.props.name)) ? node : null;
|
|
67
|
+
case "Link": return node.props.to === void 0 || addressErrors(node.props.to, {
|
|
68
|
+
models,
|
|
69
|
+
source: linkSource(models, scope)
|
|
70
|
+
}).length === 0 ? node : null;
|
|
59
71
|
case "Show": {
|
|
60
72
|
const parsed = parseCondition(String(node.props.when));
|
|
61
73
|
if (!parsed.ok) return null;
|
|
@@ -70,8 +82,9 @@ const prune = (node, scope, access) => {
|
|
|
70
82
|
};
|
|
71
83
|
/**
|
|
72
84
|
* The page as one caller may see it, or `undefined` when its roles leave the caller out. Every element that names a
|
|
73
|
-
* resource, field, filter, sort or action the caller cannot use is left out, with what it contains
|
|
74
|
-
*
|
|
85
|
+
* resource, field, filter, sort or action the caller cannot use is left out, with what it contains, custom components
|
|
86
|
+
* by their `resource` and field references (`fieldRef`) too; table columns and form fields are narrowed to what the
|
|
87
|
+
* caller may read and write. Data still comes from the API, which applies
|
|
75
88
|
* the same access rules (row filters included) to every request.
|
|
76
89
|
*/
|
|
77
90
|
const prunePage = (page, roles, access) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/layout",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"description": "Composed pages for Protobase, written as JSX that compiles to plain data, with its JSX runtime.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"access": "public"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@protobase/schema": "0.
|
|
34
|
+
"@protobase/schema": "0.11.1",
|
|
35
35
|
"aip-parsers": "^1.0.0"
|
|
36
36
|
}
|
|
37
37
|
}
|