@protobase/schema 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,6 +18,7 @@ export type ResolveOptions = {
18
18
  */
19
19
  roles?: {
20
20
  can: (capability: any) => Rule;
21
+ definitions?: Record<string, unknown>;
21
22
  };
22
23
  /**
23
24
  * Overrides what an operation without a rule does. Without `roles` the default is `allow`,
@@ -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,14 @@
1
+ import { BottomBarModel } from "./model.js";
2
+ //#region src/bottom-bar-errors.d.ts
3
+ /** What the bottom bars may name. Without `roles` (an app whose roles are not declared) any role is accepted. */
4
+ export type BottomBarNames = {
5
+ resources: readonly string[];
6
+ pages: readonly string[];
7
+ roles?: readonly string[];
8
+ };
9
+ /**
10
+ * Why the bottom bars do not fit the app, as messages; empty when they do. Pure, so it can run wherever the config is
11
+ * known: at startup today, when a bundle is built later.
12
+ */
13
+ export declare const bottomBarErrors: (bars: BottomBarModel[], names: BottomBarNames) => string[];
14
+ //#endregion
@@ -0,0 +1,33 @@
1
+ import "./bottom-bar.js";
2
+ //#region src/bottom-bar-errors.ts
3
+ const itemErrors = (item, names) => {
4
+ if (item.kind === "resource" && !names.resources.includes(item.resource)) return [`resource "${item.resource}" does not exist`];
5
+ if (item.kind === "page" && !names.pages.includes(item.page)) return [`page "${item.page}" does not exist`];
6
+ return [];
7
+ };
8
+ const barErrors = (bar, names) => {
9
+ const unknownRoles = names.roles ? bar.roles.filter((role) => !names.roles.includes(role)) : [];
10
+ return [
11
+ ...bar.roles.length === 0 ? ["needs at least one role"] : [],
12
+ ...unknownRoles.map((role) => `role "${role}" is not one of the roles: ${names.roles.join(", ")}`),
13
+ ...bar.items.length === 0 ? ["needs at least one item"] : [],
14
+ ...bar.items.length > 4 ? [`has ${bar.items.length} items; at most 4 fit beside "More"`] : [],
15
+ ...bar.items.flatMap((item) => itemErrors(item, names))
16
+ ].map((error) => `Bottom bar "${bar.label}": ${error}`);
17
+ };
18
+ const overlapErrors = (bars) => bars.flatMap((bar, index) => {
19
+ const earlier = bars.slice(0, index);
20
+ const sameLabel = earlier.some((other) => other.label === bar.label) ? [`Two bottom bars are labelled "${bar.label}"; give each its own label`] : [];
21
+ const shared = bar.roles.flatMap((role) => {
22
+ const other = earlier.find((candidate) => candidate.roles.includes(role));
23
+ return other ? [`Bottom bars "${other.label}" and "${bar.label}" both name role "${role}"; a role belongs to one bottom bar`] : [];
24
+ });
25
+ return [...sameLabel, ...shared];
26
+ });
27
+ /**
28
+ * Why the bottom bars do not fit the app, as messages; empty when they do. Pure, so it can run wherever the config is
29
+ * known: at startup today, when a bundle is built later.
30
+ */
31
+ const bottomBarErrors = (bars, names) => [...bars.flatMap((bar) => barErrors(bar, names)), ...overlapErrors(bars)];
32
+ //#endregion
33
+ export { bottomBarErrors };
@@ -0,0 +1,35 @@
1
+ import { BottomBarItemModel, BottomBarModel } from "./model.js";
2
+ //#region src/bottom-bar.d.ts
3
+ /** The tabs beside the last one, "More", which opens the sidebar: five tabs in all, as on iOS. */
4
+ export declare const maxBottomBarItems = 4;
5
+ declare const items: {
6
+ /** Opens the resource's list. */
7
+ resource: (resource: string, options: {
8
+ label: string;
9
+ icon?: string;
10
+ }) => BottomBarItemModel;
11
+ /** Opens a composed page (`page()` from @protobase/layout). */
12
+ page: (page: string, options: {
13
+ label: string;
14
+ icon?: string;
15
+ }) => BottomBarItemModel;
16
+ /** An address outside the admin, opened in a new tab: `https:`, `http:` or `mailto:`. */
17
+ link: (label: string, href: string, options?: {
18
+ icon?: string;
19
+ }) => BottomBarItemModel;
20
+ };
21
+ /** What the server needs from a `bottomBar(...)` export of the config module. */
22
+ export type BottomBarSource = {
23
+ toBottomBarModel(): BottomBarModel;
24
+ };
25
+ /**
26
+ * Tabs at the bottom of the screen on phones, for the users holding one of `roles`. Export one per group of roles; a
27
+ * role belongs to one bottom bar. `label` names the bar where someone with several switches between them.
28
+ */
29
+ export declare const bottomBar: (options: {
30
+ label: string;
31
+ roles: string[];
32
+ }, build: (m: typeof items) => BottomBarItemModel[]) => BottomBarSource;
33
+ /** The bottom bar of someone working as `role`: the one that names it, since a role belongs to one bar. */
34
+ export declare const bottomBarFor: (bars: readonly BottomBarModel[], role: string | undefined) => BottomBarModel | undefined;
35
+ //#endregion
@@ -0,0 +1,44 @@
1
+ import { isMenuHref } from "./menu-href.js";
2
+ //#region src/bottom-bar.ts
3
+ /** The tabs beside the last one, "More", which opens the sidebar: five tabs in all, as on iOS. */
4
+ const maxBottomBarItems = 4;
5
+ const items = {
6
+ /** Opens the resource's list. */
7
+ resource: (resource, options) => ({
8
+ kind: "resource",
9
+ resource,
10
+ ...options
11
+ }),
12
+ /** Opens a composed page (`page()` from @protobase/layout). */
13
+ page: (page, options) => ({
14
+ kind: "page",
15
+ page,
16
+ ...options
17
+ }),
18
+ /** An address outside the admin, opened in a new tab: `https:`, `http:` or `mailto:`. */
19
+ link: (label, href, options = {}) => {
20
+ if (!isMenuHref(href)) throw new Error(`Bottom bar link "${label}": href must start with https://, http:// or mailto:`);
21
+ return {
22
+ kind: "link",
23
+ label,
24
+ href,
25
+ ...options
26
+ };
27
+ }
28
+ };
29
+ /**
30
+ * Tabs at the bottom of the screen on phones, for the users holding one of `roles`. Export one per group of roles; a
31
+ * role belongs to one bottom bar. `label` names the bar where someone with several switches between them.
32
+ */
33
+ const bottomBar = (options, build) => {
34
+ const model = {
35
+ label: options.label,
36
+ roles: [...options.roles],
37
+ items: build(items)
38
+ };
39
+ return { toBottomBarModel: () => structuredClone(model) };
40
+ };
41
+ /** The bottom bar of someone working as `role`: the one that names it, since a role belongs to one bar. */
42
+ const bottomBarFor = (bars, role) => role === void 0 ? void 0 : bars.find((bar) => bar.roles.includes(role));
43
+ //#endregion
44
+ export { bottomBar, bottomBarFor, maxBottomBarItems };
@@ -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, 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,14 +8,22 @@ 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";
16
19
  import { UserMenuSource, userMenu } from "./user-menu.js";
20
+ import { BottomBarSource, bottomBar, bottomBarFor, maxBottomBarItems } from "./bottom-bar.js";
21
+ import { BottomBarNames, bottomBarErrors } from "./bottom-bar-errors.js";
17
22
  import { layout } from "./layout.js";
18
23
  import { KeyType, decodeKey, encodeKey, keyTypes } from "./keys.js";
24
+ import { addressWords, appPath, readAppPath } from "./app-address.js";
25
+ import { addressPlaceholders, appHrefError, fillAddress, hrefPlaceholders, leavesApp } from "./address-template.js";
26
+ import { AddressCheck, addressErrors } from "./address-errors.js";
19
27
  import { FilterLimits, FilterParse, parseFilter } from "./filter/parse.js";
20
28
  import { CheckResult, checkFilter } from "./filter/check.js";
21
29
  import { EvaluateOptions, evaluateFilter } from "./filter/evaluate.js";
@@ -33,4 +41,4 @@ import "./access/index.js";
33
41
  import { AssistantAction, AssistantActionRequest, AssistantCardPart, AssistantDiff, AssistantEvent, AssistantMessage, AssistantMessageRequest, AssistantPart, AssistantState, AssistantStep, AssistantStepState, AssistantTablePart, AssistantTextPart, AssistantTone, AssistantWidgetPart } from "./assistant/protocol.js";
34
42
  import { applyAssistantEvent, emptyAssistantState } from "./assistant/apply-event.js";
35
43
  import { readEventStream } from "./assistant/event-stream.js";
36
- 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 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, checkEventFilter, checkFilter, checkOrderBy, checkRecord, checkedAccept, checkedProvider, createWhere, decodeKey, defaultRecentLimit, defineRoles, emptyAssistantState, encodeKey, enumLabel, evaluateFilter, f, fieldsUsedBy, keyTypes, layout as l, matchedSearchField, matchesSearch, navRecentErrors, parseFilter, parseOrderBy, parseSize, pickView, printFilter, printOrderBy, readEventStream, resolveAccess, resource, restrictModel, rule, searchTerms, statusTones, termMatches, userMenu, view, webhookResources, where };
44
+ 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 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, isFieldRef, keyTypes, layout as l, leavesApp, markFieldRefs, matchedSearchField, matchesSearch, maxBottomBarItems, navRecentErrors, parseFilter, parseOrderBy, parseSize, pickView, 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,18 +9,26 @@ 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";
23
+ import { bottomBar, bottomBarFor, maxBottomBarItems } from "./bottom-bar.js";
24
+ import { bottomBarErrors } from "./bottom-bar-errors.js";
19
25
  import { decodeKey, encodeKey, keyTypes } from "./keys.js";
