@protobase/layout 0.0.0-stage → 0.1.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/LICENSE +202 -0
- package/dist/_virtual/_rolldown/runtime.js +13 -0
- package/dist/block-specs.js +183 -0
- package/dist/blocks.d.ts +120 -0
- package/dist/blocks.js +17 -0
- package/dist/check-page.d.ts +11 -0
- package/dist/check-page.js +104 -0
- package/dist/check-structure.js +52 -0
- package/dist/component.d.ts +10 -0
- package/dist/component.js +15 -0
- package/dist/condition.d.ts +18 -0
- package/dist/condition.js +42 -0
- package/dist/create-node.js +56 -0
- package/dist/field-path.d.ts +20 -0
- package/dist/field-path.js +38 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/jsx-dev-runtime.d.ts +6 -0
- package/dist/jsx-dev-runtime.js +6 -0
- package/dist/jsx-runtime.d.ts +22 -0
- package/dist/jsx-runtime.js +13 -0
- package/dist/node.d.ts +24 -0
- package/dist/node.js +4 -0
- package/dist/page.d.ts +28 -0
- package/dist/page.js +26 -0
- package/dist/prune-page.d.ts +21 -0
- package/dist/prune-page.js +86 -0
- package/dist/walk.js +68 -0
- package/package.json +33 -3
- package/README.md +0 -3
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { blockSpecs, propProblems } from "./block-specs.js";
|
|
2
|
+
import { parseCondition } from "./condition.js";
|
|
3
|
+
import { walkTree } from "./walk.js";
|
|
4
|
+
//#region src/check-structure.ts
|
|
5
|
+
/** Problems that need a record around the element, which only a `RecordCard` or a `CardRow` card provides. */
|
|
6
|
+
const recordProblems = (node, scope) => {
|
|
7
|
+
if (scope.record) return [];
|
|
8
|
+
const { props } = node;
|
|
9
|
+
const outside = "needs a record: put it inside a RecordCard or a CardRow";
|
|
10
|
+
switch (node.type) {
|
|
11
|
+
case "Field": return [outside];
|
|
12
|
+
case "Stat": return props.field !== void 0 ? [`with "field" ${outside}`] : [];
|
|
13
|
+
case "Progress": return typeof props.value === "string" || typeof props.max === "string" ? [`with a field name ${outside}`] : [];
|
|
14
|
+
case "ModalForm": return props.mode === "edit" && (props.resource === void 0 || props.recordKey === void 0) ? [`in edit mode ${outside}, or name "resource" and "recordKey"`] : [];
|
|
15
|
+
case "Action": return props.resource === void 0 ? [`${outside}, or name "resource"`] : [];
|
|
16
|
+
default: return [];
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
const statProblems = ({ props }) => {
|
|
20
|
+
if ([
|
|
21
|
+
"value",
|
|
22
|
+
"resource",
|
|
23
|
+
"field"
|
|
24
|
+
].filter((name) => props[name] !== void 0).length !== 1) return ["takes exactly one of \"value\", \"resource\" (a count) and \"field\""];
|
|
25
|
+
return props.filter !== void 0 && props.resource === void 0 ? ["\"filter\" needs \"resource\""] : [];
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Checks a page's tree without the models: the root is one `Page`, every built-in element has known props of the right
|
|
29
|
+
* kind, elements that show a record's fields sit inside one, and conditions parse. Returns the problems, each with
|
|
30
|
+
* where it is.
|
|
31
|
+
*/
|
|
32
|
+
const structureProblems = (tree) => {
|
|
33
|
+
const problems = [];
|
|
34
|
+
if (tree.custom || tree.type !== "Page") problems.push(`the root element is <${tree.type}>, it must be <Page>`);
|
|
35
|
+
walkTree(tree, (node, { path, scope }) => {
|
|
36
|
+
const report = (problem) => problems.push(`${path}: ${problem}`);
|
|
37
|
+
if (node.custom) return;
|
|
38
|
+
const spec = blockSpecs[node.type];
|
|
39
|
+
if (!spec) return report(`<${node.type}> is not a block; custom components are declared with component('${node.type}')`);
|
|
40
|
+
if (node.type === "Page" && node !== tree) report("<Page> is only allowed as the root");
|
|
41
|
+
propProblems(spec, node.props, node.children.length).forEach(report);
|
|
42
|
+
recordProblems(node, scope).forEach(report);
|
|
43
|
+
if (node.type === "Stat") statProblems(node).forEach(report);
|
|
44
|
+
if (node.type === "Show" && typeof node.props.when === "string") {
|
|
45
|
+
const parsed = parseCondition(node.props.when);
|
|
46
|
+
if (!parsed.ok) report(`"when" is not a valid condition: ${parsed.message}`);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
return problems;
|
|
50
|
+
};
|
|
51
|
+
//#endregion
|
|
52
|
+
export { structureProblems };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Block, LayoutChildren } from "./node.js";
|
|
2
|
+
//#region src/component.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Declares a custom component for layouts: `<UsageChart metric="cpu" />` becomes an element of type `UsageChart`,
|
|
5
|
+
* which the app renders with the React component registered under that name. Its props must be static values too.
|
|
6
|
+
*/
|
|
7
|
+
export declare const component: <P extends object = Record<string, never>>(name: string) => Block<P & {
|
|
8
|
+
children?: LayoutChildren;
|
|
9
|
+
}>;
|
|
10
|
+
//#endregion
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { defineBlock } from "./create-node.js";
|
|
2
|
+
import { blockSpecs } from "./block-specs.js";
|
|
3
|
+
//#region src/component.ts
|
|
4
|
+
const componentName = /^[A-Z][A-Za-z0-9]*$/;
|
|
5
|
+
/**
|
|
6
|
+
* Declares a custom component for layouts: `<UsageChart metric="cpu" />` becomes an element of type `UsageChart`,
|
|
7
|
+
* which the app renders with the React component registered under that name. Its props must be static values too.
|
|
8
|
+
*/
|
|
9
|
+
const component = (name) => {
|
|
10
|
+
if (!componentName.test(name)) throw new Error(`Component name "${name}" must be PascalCase, such as UsageChart`);
|
|
11
|
+
if (Object.hasOwn(blockSpecs, name) || name === "Fragment") throw new Error(`"${name}" is a built-in block; give the custom component another name`);
|
|
12
|
+
return defineBlock(name, true);
|
|
13
|
+
};
|
|
14
|
+
//#endregion
|
|
15
|
+
export { component };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Expr } from "aip-parsers/filter";
|
|
2
|
+
//#region src/condition.d.ts
|
|
3
|
+
export type Condition = {
|
|
4
|
+
ast: Expr | undefined;
|
|
5
|
+
paths: string[];
|
|
6
|
+
};
|
|
7
|
+
export type ConditionParse = ({
|
|
8
|
+
ok: true;
|
|
9
|
+
} & Condition) | {
|
|
10
|
+
ok: false;
|
|
11
|
+
message: string;
|
|
12
|
+
};
|
|
13
|
+
export declare const parseCondition: (source: string) => ConditionParse;
|
|
14
|
+
/** True when the condition holds for `values`, keyed by the dotted paths in `condition.paths`. Functions are not supported. */
|
|
15
|
+
export declare const evaluateCondition: (condition: Condition, values: Record<string, unknown>) => boolean;
|
|
16
|
+
/** The resource a path counts (`billingProfiles.count`), when it is not a field of the record around it. */
|
|
17
|
+
export declare const countedResource: (path: string, isField: (name: string) => boolean) => string | undefined;
|
|
18
|
+
//#endregion
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { evaluateFilter, parseFilter } from "aip-parsers/filter";
|
|
2
|
+
//#region src/condition.ts
|
|
3
|
+
const isMember = (node) => typeof node === "object" && node !== null && node.kind === "member";
|
|
4
|
+
const memberPaths = (node) => {
|
|
5
|
+
if (isMember(node)) return [node.path.join(".")];
|
|
6
|
+
if (typeof node !== "object" || node === null) return [];
|
|
7
|
+
return Object.values(node).flatMap(memberPaths);
|
|
8
|
+
};
|
|
9
|
+
const folded = (node) => {
|
|
10
|
+
if (isMember(node)) return {
|
|
11
|
+
...node,
|
|
12
|
+
path: [node.path.join(".")]
|
|
13
|
+
};
|
|
14
|
+
if (Array.isArray(node)) return node.map(folded);
|
|
15
|
+
if (typeof node !== "object" || node === null) return node;
|
|
16
|
+
return Object.fromEntries(Object.entries(node).map(([key, value]) => [key, folded(value)]));
|
|
17
|
+
};
|
|
18
|
+
const parseCondition = (source) => {
|
|
19
|
+
const parsed = parseFilter(source);
|
|
20
|
+
if (!parsed.ok) return {
|
|
21
|
+
ok: false,
|
|
22
|
+
message: parsed.errors.map((error) => error.message).join("; ")
|
|
23
|
+
};
|
|
24
|
+
if (!parsed.ast) return {
|
|
25
|
+
ok: false,
|
|
26
|
+
message: "the condition is empty"
|
|
27
|
+
};
|
|
28
|
+
return {
|
|
29
|
+
ok: true,
|
|
30
|
+
ast: parsed.ast,
|
|
31
|
+
paths: [...new Set(memberPaths(parsed.ast))]
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
/** True when the condition holds for `values`, keyed by the dotted paths in `condition.paths`. Functions are not supported. */
|
|
35
|
+
const evaluateCondition = (condition, values) => condition.ast !== void 0 && evaluateFilter(folded(condition.ast), values);
|
|
36
|
+
/** The resource a path counts (`billingProfiles.count`), when it is not a field of the record around it. */
|
|
37
|
+
const countedResource = (path, isField) => {
|
|
38
|
+
const [first, second, ...rest] = path.split(".");
|
|
39
|
+
return first && second === "count" && rest.length === 0 && !isField(first) ? first : void 0;
|
|
40
|
+
};
|
|
41
|
+
//#endregion
|
|
42
|
+
export { countedResource, evaluateCondition, parseCondition };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { isLayoutNode } from "./node.js";
|
|
2
|
+
//#region src/create-node.ts
|
|
3
|
+
const advice = "Layouts are static data, sent to the browser as JSON: use <Action name=\"...\"> for behaviour, or register a custom component for React code.";
|
|
4
|
+
const describe = (value) => {
|
|
5
|
+
if (typeof value === "function") return "a function";
|
|
6
|
+
if (typeof value === "number") return String(value);
|
|
7
|
+
if (typeof value !== "object" || value === null) return `a ${typeof value}`;
|
|
8
|
+
return `a ${value.constructor?.name ?? "object without prototype"}`;
|
|
9
|
+
};
|
|
10
|
+
const isPlainObject = (value) => {
|
|
11
|
+
const prototype = Object.getPrototypeOf(value);
|
|
12
|
+
return prototype === Object.prototype || prototype === null;
|
|
13
|
+
};
|
|
14
|
+
/** Throws unless `value` is JSON-safe data: strings, finite numbers, booleans, null, arrays and plain objects of those. */
|
|
15
|
+
const staticValue = (value, where) => {
|
|
16
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return value;
|
|
17
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
18
|
+
if (Array.isArray(value)) return value.map((item, index) => staticValue(item, `${where.slice(0, -1)}[${index}]>`));
|
|
19
|
+
if (typeof value === "object" && isPlainObject(value)) return Object.fromEntries(Object.entries(value).flatMap(([key, item]) => item === void 0 ? [] : [[key, staticValue(item, `${where.slice(0, -1)}.${key}>`)]]));
|
|
20
|
+
throw new Error(`${where} is ${describe(value)}. ${advice}`);
|
|
21
|
+
};
|
|
22
|
+
/** Flattens arrays and fragments, drops `null`, `undefined` and booleans, and turns numbers into text, like React. */
|
|
23
|
+
const normalizeChildren = (children, where) => {
|
|
24
|
+
if (children === void 0 || children === null || typeof children === "boolean") return [];
|
|
25
|
+
if (typeof children === "string") return [children];
|
|
26
|
+
if (typeof children === "number" && Number.isFinite(children)) return [String(children)];
|
|
27
|
+
if (Array.isArray(children)) return children.flatMap((child) => normalizeChildren(child, where));
|
|
28
|
+
if (isLayoutNode(children)) return children.type === "Fragment" && !children.custom ? children.children : [children];
|
|
29
|
+
throw new Error(`${where} has a child that is ${describe(children)}. ${advice}`);
|
|
30
|
+
};
|
|
31
|
+
const fragmentName = "Fragment";
|
|
32
|
+
const isBlock = (type) => typeof type === "function" && typeof type.blockName === "string";
|
|
33
|
+
/** The element for `<type {...props} />`: checks that every prop and child is static and returns plain data. */
|
|
34
|
+
const createNode = (type, props) => {
|
|
35
|
+
if (typeof type === "string") throw new Error(`<${type}> is an HTML element, not a layout block: use the blocks from @protobase/layout or a custom component`);
|
|
36
|
+
if (!isBlock(type)) throw new Error(`<${typeof type === "function" && type.name ? type.name : "unknown"}> is not a layout block. Components in a layout are declared with component('Name') and implemented in the app; ${advice}`);
|
|
37
|
+
const name = type.blockName;
|
|
38
|
+
const { children, ...rest } = props;
|
|
39
|
+
const node = {
|
|
40
|
+
type: name,
|
|
41
|
+
props: Object.fromEntries(Object.entries(rest).flatMap(([key, value]) => value === void 0 ? [] : [[key, staticValue(value, `<${name} ${key}>`)]])),
|
|
42
|
+
children: normalizeChildren(children, `<${name}>`)
|
|
43
|
+
};
|
|
44
|
+
if (type.custom) node.custom = true;
|
|
45
|
+
return node;
|
|
46
|
+
};
|
|
47
|
+
/** Declares a block named `name`; `custom` marks a component the app implements in React. */
|
|
48
|
+
const defineBlock = (name, custom) => {
|
|
49
|
+
const block = Object.assign((props) => createNode(block, props), {
|
|
50
|
+
blockName: name,
|
|
51
|
+
custom
|
|
52
|
+
});
|
|
53
|
+
return block;
|
|
54
|
+
};
|
|
55
|
+
//#endregion
|
|
56
|
+
export { createNode, defineBlock, fragmentName, normalizeChildren, staticValue };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { FieldModel, ResourceModel } from "@protobase/schema";
|
|
2
|
+
//#region src/field-path.d.ts
|
|
3
|
+
/** One step of a field path: the field, and the resource it belongs to. */
|
|
4
|
+
export type PathStep = {
|
|
5
|
+
model: ResourceModel;
|
|
6
|
+
field: FieldModel;
|
|
7
|
+
};
|
|
8
|
+
export type PathResolution = {
|
|
9
|
+
ok: true;
|
|
10
|
+
steps: PathStep[];
|
|
11
|
+
} | {
|
|
12
|
+
ok: false;
|
|
13
|
+
message: string;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Resolves `plan.name` from `resource`: every name but the last must be a relation, and each next name a field of the
|
|
17
|
+
* resource that relation points at. A missing resource or field is reported as unknown, never as hidden.
|
|
18
|
+
*/
|
|
19
|
+
export declare const resolveFieldPath: (models: Record<string, ResourceModel>, resource: string, path: string) => PathResolution;
|
|
20
|
+
//#endregion
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
//#region src/field-path.ts
|
|
2
|
+
/**
|
|
3
|
+
* Resolves `plan.name` from `resource`: every name but the last must be a relation, and each next name a field of the
|
|
4
|
+
* resource that relation points at. A missing resource or field is reported as unknown, never as hidden.
|
|
5
|
+
*/
|
|
6
|
+
const resolveFieldPath = (models, resource, path) => {
|
|
7
|
+
const steps = [];
|
|
8
|
+
let model = models[resource];
|
|
9
|
+
const names = path.split(".");
|
|
10
|
+
for (const [index, name] of names.entries()) {
|
|
11
|
+
if (!model) return {
|
|
12
|
+
ok: false,
|
|
13
|
+
message: `unknown resource "${resource}"`
|
|
14
|
+
};
|
|
15
|
+
const field = Object.hasOwn(model.fields, name) ? model.fields[name] : void 0;
|
|
16
|
+
if (!field) return {
|
|
17
|
+
ok: false,
|
|
18
|
+
message: `unknown field "${names.slice(0, index + 1).join(".")}" on ${model.name}`
|
|
19
|
+
};
|
|
20
|
+
steps.push({
|
|
21
|
+
model,
|
|
22
|
+
field
|
|
23
|
+
});
|
|
24
|
+
if (index === names.length - 1) break;
|
|
25
|
+
if (field.type !== "relation" || !field.relation) return {
|
|
26
|
+
ok: false,
|
|
27
|
+
message: `"${names.slice(0, index + 1).join(".")}" is not a relation, so "${path}" cannot go through it`
|
|
28
|
+
};
|
|
29
|
+
resource = field.relation.resource;
|
|
30
|
+
model = models[resource];
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
ok: true,
|
|
34
|
+
steps
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
//#endregion
|
|
38
|
+
export { resolveFieldPath };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Block, LayoutChild, LayoutChildren, LayoutNode, LayoutValue, isLayoutNode } from "./node.js";
|
|
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";
|
|
4
|
+
import { PageDefinition, PageModel, PageOptions, isPageDefinition, page } from "./page.js";
|
|
5
|
+
import { checkPages } from "./check-page.js";
|
|
6
|
+
import { prunePage } from "./prune-page.js";
|
|
7
|
+
import { countedResource, evaluateCondition, parseCondition } from "./condition.js";
|
|
8
|
+
import { PathStep, resolveFieldPath } from "./field-path.js";
|
|
9
|
+
import { jsx_runtime_d_exports } from "./jsx-runtime.js";
|
|
10
|
+
export { Action, type ActionProps, type Block, type ButtonVariant, Card, type CardProps, CardRow, type CardRowProps, type DataProps, type Elements, Field, type FieldProps, 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, isLayoutNode, isPageDefinition, jsx_runtime_d_exports as jsxRuntime, page, parseCondition, prunePage, resolveFieldPath };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { isLayoutNode } from "./node.js";
|
|
2
|
+
import { Action, Card, CardRow, Field, Grid, Link, ModalForm, Page, Progress, RecordCard, Show, Stat, Table } from "./blocks.js";
|
|
3
|
+
import { component } from "./component.js";
|
|
4
|
+
import { countedResource, evaluateCondition, parseCondition } from "./condition.js";
|
|
5
|
+
import { isPageDefinition, page } from "./page.js";
|
|
6
|
+
import { resolveFieldPath } from "./field-path.js";
|
|
7
|
+
import { checkPages } from "./check-page.js";
|
|
8
|
+
import { prunePage } from "./prune-page.js";
|
|
9
|
+
import { jsx_runtime_exports } from "./jsx-runtime.js";
|
|
10
|
+
export { Action, Card, CardRow, Field, Grid, Link, ModalForm, Page, Progress, RecordCard, Show, Stat, Table, checkPages, component, countedResource, evaluateCondition, isLayoutNode, isPageDefinition, jsx_runtime_exports as jsxRuntime, page, parseCondition, prunePage, resolveFieldPath };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { LayoutNode } from "./node.js";
|
|
2
|
+
import { Fragment, JSX } from "./jsx-runtime.js";
|
|
3
|
+
//#region src/jsx-dev-runtime.d.ts
|
|
4
|
+
export declare const jsxDEV: (type: unknown, props: Record<string, unknown>) => LayoutNode;
|
|
5
|
+
//#endregion
|
|
6
|
+
export { Fragment, type JSX };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Block, LayoutChildren, LayoutNode } from "./node.js";
|
|
2
|
+
declare namespace jsx_runtime_d_exports {
|
|
3
|
+
export { Fragment, JSX, jsx, jsxs };
|
|
4
|
+
}
|
|
5
|
+
export declare const Fragment: Block<{
|
|
6
|
+
children?: LayoutChildren;
|
|
7
|
+
}>;
|
|
8
|
+
export declare const jsx: (type: unknown, props: Record<string, unknown>) => LayoutNode;
|
|
9
|
+
export declare const jsxs: (type: unknown, props: Record<string, unknown>) => LayoutNode;
|
|
10
|
+
export declare namespace JSX {
|
|
11
|
+
type Element = LayoutNode;
|
|
12
|
+
type ElementType = Block<any>;
|
|
13
|
+
interface ElementChildrenAttribute {
|
|
14
|
+
children: {};
|
|
15
|
+
}
|
|
16
|
+
interface IntrinsicElements {}
|
|
17
|
+
interface IntrinsicAttributes {
|
|
18
|
+
key?: string | number;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
//#endregion
|
|
22
|
+
export { jsx_runtime_d_exports };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { __exportAll } from "./_virtual/_rolldown/runtime.js";
|
|
2
|
+
import { createNode, defineBlock, fragmentName } from "./create-node.js";
|
|
3
|
+
//#region src/jsx-runtime.ts
|
|
4
|
+
var jsx_runtime_exports = /* @__PURE__ */ __exportAll({
|
|
5
|
+
Fragment: () => Fragment,
|
|
6
|
+
jsx: () => jsx,
|
|
7
|
+
jsxs: () => jsxs
|
|
8
|
+
});
|
|
9
|
+
const Fragment = defineBlock(fragmentName, false);
|
|
10
|
+
const jsx = (type, props) => createNode(type, props);
|
|
11
|
+
const jsxs = jsx;
|
|
12
|
+
//#endregion
|
|
13
|
+
export { Fragment, jsx, jsx_runtime_exports, jsxs };
|
package/dist/node.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
//#region src/node.d.ts
|
|
2
|
+
/** A static value a layout prop may hold: what survives `JSON.stringify` unchanged, plus elements (also plain data). */
|
|
3
|
+
export type LayoutValue = string | number | boolean | null | LayoutValue[] | {
|
|
4
|
+
[key: string]: LayoutValue;
|
|
5
|
+
};
|
|
6
|
+
/** One element of a layout tree. Plain data: it is sent to the browser as JSON and rendered there. */
|
|
7
|
+
export type LayoutNode = {
|
|
8
|
+
/** A built-in block (`Table`) or a custom component's registered name. */
|
|
9
|
+
type: string;
|
|
10
|
+
props: Record<string, LayoutValue>;
|
|
11
|
+
children: LayoutChild[];
|
|
12
|
+
/** Set on custom components, which the app renders with the React component registered under `type`. */
|
|
13
|
+
custom?: true;
|
|
14
|
+
};
|
|
15
|
+
export type LayoutChild = LayoutNode | string;
|
|
16
|
+
/** What JSX accepts as children; `null`, `undefined` and booleans render nothing, like in React. */
|
|
17
|
+
export type LayoutChildren = LayoutChild | number | boolean | null | undefined | LayoutChildren[];
|
|
18
|
+
/** A block as JSX sees it: called by the runtime with its props, it describes one element. */
|
|
19
|
+
export type Block<P> = ((props: P) => LayoutNode) & {
|
|
20
|
+
readonly blockName: string;
|
|
21
|
+
readonly custom: boolean;
|
|
22
|
+
};
|
|
23
|
+
export declare const isLayoutNode: (value: unknown) => value is LayoutNode;
|
|
24
|
+
//#endregion
|
package/dist/node.js
ADDED
package/dist/page.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { LayoutNode } from "./node.js";
|
|
2
|
+
import { NavModel } from "@protobase/schema";
|
|
3
|
+
//#region src/page.d.ts
|
|
4
|
+
/** A composed page as `/api/meta` serves it. */
|
|
5
|
+
export type PageModel = {
|
|
6
|
+
/** Its address: the page is at `/<name>`, beside the resources, so no resource may have the same name. */
|
|
7
|
+
name: string;
|
|
8
|
+
/** The root `Page` element's title. */
|
|
9
|
+
title: string;
|
|
10
|
+
icon?: string;
|
|
11
|
+
nav?: NavModel;
|
|
12
|
+
/** Shown only to users with one of these roles; a page without roles is for everyone who can sign in. */
|
|
13
|
+
roles?: string[];
|
|
14
|
+
tree: LayoutNode;
|
|
15
|
+
};
|
|
16
|
+
export type PageOptions = Pick<PageModel, 'icon' | 'nav' | 'roles'>;
|
|
17
|
+
export type PageDefinition = {
|
|
18
|
+
readonly kind: 'page';
|
|
19
|
+
readonly name: string;
|
|
20
|
+
toModel(): PageModel;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* A composed page at `/<name>`. Checks the tree right away, so a broken layout stops the config from loading; the
|
|
24
|
+
* resources, fields, filters and actions it names are checked against the models when the server starts.
|
|
25
|
+
*/
|
|
26
|
+
export declare const page: (name: string, tree: LayoutNode, options?: PageOptions) => PageDefinition;
|
|
27
|
+
export declare const isPageDefinition: (value: unknown) => value is PageDefinition;
|
|
28
|
+
//#endregion
|
package/dist/page.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { structureProblems } from "./check-structure.js";
|
|
2
|
+
//#region src/page.ts
|
|
3
|
+
const pageName = /^[a-z][a-zA-Z0-9-]*$/;
|
|
4
|
+
/**
|
|
5
|
+
* A composed page at `/<name>`. Checks the tree right away, so a broken layout stops the config from loading; the
|
|
6
|
+
* resources, fields, filters and actions it names are checked against the models when the server starts.
|
|
7
|
+
*/
|
|
8
|
+
const page = (name, tree, options = {}) => {
|
|
9
|
+
if (!pageName.test(name)) throw new Error(`Page name "${name}" must start with a lowercase letter and hold only letters, digits and dashes`);
|
|
10
|
+
const problems = structureProblems(tree);
|
|
11
|
+
if (problems.length > 0) throw new Error(`Page "${name}" is not a valid layout:\n${problems.map((problem) => ` - ${problem}`).join("\n")}`);
|
|
12
|
+
const model = {
|
|
13
|
+
name,
|
|
14
|
+
title: String(tree.props.title),
|
|
15
|
+
tree,
|
|
16
|
+
...options
|
|
17
|
+
};
|
|
18
|
+
return {
|
|
19
|
+
kind: "page",
|
|
20
|
+
name,
|
|
21
|
+
toModel: () => structuredClone(model)
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
const isPageDefinition = (value) => typeof value === "object" && value !== null && value.kind === "page" && typeof value.toModel === "function";
|
|
25
|
+
//#endregion
|
|
26
|
+
export { isPageDefinition, page };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { PageModel } from "./page.js";
|
|
2
|
+
import { ResourceModel } from "@protobase/schema";
|
|
3
|
+
//#region src/prune-page.d.ts
|
|
4
|
+
export type CallerAccess = {
|
|
5
|
+
/** The resources the caller can do anything with, as restricted models: hidden fields absent, `readOnly` unless writable. */
|
|
6
|
+
models: Record<string, ResourceModel>;
|
|
7
|
+
permissions: Record<string, {
|
|
8
|
+
create: boolean;
|
|
9
|
+
update: boolean;
|
|
10
|
+
}>;
|
|
11
|
+
/** The named actions in the caller's view of a resource. */
|
|
12
|
+
actions: (resource: string) => ReadonlySet<string>;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
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; table columns
|
|
17
|
+
* and form fields are narrowed to what the caller may read and write. Data still comes from the API, which applies
|
|
18
|
+
* the same access rules (row filters included) to every request.
|
|
19
|
+
*/
|
|
20
|
+
export declare const prunePage: (page: PageModel, roles: readonly string[], access: CallerAccess) => PageModel | undefined;
|
|
21
|
+
//#endregion
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { countedResource, parseCondition } from "./condition.js";
|
|
2
|
+
import { mapTree } from "./walk.js";
|
|
3
|
+
import { resolveFieldPath } from "./field-path.js";
|
|
4
|
+
import { elementResource } from "./check-page.js";
|
|
5
|
+
import { checkFilter, checkOrderBy, parseFilter, parseOrderBy } from "@protobase/schema";
|
|
6
|
+
//#region src/prune-page.ts
|
|
7
|
+
const text = (value) => typeof value === "string" ? value : void 0;
|
|
8
|
+
const filterFits = (model, source) => {
|
|
9
|
+
if (source === void 0) return true;
|
|
10
|
+
const parsed = parseFilter(source);
|
|
11
|
+
return parsed.ok && (parsed.ast === void 0 || checkFilter(model, parsed.ast).ok);
|
|
12
|
+
};
|
|
13
|
+
const sortFits = (model, source) => {
|
|
14
|
+
if (source === void 0) return true;
|
|
15
|
+
const parsed = parseOrderBy(source);
|
|
16
|
+
return parsed.ok && checkOrderBy(model, parsed.items).ok;
|
|
17
|
+
};
|
|
18
|
+
const writable = (model, name) => Object.hasOwn(model.fields, name) && !model.fields[name].readOnly;
|
|
19
|
+
const prune = (node, scope, access) => {
|
|
20
|
+
if (node.custom) return node;
|
|
21
|
+
const { models } = access;
|
|
22
|
+
const own = text(node.props.resource);
|
|
23
|
+
if (own !== void 0 && !models[own]) return null;
|
|
24
|
+
const resource = elementResource(node, scope);
|
|
25
|
+
const model = resource === void 0 ? void 0 : models[resource];
|
|
26
|
+
if (own !== void 0 && model && (!filterFits(model, text(node.props.filter)) || !sortFits(model, text(node.props.sort)))) return null;
|
|
27
|
+
const readable = (name) => scope.resource !== void 0 && resolveFieldPath(models, scope.resource, name).ok;
|
|
28
|
+
switch (node.type) {
|
|
29
|
+
case "Field": return readable(String(node.props.name)) ? node : null;
|
|
30
|
+
case "Stat": return text(node.props.field) === void 0 || readable(String(node.props.field)) ? node : null;
|
|
31
|
+
case "Progress": return [node.props.value, node.props.max].every((name) => typeof name !== "string" || readable(name)) ? node : null;
|
|
32
|
+
case "Table": {
|
|
33
|
+
const columns = node.props.columns;
|
|
34
|
+
if (!columns || !model) return node;
|
|
35
|
+
const visible = columns.filter((name) => Object.hasOwn(model.fields, name));
|
|
36
|
+
const { columns: _, ...props } = node.props;
|
|
37
|
+
return {
|
|
38
|
+
...node,
|
|
39
|
+
props: visible.length > 0 ? {
|
|
40
|
+
...props,
|
|
41
|
+
columns: visible
|
|
42
|
+
} : props
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
case "ModalForm": {
|
|
46
|
+
if (!model || resource === void 0) return null;
|
|
47
|
+
if (!(node.props.mode === "create" ? access.permissions[resource]?.create : access.permissions[resource]?.update)) return null;
|
|
48
|
+
if (!Object.keys(node.props.values ?? {}).every((name) => writable(model, name))) return null;
|
|
49
|
+
const fields = node.props.fields.filter((name) => writable(model, name));
|
|
50
|
+
return fields.length > 0 ? {
|
|
51
|
+
...node,
|
|
52
|
+
props: {
|
|
53
|
+
...node.props,
|
|
54
|
+
fields
|
|
55
|
+
}
|
|
56
|
+
} : null;
|
|
57
|
+
}
|
|
58
|
+
case "Action": return resource !== void 0 && model && access.actions(resource).has(String(node.props.name)) ? node : null;
|
|
59
|
+
case "Show": {
|
|
60
|
+
const parsed = parseCondition(String(node.props.when));
|
|
61
|
+
if (!parsed.ok) return null;
|
|
62
|
+
const record = scope.record && scope.resource !== void 0 ? models[scope.resource] : void 0;
|
|
63
|
+
return parsed.paths.every((name) => {
|
|
64
|
+
const counted = countedResource(name, (first) => Boolean(record && Object.hasOwn(record.fields, first)));
|
|
65
|
+
return counted !== void 0 ? Boolean(models[counted]) : readable(name);
|
|
66
|
+
}) ? node : null;
|
|
67
|
+
}
|
|
68
|
+
default: return node;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* 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; table columns
|
|
74
|
+
* and form fields are narrowed to what the caller may read and write. Data still comes from the API, which applies
|
|
75
|
+
* the same access rules (row filters included) to every request.
|
|
76
|
+
*/
|
|
77
|
+
const prunePage = (page, roles, access) => {
|
|
78
|
+
if (page.roles && !page.roles.some((role) => roles.includes(role))) return void 0;
|
|
79
|
+
const tree = mapTree(page.tree, (node, { scope }) => prune(node, scope, access));
|
|
80
|
+
return tree ? {
|
|
81
|
+
...page,
|
|
82
|
+
tree
|
|
83
|
+
} : void 0;
|
|
84
|
+
};
|
|
85
|
+
//#endregion
|
|
86
|
+
export { prunePage };
|
package/dist/walk.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { isLayoutNode } from "./node.js";
|
|
2
|
+
//#region src/walk.ts
|
|
3
|
+
const pageScope = { record: false };
|
|
4
|
+
const recordScope = (node) => ({
|
|
5
|
+
resource: typeof node.props.resource === "string" ? node.props.resource : void 0,
|
|
6
|
+
record: true
|
|
7
|
+
});
|
|
8
|
+
/** The scope of an element's children: each card of a `RecordCard` or `CardRow` is a record of its resource. */
|
|
9
|
+
const childScope = (node, scope) => !node.custom && (node.type === "RecordCard" || node.type === "CardRow") ? recordScope(node) : scope;
|
|
10
|
+
/** The scope of the elements in a prop: a `RecordCard`'s header actions belong to its record, others to the parent's scope. */
|
|
11
|
+
const propScope = (node, scope) => !node.custom && node.type === "RecordCard" ? recordScope(node) : scope;
|
|
12
|
+
const label = (node) => `<${node.type}${typeof node.props.resource === "string" ? ` resource="${node.props.resource}"` : ""}${typeof node.props.name === "string" ? ` name="${node.props.name}"` : ""}>`;
|
|
13
|
+
const isElements = (value) => isLayoutNode(value) || Array.isArray(value) && value.length > 0 && value.every(isLayoutNode);
|
|
14
|
+
/**
|
|
15
|
+
* Rebuilds the tree bottom-up through `visit`, which sees each element (with its children and element props already
|
|
16
|
+
* rebuilt) and returns it, a replacement, or `null` to leave it out. Text children are kept as they are.
|
|
17
|
+
*/
|
|
18
|
+
const mapTree = (node, visit, at = {
|
|
19
|
+
path: label(node),
|
|
20
|
+
scope: pageScope
|
|
21
|
+
}) => {
|
|
22
|
+
const inner = childScope(node, at.scope);
|
|
23
|
+
const slot = propScope(node, at.scope);
|
|
24
|
+
const children = node.children.flatMap((child) => {
|
|
25
|
+
if (typeof child === "string") return [child];
|
|
26
|
+
const mapped = mapTree(child, visit, {
|
|
27
|
+
path: `${at.path} › ${label(child)}`,
|
|
28
|
+
scope: inner
|
|
29
|
+
});
|
|
30
|
+
return mapped ? [mapped] : [];
|
|
31
|
+
});
|
|
32
|
+
const props = Object.fromEntries(Object.entries(node.props).flatMap(([name, value]) => {
|
|
33
|
+
if (!isElements(value)) return [[name, value]];
|
|
34
|
+
const mapped = (Array.isArray(value) ? value : [value]).flatMap((element) => mapTree(element, visit, {
|
|
35
|
+
path: `${at.path} ${name}={${label(element)}}`,
|
|
36
|
+
scope: slot
|
|
37
|
+
}) ?? []);
|
|
38
|
+
if (mapped.length === 0) return [];
|
|
39
|
+
return [[name, Array.isArray(value) ? mapped : mapped[0]]];
|
|
40
|
+
}));
|
|
41
|
+
return visit({
|
|
42
|
+
...node,
|
|
43
|
+
props,
|
|
44
|
+
children
|
|
45
|
+
}, at);
|
|
46
|
+
};
|
|
47
|
+
/** Calls `visit` for every element, parents first. */
|
|
48
|
+
const walkTree = (node, visit, at = {
|
|
49
|
+
path: label(node),
|
|
50
|
+
scope: pageScope
|
|
51
|
+
}) => {
|
|
52
|
+
visit(node, at);
|
|
53
|
+
const inner = childScope(node, at.scope);
|
|
54
|
+
const slot = propScope(node, at.scope);
|
|
55
|
+
for (const child of node.children) if (typeof child !== "string") walkTree(child, visit, {
|
|
56
|
+
path: `${at.path} › ${label(child)}`,
|
|
57
|
+
scope: inner
|
|
58
|
+
});
|
|
59
|
+
for (const [name, value] of Object.entries(node.props)) {
|
|
60
|
+
if (!isElements(value)) continue;
|
|
61
|
+
for (const element of Array.isArray(value) ? value : [value]) walkTree(element, visit, {
|
|
62
|
+
path: `${at.path} ${name}={${label(element)}}`,
|
|
63
|
+
scope: slot
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
//#endregion
|
|
68
|
+
export { childScope, mapTree, pageScope, propScope, walkTree };
|
package/package.json
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/layout",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Composed pages for Protobase, written as JSX that compiles to plain data, with its JSX runtime.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/PrototeamHQ/protobase.git",
|
|
9
|
+
"directory": "packages/layout"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://docs.protobase.net",
|
|
12
|
+
"type": "module",
|
|
13
|
+
"sideEffects": false,
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=22.12.0"
|
|
16
|
+
},
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./dist/index.js",
|
|
19
|
+
"./jsx-runtime": "./dist/jsx-runtime.js",
|
|
20
|
+
"./jsx-dev-runtime": "./dist/jsx-dev-runtime.js"
|
|
21
|
+
},
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"files": [
|
|
24
|
+
"dist"
|
|
25
|
+
],
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"aip-parsers": "^1.0.0",
|
|
31
|
+
"@protobase/schema": "0.1.1"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsdown"
|
|
35
|
+
}
|
|
6
36
|
}
|
package/README.md
DELETED