26
+ import { addressWords, appPath, readAppPath } from "./app-address.js";
27
+ import { addressErrors } from "./address-errors.js";
20
28
  import { rule } from "./access/rule.js";
21
29
  import { defineRoles } from "./access/roles.js";
22
30
  import { pickView } from "./access/pick-view.js";
23
31
  import { restrictModel } from "./access/restrict.js";
24
32
  import { applyAssistantEvent, emptyAssistantState } from "./assistant/apply-event.js";
25
33
  import { readEventStream } from "./assistant/event-stream.js";
26
- export { accessWarnings, applyAssistantEvent, checkEventFilter, checkFilter, checkOrderBy, checkRecord, checkedAccept, checkedProvider, createWhere, decodeKey, defaultRecentLimit, defineRoles, emptyAssistantState, encodeKey, enumLabel, evaluateFilter, f, fieldsUsedBy, keyTypes, layout as l, matchedSearchField, matchesSearch, navRecentErrors, parseFilter, parseOrderBy, parseSize, pickView, printFilter, printOrderBy, readEventStream, resolveAccess, resource, restrictModel, rule, searchTerms, statusTones, termMatches, userMenu, view, webhookResources, where };
34
+ 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, isFieldRef, keyTypes, layout as l, leavesApp, markFieldRefs, matchedSearchField, matchesSearch, maxBottomBarItems, navRecentErrors, parseFilter, parseOrderBy, parseSize, pickView, 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
@@ -0,0 +1,5 @@
1
+ //#region src/menu-href.ts
2
+ /** Where a menu link may point: a web or mail address outside the admin, never `javascript:` or a path inside it. */
3
+ const isMenuHref = (href) => /^(https?:\/\/|mailto:)/i.test(href);
4
+ //#endregion
5
+ export { isMenuHref };