@protobase/schema 0.10.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.
@@ -0,0 +1,16 @@
1
+ import { AppAddress, ResourceModel } from "./model.js";
2
+ //#region src/address-errors.d.ts
3
+ export type AddressCheck = {
4
+ /** The resources; against a caller's restricted models, the check says whether that caller can follow the link. */
5
+ models: Record<string, ResourceModel>;
6
+ /** The composed pages; without them a page's name is not checked. */
7
+ pages?: ReadonlySet<string>;
8
+ /** The resource of the record the link sits on, whose fields its `{field}` placeholders name. */
9
+ source?: ResourceModel;
10
+ };
11
+ /**
12
+ * What is wrong with a link's address against these models: an unknown resource or page, a `{field}` the record it sits
13
+ * on does not have, or a filter or order naming fields the resource does not have. Empty when it can be followed.
14
+ */
15
+ export declare const addressErrors: (to: AppAddress, { models, pages, source }: AddressCheck) => string[];
16
+ //#endregion
@@ -0,0 +1,59 @@
1
+ import { parseFilter } from "./filter/parse.js";
2
+ import { checkFilter } from "./filter/check.js";
3
+ import { checkOrderBy, parseOrderBy } from "./filter/order-by.js";
4
+ import { addressPlaceholders } from "./address-template.js";
5
+ //#region src/address-errors.ts
6
+ const nameErrors = [
7
+ "unknown-field",
8
+ "not-filterable",
9
+ "not-sortable",
10
+ "unsupported-traversal",
11
+ "invalid-traversal"
12
+ ];
13
+ const withSamples = (filter) => filter.replace(/(["']?)\{[A-Za-z_][A-Za-z0-9_]*\}\1/g, (_, quote) => quote ? `${quote}x${quote}` : "0");
14
+ const filterErrors = (model, filter) => {
15
+ const parsed = parseFilter(withSamples(filter));
16
+ if (!parsed.ok) return parsed.errors.map((error) => `filter: ${error.message}`);
17
+ if (!parsed.ast) return [];
18
+ const checked = checkFilter(model, parsed.ast);
19
+ return checked.ok ? [] : checked.errors.filter((error) => nameErrors.includes(error.code)).map((error) => `filter: ${error.message}`);
20
+ };
21
+ const orderErrors = (model, orderBy) => {
22
+ if (addressPlaceholders({
23
+ kind: "list",
24
+ resource: model.name,
25
+ orderBy
26
+ }).length > 0) return [];
27
+ const parsed = parseOrderBy(orderBy);
28
+ if (!parsed.ok) return parsed.errors.map((error) => `orderBy: ${error.message}`);
29
+ const checked = checkOrderBy(model, parsed.items);
30
+ return checked.ok ? [] : checked.errors.map((error) => `orderBy: ${error.message}`);
31
+ };
32
+ /**
33
+ * What is wrong with a link's address against these models: an unknown resource or page, a `{field}` the record it sits
34
+ * on does not have, or a filter or order naming fields the resource does not have. Empty when it can be followed.
35
+ */
36
+ const addressErrors = (to, { models, pages, source }) => {
37
+ const placeholders = addressPlaceholders(to).flatMap((name) => {
38
+ if (!source) return [`"{${name}}" takes a value from the record, but the link sits on none`];
39
+ return Object.hasOwn(source.fields, name) ? [] : [`"{${name}}" is not a field of ${source.name}`];
40
+ });
41
+ switch (to.kind) {
42
+ case "page": return pages && !pages.has(to.page) ? [`unknown page "${to.page}"`] : [];
43
+ case "list":
44
+ case "record":
45
+ case "create": {
46
+ const target = Object.hasOwn(models, to.resource) ? models[to.resource] : void 0;
47
+ if (!target) return [`unknown resource "${to.resource}"`];
48
+ if (to.kind !== "list") return placeholders;
49
+ return [
50
+ ...placeholders,
51
+ ...to.filter ? filterErrors(target, to.filter) : [],
52
+ ...to.orderBy ? orderErrors(target, to.orderBy) : []
53
+ ];
54
+ }
55
+ default: return [`an address's kind is list, record, create or page, not "${String(to.kind)}"`];
56
+ }
57
+ };
58
+ //#endregion
59
+ export { addressErrors };
@@ -0,0 +1,16 @@
1
+ import { AppAddress } from "./model.js";
2
+ //#region src/address-template.d.ts
3
+ /** The fields whose values a link's address takes from the record, from its `{field}` placeholders. */
4
+ export declare const addressPlaceholders: (to: AppAddress) => string[];
5
+ /** The fields an href takes from the record, from its `{field}` placeholders. */
6
+ export declare const hrefPlaceholders: (href: string) => string[];
7
+ /** `to` with every `{field}` in its key, filter and order replaced by the record's value; a missing value becomes empty. */
8
+ export declare const fillAddress: (to: AppAddress, record?: Record<string, unknown>) => AppAddress;
9
+ /**
10
+ * Whether an href leaves the app: it starts with a scheme (`https:`, `mailto:`) or `//`. A path inside the app is an
11
+ * address, given as `to`, so that it is written one way everywhere.
12
+ */
13
+ export declare const leavesApp: (href: string) => boolean;
14
+ /** Why an href cannot be a link's `href`: it points inside the app, which takes an address in `to`. */
15
+ export declare const appHrefError: (href: string) => string | undefined;
16
+ //#endregion
@@ -0,0 +1,38 @@
1
+ //#region src/address-template.ts
2
+ const placeholder = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
3
+ const names = (text) => [...text.matchAll(placeholder)].map((match) => match[1]);
4
+ const templated = (to) => {
5
+ if (to.kind === "record") return (Array.isArray(to.key) ? to.key : [to.key]).filter((part) => typeof part === "string");
6
+ if (to.kind === "list") return [to.filter, to.orderBy].filter((part) => part !== void 0);
7
+ return [];
8
+ };
9
+ /** The fields whose values a link's address takes from the record, from its `{field}` placeholders. */
10
+ const addressPlaceholders = (to) => [...new Set(templated(to).flatMap(names))];
11
+ /** The fields an href takes from the record, from its `{field}` placeholders. */
12
+ const hrefPlaceholders = (href) => [...new Set(names(href))];
13
+ const fill = (text, record) => text.replace(placeholder, (_, name) => record[name] === null || record[name] === void 0 ? "" : String(record[name]));
14
+ /** `to` with every `{field}` in its key, filter and order replaced by the record's value; a missing value becomes empty. */
15
+ const fillAddress = (to, record = {}) => {
16
+ if (to.kind === "record") {
17
+ const part = (value) => typeof value === "string" ? fill(value, record) : value;
18
+ return {
19
+ ...to,
20
+ key: Array.isArray(to.key) ? to.key.map(part) : part(to.key)
21
+ };
22
+ }
23
+ if (to.kind !== "list") return to;
24
+ return {
25
+ ...to,
26
+ ...to.filter !== void 0 && { filter: fill(to.filter, record) },
27
+ ...to.orderBy !== void 0 && { orderBy: fill(to.orderBy, record) }
28
+ };
29
+ };
30
+ /**
31
+ * Whether an href leaves the app: it starts with a scheme (`https:`, `mailto:`) or `//`. A path inside the app is an
32
+ * address, given as `to`, so that it is written one way everywhere.
33
+ */
34
+ const leavesApp = (href) => /^([a-z][a-z0-9+.-]*:|\/\/)/i.test(href);
35
+ /** Why an href cannot be a link's `href`: it points inside the app, which takes an address in `to`. */
36
+ const appHrefError = (href) => leavesApp(href) ? void 0 : `href "${href}" points inside the app; give the link \`to\` instead, an address such as { kind: 'record', resource: 'orders', key: '{order_id}' }`;
37
+ //#endregion
38
+ export { addressPlaceholders, appHrefError, fillAddress, hrefPlaceholders, leavesApp };
@@ -0,0 +1,26 @@
1
+ import { AppAddress } from "./model.js";
2
+ //#region src/app-address.d.ts
3
+ /**
4
+ * The words of the app's addresses. Only `appPath` and `readAppPath` write and read them, so the shape of an address
5
+ * changes in this module alone.
6
+ */
7
+ export declare const addressWords: {
8
+ /** A list's filter (AIP-160), as the list API takes it. */
9
+ readonly filter: "filter";
10
+ /** A list's order (AIP-132). */
11
+ readonly orderBy: "order_by";
12
+ /** The list display shown, when it is not the view's default. */
13
+ readonly display: "display";
14
+ /** Where a list's filters sit: `bar` (the default) or `panel`. */
15
+ readonly filterLayout: "layout";
16
+ /** The second path segment that opens a resource's create form instead of a record. */
17
+ readonly create: "new";
18
+ };
19
+ /** The path of `address` below `basePath` (such as `/admin`), every name and key encoded the one way the app reads them. */
20
+ export declare const appPath: (address: AppAddress, basePath?: string) => string;
21
+ /**
22
+ * The address `pathAndQuery` (below the base path) shows, or `undefined` for anything else: the root, the app's own
23
+ * pages under `/-/`, or a path that is not one. A first segment in `pages` is a composed page, otherwise a resource.
24
+ */
25
+ export declare const readAppPath: (pathAndQuery: string, pages: ReadonlySet<string>) => AppAddress | undefined;
26
+ //#endregion
@@ -0,0 +1,90 @@
1
+ import { encodeKey } from "./keys.js";
2
+ //#region src/app-address.ts
3
+ /**
4
+ * The words of the app's addresses. Only `appPath` and `readAppPath` write and read them, so the shape of an address
5
+ * changes in this module alone.
6
+ */
7
+ const addressWords = {
8
+ /** A list's filter (AIP-160), as the list API takes it. */
9
+ filter: "filter",
10
+ /** A list's order (AIP-132). */
11
+ orderBy: "order_by",
12
+ /** The list display shown, when it is not the view's default. */
13
+ display: "display",
14
+ /** Where a list's filters sit: `bar` (the default) or `panel`. */
15
+ filterLayout: "layout",
16
+ /** The second path segment that opens a resource's create form instead of a record. */
17
+ create: "new"
18
+ };
19
+ const listWords = [
20
+ "display",
21
+ "filter",
22
+ "orderBy",
23
+ "filterLayout"
24
+ ];
25
+ const listQuery = (address) => {
26
+ const query = new URLSearchParams();
27
+ for (const name of listWords) {
28
+ const value = address[name];
29
+ if (value !== void 0 && value !== "") query.set(addressWords[name], value);
30
+ }
31
+ const text = query.toString();
32
+ return text ? `?${text}` : "";
33
+ };
34
+ /** The path of `address` below `basePath` (such as `/admin`), every name and key encoded the one way the app reads them. */
35
+ const appPath = (address, basePath = "") => {
36
+ switch (address.kind) {
37
+ case "page": return `${basePath}/${encodeURIComponent(address.page)}`;
38
+ case "create": return `${basePath}/${encodeURIComponent(address.resource)}/${addressWords.create}`;
39
+ case "record": return `${basePath}/${encodeURIComponent(address.resource)}/${encodeURIComponent(encodeKey(address.key))}`;
40
+ case "list": return `${basePath}/${encodeURIComponent(address.resource)}${listQuery(address)}`;
41
+ }
42
+ };
43
+ const decodeSegments = (pathname) => {
44
+ try {
45
+ return pathname.split("/").filter(Boolean).map(decodeURIComponent);
46
+ } catch (error) {
47
+ if (error instanceof URIError) return void 0;
48
+ throw error;
49
+ }
50
+ };
51
+ const filterLayout = (value) => value === "bar" || value === "panel" ? value : void 0;
52
+ /**
53
+ * The address `pathAndQuery` (below the base path) shows, or `undefined` for anything else: the root, the app's own
54
+ * pages under `/-/`, or a path that is not one. A first segment in `pages` is a composed page, otherwise a resource.
55
+ */
56
+ const readAppPath = (pathAndQuery, pages) => {
57
+ const url = new URL(pathAndQuery, "http://app");
58
+ const segments = decodeSegments(url.pathname);
59
+ if (!segments || segments.length === 0 || segments.length > 2) return void 0;
60
+ const [first, second] = segments;
61
+ if (first === "-") return void 0;
62
+ if (pages.has(first)) return second === void 0 ? {
63
+ kind: "page",
64
+ page: first
65
+ } : void 0;
66
+ if (second === addressWords.create) return {
67
+ kind: "create",
68
+ resource: first
69
+ };
70
+ if (second !== void 0) return {
71
+ kind: "record",
72
+ resource: first,
73
+ key: second
74
+ };
75
+ const params = url.searchParams;
76
+ const display = params.get(addressWords.display) || void 0;
77
+ const filter = params.get(addressWords.filter) || void 0;
78
+ const orderBy = params.get(addressWords.orderBy) || void 0;
79
+ const layout = filterLayout(params.get(addressWords.filterLayout));
80
+ return {
81
+ kind: "list",
82
+ resource: first,
83
+ ...display && { display },
84
+ ...filter && { filter },
85
+ ...orderBy && { orderBy },
86
+ ...layout && { filterLayout: layout }
87
+ };
88
+ };
89
+ //#endregion
90
+ export { addressWords, appPath, readAppPath };
@@ -0,0 +1,19 @@
1
+ import { JsonValue } from "./model.js";
2
+ //#region src/field-ref.d.ts
3
+ /**
4
+ * A field named inside free-form data, such as a custom component's props: `{ $field: 'status' }`, or a path through
5
+ * relations (`plan.name`) on a page. Marked, so that the walker leaving out hidden fields finds it wherever it is.
6
+ */
7
+ export type FieldRefValue = {
8
+ readonly $field: string;
9
+ };
10
+ /** A field reference for a layout's custom component props, such as `<UsageChart field={fieldRef('total')} />`. */
11
+ export declare const fieldRef: (path: string) => FieldRefValue;
12
+ export declare const isFieldRef: (value: unknown) => value is FieldRefValue;
13
+ /** Props as a view stores them: every `r.field` in them, at any depth, becomes a field reference. */
14
+ export declare const markFieldRefs: (value: unknown) => JsonValue;
15
+ /** Props as a custom component gets them: every field reference as its field's name. */
16
+ export declare const unmarkFieldRefs: <T>(value: T) => T;
17
+ /** Every field the value names through field references, at any depth. */
18
+ export declare const fieldRefsIn: (value: unknown) => string[];
19
+ //#endregion
@@ -0,0 +1,29 @@
1
+ import { isRef } from "./refs.js";
2
+ //#region src/field-ref.ts
3
+ /** A field reference for a layout's custom component props, such as `<UsageChart field={fieldRef('total')} />`. */
4
+ const fieldRef = (path) => ({ $field: path });
5
+ const isFieldRef = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length === 1 && typeof value.$field === "string";
6
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
7
+ /** Props as a view stores them: every `r.field` in them, at any depth, becomes a field reference. */
8
+ const markFieldRefs = (value) => {
9
+ if (isRef(value)) return fieldRef(value.name);
10
+ if (Array.isArray(value)) return value.map(markFieldRefs);
11
+ if (isObject(value)) return Object.fromEntries(Object.entries(value).map(([name, entry]) => [name, markFieldRefs(entry)]));
12
+ return value;
13
+ };
14
+ /** Props as a custom component gets them: every field reference as its field's name. */
15
+ const unmarkFieldRefs = (value) => {
16
+ if (isFieldRef(value)) return value.$field;
17
+ if (Array.isArray(value)) return value.map(unmarkFieldRefs);
18
+ if (isObject(value)) return Object.fromEntries(Object.entries(value).map(([name, entry]) => [name, unmarkFieldRefs(entry)]));
19
+ return value;
20
+ };
21
+ /** Every field the value names through field references, at any depth. */
22
+ const fieldRefsIn = (value) => {
23
+ if (isFieldRef(value)) return [value.$field];
24
+ if (Array.isArray(value)) return value.flatMap(fieldRefsIn);
25
+ if (isObject(value)) return Object.values(value).flatMap(fieldRefsIn);
26
+ return [];
27
+ };
28
+ //#endregion
29
+ export { fieldRef, fieldRefsIn, isFieldRef, markFieldRefs, unmarkFieldRefs };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { ActionModel, ActionRun, BottomBarItemModel, BottomBarModel, ChartGranularity, ChartModel, ChartRange, CheckedFilter, CompareOp, DatePreset, DurationLiteral, DurationUnit, EventModel, EventOperation, FieldFormat, FieldModel, FieldPath, FieldType, FieldViewModel, FileFieldModel, FilterError, FilterErrorCode, FilterExpr, FilterOf, FilterValue, FilterWidgetModel, KeyValue, LayoutItemModel, ListModel, ListQuery, Literal, NavModel, NavRecentModel, NowValue, OrderByItem, RelatedModel, ResourceModel, SaveFeedback, SearchMatch, SearchResultModel, SortSpec, Span, StatusTone, UserMenuItemModel, UserMenuModel, ViewModel } from "./model.js";
1
+ import { ActionModel, ActionRun, AppAddress, BottomBarItemModel, BottomBarModel, ChartGranularity, ChartModel, ChartRange, CheckedFilter, CompareOp, CustomSectionModel, DatePreset, DurationLiteral, DurationUnit, EventModel, EventOperation, FieldFormat, FieldModel, FieldPath, FieldType, FieldViewModel, FileFieldModel, FilterError, FilterErrorCode, FilterExpr, FilterLayout, FilterOf, FilterValue, FilterWidgetModel, JsonValue, KeyValue, LayoutItemModel, ListDisplayModel, ListModel, ListQuery, Literal, NavModel, NavRecentModel, NowValue, OrderByItem, RelatedModel, ResourceModel, SaveFeedback, SearchMatch, SearchResultModel, SortSpec, Span, StatusTone, UserMenuItemModel, UserMenuModel, ViewModel } from "./model.js";
2
2
  import { AccessAction, AccessContext, AccessFn, AccessResult, AccessUser, FieldAccess, FieldRule } from "./access/types.js";
3
3
  import { FileProcessor, FileProcessorInput, checkedAccept, checkedProvider, parseSize } from "./files/file-options.js";
4
4
  import { Field } from "./field.js";
@@ -8,8 +8,11 @@ import { EventActor, EventContext, EventHelpers, EventMatch, EventSpec } from ".
8
8
  import { InferRecord, RecordKey } from "./resource-types.js";
9
9
  import { resource } from "./resource.js";
10
10
  import { checkEventFilter } from "./events/event-models.js";
11
+ import { ComponentRef, ViewProps, displayFields, tableDisplay } from "./view-displays.js";
11
12
  import { view } from "./view.js";
12
13
  import { webhookResources } from "./webhooks/webhook-resources.js";
14
+ import { FieldRefValue, fieldRef, fieldRefsIn, isFieldRef, markFieldRefs, unmarkFieldRefs } from "./field-ref.js";
15
+ import { PropShapes, RefShape, pruneByShape, shapes } from "./ref-shape.js";
13
16
  import { enumLabel } from "./enum-label.js";
14
17
  import { SearchTerm, matchedSearchField, matchesSearch, searchTerms, termMatches } from "./search-match.js";
15
18
  import { defaultRecentLimit, navRecentErrors, statusTones } from "./nav-recent.js";
@@ -18,6 +21,9 @@ import { BottomBarSource, bottomBar, bottomBarFor, maxBottomBarItems } from "./b
18
21
  import { BottomBarNames, bottomBarErrors } from "./bottom-bar-errors.js";
19
22
  import { layout } from "./layout.js";
20
23
  import { KeyType, decodeKey, encodeKey, keyTypes } from "./keys.js";
24
+ import { addressWords, appPath, readAppPath } from "./app-address.js";
25
+ import { addressPlaceholders, appHrefError, fillAddress, hrefPlaceholders, leavesApp } from "./address-template.js";
26
+ import { AddressCheck, addressErrors } from "./address-errors.js";
21
27
  import { FilterLimits, FilterParse, parseFilter } from "./filter/parse.js";
22
28
  import { CheckResult, checkFilter } from "./filter/check.js";
23
29
  import { EvaluateOptions, evaluateFilter } from "./filter/evaluate.js";
@@ -35,4 +41,4 @@ import "./access/index.js";
35
41
  import { AssistantAction, AssistantActionRequest, AssistantCardPart, AssistantDiff, AssistantEvent, AssistantMessage, AssistantMessageRequest, AssistantPart, AssistantState, AssistantStep, AssistantStepState, AssistantTablePart, AssistantTextPart, AssistantTone, AssistantWidgetPart } from "./assistant/protocol.js";
36
42
  import { applyAssistantEvent, emptyAssistantState } from "./assistant/apply-event.js";
37
43
  import { readEventStream } from "./assistant/event-stream.js";
38
- export { type AccessAction, type AccessContext, type AccessFn, type AccessResult, type AccessSource, type AccessUser, type ActionModel, type ActionRun, type AssistantAction, type AssistantActionRequest, type AssistantCardPart, type AssistantDiff, type AssistantEvent, type AssistantMessage, type AssistantMessageRequest, type AssistantPart, type AssistantState, type AssistantStep, type AssistantStepState, type AssistantTablePart, type AssistantTextPart, type AssistantTone, type AssistantWidgetPart, type BottomBarItemModel, type BottomBarModel, type BottomBarNames, type BottomBarSource, type Capability, type CapabilityAction, type CapabilityScope, type ChartGranularity, type ChartModel, type ChartRange, type CheckResult, type CheckedFilter, type CompareOp, type DatePreset, type DurationLiteral, type DurationUnit, type EvaluateOptions, type EventActor, type EventContext, type EventHelpers, type EventMatch, type EventModel, type EventOperation, type EventSpec, type Field, type FieldAccess, type FieldFormat, type FieldModel, type FieldPath, type FieldRule, type FieldType, type FieldViewModel, type FileField, type FileFieldModel, type FileProcessor, type FileProcessorInput, type FilterError, type FilterErrorCode, type FilterExpr, type FilterLimits, type FilterOf, type FilterParse, type FilterValue, type FilterWidgetModel, type InferRecord, type KeyType, type KeyValue, type LayoutItemModel, type ListModel, type ListQuery, type Literal, type NavModel, type NavRecentModel, type NowValue, type OrderByCheck, type OrderByItem, type OrderByParse, type RecordKey, type RelatedModel, type ResolveOptions, type ResolvedAccess, type ResourceModel, type Roles, type RolesOptions, type RowFilterOperation, type Rule, type SaveFeedback, type SearchMatch, type SearchResultModel, type SearchTerm, type SortSpec, type Span, type StatusTone, type UserMenuItemModel, type UserMenuModel, type UserMenuSource, type ViewModel, accessWarnings, applyAssistantEvent, bottomBar, bottomBarErrors, bottomBarFor, checkEventFilter, checkFilter, checkOrderBy, checkRecord, checkedAccept, checkedProvider, createWhere, decodeKey, defaultRecentLimit, defineRoles, emptyAssistantState, encodeKey, enumLabel, evaluateFilter, f, fieldsUsedBy, keyTypes, layout as l, matchedSearchField, matchesSearch, maxBottomBarItems, navRecentErrors, parseFilter, parseOrderBy, parseSize, pickView, printFilter, printOrderBy, readEventStream, resolveAccess, resource, restrictModel, rule, searchTerms, statusTones, termMatches, userMenu, view, webhookResources, where };
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,20 +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";
19
23
  import { bottomBar, bottomBarFor, maxBottomBarItems } from "./bottom-bar.js";
20
24
  import { bottomBarErrors } from "./bottom-bar-errors.js";
21
25
  import { decodeKey, encodeKey, keyTypes } from "./keys.js";
26
+ import { addressWords, appPath, readAppPath } from "./app-address.js";
27
+ import { addressErrors } from "./address-errors.js";
22
28
  import { rule } from "./access/rule.js";
23
29
  import { defineRoles } from "./access/roles.js";
24
30
  import { pickView } from "./access/pick-view.js";
25
31
  import { restrictModel } from "./access/restrict.js";
26
32
  import { applyAssistantEvent, emptyAssistantState } from "./assistant/apply-event.js";
27
33
  import { readEventStream } from "./assistant/event-stream.js";
28
- export { accessWarnings, applyAssistantEvent, bottomBar, bottomBarErrors, bottomBarFor, checkEventFilter, checkFilter, checkOrderBy, checkRecord, checkedAccept, checkedProvider, createWhere, decodeKey, defaultRecentLimit, defineRoles, emptyAssistantState, encodeKey, enumLabel, evaluateFilter, f, fieldsUsedBy, keyTypes, layout as l, matchedSearchField, matchesSearch, maxBottomBarItems, navRecentErrors, parseFilter, parseOrderBy, parseSize, pickView, printFilter, printOrderBy, readEventStream, resolveAccess, resource, restrictModel, rule, searchTerms, statusTones, termMatches, userMenu, view, webhookResources, where };
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
package/dist/model.d.ts CHANGED
@@ -71,6 +71,10 @@ export type EventModel = {
71
71
  match?: true;
72
72
  };
73
73
  export type SortSpec = Array<[field: string, direction: 'asc' | 'desc']>;
74
+ /** A value that survives `JSON.stringify` unchanged, as a custom component's props in a view must be. */
75
+ export type JsonValue = string | number | boolean | null | JsonValue[] | {
76
+ [key: string]: JsonValue;
77
+ };
74
78
  export type KeyValue = string | number;
75
79
  export type ListQuery = {
76
80
  filter?: CheckedFilter;
@@ -80,6 +84,29 @@ export type ListQuery = {
80
84
  direction?: 'after' | 'before';
81
85
  limit: number;
82
86
  };
87
+ export type FilterLayout = 'bar' | 'panel';
88
+ /**
89
+ * A place in the app: a resource's list, one of its records, its create form, or a composed page. A record's `key` is
90
+ * its one value or its parts, as the record API takes them; `readAppPath` gives it back as the API's text form.
91
+ */
92
+ export type AppAddress = {
93
+ kind: 'list';
94
+ resource: string;
95
+ filter?: string;
96
+ orderBy?: string;
97
+ display?: string;
98
+ filterLayout?: FilterLayout;
99
+ } | {
100
+ kind: 'record';
101
+ resource: string;
102
+ key: KeyValue | readonly KeyValue[];
103
+ } | {
104
+ kind: 'create';
105
+ resource: string;
106
+ } | {
107
+ kind: 'page';
108
+ page: string;
109
+ };
83
110
  export type FieldFormat = 'relative' | 'absolute' | 'compact' | 'percent' | 'badge' | 'code';
84
111
  export type FieldViewModel = {
85
112
  label?: string;
@@ -90,11 +117,37 @@ export type FieldViewModel = {
90
117
  /** What an enum value reads as wherever it is shown; a value without one reads as itself in words (`enumLabel`). */
91
118
  valueLabels?: Record<string, string>;
92
119
  };
120
+ /**
121
+ * A way to show a list besides its table: a card per record, or a project component (`custom`) drawing the rows the
122
+ * list page loads. `name` is what the address names (`?display=cards`); `label` and `icon` are its switch's.
123
+ */
124
+ export type ListDisplayModel = {
125
+ kind: 'cards';
126
+ name: string;
127
+ label?: string;
128
+ icon?: string;
129
+ title?: string;
130
+ subtitle?: string;
131
+ badge?: string;
132
+ meta?: string[];
133
+ } | {
134
+ kind: 'custom';
135
+ name: string;
136
+ label: string;
137
+ icon?: string;
138
+ component: string;
139
+ fields: string[];
140
+ props?: Record<string, JsonValue>;
141
+ };
93
142
  export type ListModel = {
94
143
  columns: string[];
95
144
  sort?: SortSpec;
96
145
  /** Defaults for the UI's search box. `ResourceModel.search` is authoritative for what `search(...)` matches on the server. */
97
146
  search?: string[];
147
+ /** Other ways to show the list besides its table, in the order the switch shows them after it. */
148
+ displays?: ListDisplayModel[];
149
+ /** The display the list opens in when the address names none; the table when omitted. */
150
+ default?: string;
98
151
  };
99
152
  export type DatePreset = 'today' | 'yesterday' | '7d' | '30d' | '90d' | 'month' | 'quarter' | 'year';
100
153
  export type FilterWidgetModel = {
@@ -136,6 +189,17 @@ export type RelatedModel = {
136
189
  pageSize?: number;
137
190
  empty?: string;
138
191
  };
192
+ /**
193
+ * A project component drawn on the record page where it is listed, in the main column or with `sidebar` in the
194
+ * sidebar, under `title` when it has one. It gets `props` and reads the record with `useLayoutRecord()`.
195
+ */
196
+ export type CustomSectionModel = {
197
+ kind: 'custom';
198
+ component: string;
199
+ title?: string;
200
+ props?: Record<string, JsonValue>;
201
+ sidebar?: true;
202
+ };
139
203
  export type LayoutItemModel = {
140
204
  kind: 'section';
141
205
  title: string;
@@ -144,7 +208,7 @@ export type LayoutItemModel = {
144
208
  } | {
145
209
  kind: 'sidebar';
146
210
  fields: string[];
147
- } | RelatedModel;
211
+ } | RelatedModel | CustomSectionModel;
148
212
  export type ChartRange = '7d' | '30d' | '90d' | '1y' | 'all';
149
213
  export type ChartGranularity = 'hour' | 'day' | 'week' | 'month';
150
214
  export type ChartModel = {
@@ -154,14 +218,18 @@ export type ChartModel = {
154
218
  };
155
219
  export type SaveFeedback = 'toast' | 'button';
156
220
  /**
157
- * What a named action does in the app: set fields of the record (`update`), delete it (`delete`) or open a link
158
- * (`link`, with `{field}` replaced by the record's values). Without it the app runs a handler it registered by name.
221
+ * What a named action does in the app: set fields of the record (`update`), delete it (`delete`), go to an address in
222
+ * the app (`link` with `to`) or open a link that leaves it (`link` with `href`), `{field}` replaced by the record's
223
+ * values. Without it the app runs a handler it registered by name.
159
224
  */
160
225
  export type ActionRun = {
161
226
  kind: 'update';
162
227
  values: Record<string, unknown>;
163
228
  } | {
164
229
  kind: 'delete';
230
+ } | {
231
+ kind: 'link';
232
+ to: AppAddress;
165
233
  } | {
166
234
  kind: 'link';
167
235
  href: string;
@@ -0,0 +1,80 @@
1
+ //#region src/ref-shape.d.ts
2
+ /**
3
+ * Where a part of a model names fields, and what a field the caller cannot read does to it. A shape declares every part
4
+ * of the model it walks: a part it does not declare is left out, so a new part cannot reach a caller unchecked.
5
+ */
6
+ export type RefShape =
7
+ /** Plain data that names no field: kept as it is. */
8
+ {
9
+ kind: 'data';
10
+ } |
11
+ /** A field's name: left out when hidden. */
12
+ {
13
+ kind: 'field';
14
+ } |
15
+ /** Free-form data, such as a custom component's props, with field references (`{ $field }`) anywhere: left out when one is hidden. */
16
+ {
17
+ kind: 'refs';
18
+ } |
19
+ /** An object keyed by field names, such as field hints: hidden keys are left out, or with `all` the whole object. */
20
+ {
21
+ kind: 'keys';
22
+ all: boolean;
23
+ } |
24
+ /** A list: entries left out are dropped, and with `nonEmpty` a list left empty is left out too. */
25
+ {
26
+ kind: 'list';
27
+ of: RefShape;
28
+ nonEmpty: boolean;
29
+ } |
30
+ /** A fixed list, such as `[field, direction]`: left out when any entry is. */
31
+ {
32
+ kind: 'tuple';
33
+ items: RefShape[];
34
+ } |
35
+ /** An object: a part left out is dropped, a `requires` part left out leaves out the object; `after` tidies the result. */
36
+ {
37
+ kind: 'object';
38
+ props: Record<string, RefShape>;
39
+ requires: string[];
40
+ after?: (value: never) => unknown;
41
+ } |
42
+ /** One shape per value of `kind`, as layout items and displays are told apart; an unknown kind is left out. */
43
+ {
44
+ kind: 'oneOf';
45
+ cases: Record<string, RefShape>;
46
+ } |
47
+ /** A part pruned by code, such as one naming another resource's fields: `prune` returns it narrowed, or `undefined`. */
48
+ {
49
+ kind: 'map';
50
+ prune: (value: never) => unknown;
51
+ };
52
+ /** One shape per property of `T`, so the compiler names a property the shape forgets. */
53
+ export type PropShapes<T> = { [K in keyof T]-?: RefShape; };
54
+ /** The shapes, typed against the model they describe. */
55
+ export declare const shapes: {
56
+ data: RefShape;
57
+ field: RefShape;
58
+ refs: RefShape;
59
+ keys: (o?: {
60
+ all?: boolean;
61
+ }) => RefShape;
62
+ list: (of: RefShape, o?: {
63
+ nonEmpty?: boolean;
64
+ }) => RefShape;
65
+ tuple: (...items: RefShape[]) => RefShape;
66
+ object: <T extends object>(props: PropShapes<T>, o?: {
67
+ requires?: Array<keyof T & string>;
68
+ after?: (value: T) => T;
69
+ }) => RefShape;
70
+ oneOf: <T extends {
71
+ kind: string;
72
+ }>(cases: { [K in T["kind"]]: RefShape; }) => RefShape;
73
+ map: <T>(prune: (value: T) => T | undefined) => RefShape;
74
+ };
75
+ /**
76
+ * `value` with every part naming a field `visible` refuses left out, as `shape` says, or `undefined` when the whole
77
+ * value goes. One walker for every model the app sends: views, custom component props and composed pages' custom nodes.
78
+ */
79
+ export declare const pruneByShape: <T>(value: T, shape: RefShape, visible: (name: string) => boolean) => T | undefined;
80
+ //#endregion
@@ -0,0 +1,88 @@
1
+ import { fieldRefsIn } from "./field-ref.js";
2
+ //#region src/ref-shape.ts
3
+ /** The shapes, typed against the model they describe. */
4
+ const shapes = {
5
+ data: { kind: "data" },
6
+ field: { kind: "field" },
7
+ refs: { kind: "refs" },
8
+ keys: (o = {}) => ({
9
+ kind: "keys",
10
+ all: o.all ?? false
11
+ }),
12
+ list: (of, o = {}) => ({
13
+ kind: "list",
14
+ of,
15
+ nonEmpty: o.nonEmpty ?? false
16
+ }),
17
+ tuple: (...items) => ({
18
+ kind: "tuple",
19
+ items
20
+ }),
21
+ object: (props, o = {}) => ({
22
+ kind: "object",
23
+ props,
24
+ requires: o.requires ?? [],
25
+ ...o.after && { after: o.after }
26
+ }),
27
+ oneOf: (cases) => ({
28
+ kind: "oneOf",
29
+ cases
30
+ }),
31
+ map: (prune) => ({
32
+ kind: "map",
33
+ prune
34
+ })
35
+ };
36
+ const gone = Symbol("gone");
37
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
38
+ const walk = (value, shape, visible) => {
39
+ switch (shape.kind) {
40
+ case "data": return value;
41
+ case "field": return typeof value === "string" && visible(value) ? value : gone;
42
+ case "refs": return fieldRefsIn(value).every(visible) ? value : gone;
43
+ case "keys": {
44
+ if (!isObject(value)) return gone;
45
+ const entries = Object.entries(value);
46
+ const kept = entries.filter(([name]) => visible(name));
47
+ return shape.all && kept.length < entries.length ? gone : Object.fromEntries(kept);
48
+ }
49
+ case "list": {
50
+ if (!Array.isArray(value)) return gone;
51
+ const kept = value.map((entry) => walk(entry, shape.of, visible)).filter((entry) => entry !== gone);
52
+ return shape.nonEmpty && kept.length === 0 ? gone : kept;
53
+ }
54
+ case "tuple": {
55
+ if (!Array.isArray(value) || value.length !== shape.items.length) return gone;
56
+ const items = value.map((entry, index) => walk(entry, shape.items[index], visible));
57
+ return items.includes(gone) ? gone : items;
58
+ }
59
+ case "object": {
60
+ if (!isObject(value)) return gone;
61
+ const kept = {};
62
+ for (const [name, entry] of Object.entries(value)) {
63
+ const part = Object.hasOwn(shape.props, name) ? shape.props[name] : void 0;
64
+ if (!part || entry === void 0) continue;
65
+ const pruned = walk(entry, part, visible);
66
+ if (pruned === gone && shape.requires.includes(name)) return gone;
67
+ if (pruned !== gone) kept[name] = pruned;
68
+ }
69
+ return shape.after ? shape.after(kept) : kept;
70
+ }
71
+ case "oneOf": {
72
+ const kind = isObject(value) && typeof value.kind === "string" ? value.kind : void 0;
73
+ const shaped = kind !== void 0 && Object.hasOwn(shape.cases, kind) ? shape.cases[kind] : void 0;
74
+ return shaped ? walk(value, shaped, visible) : gone;
75
+ }
76
+ case "map": return shape.prune(value) ?? gone;
77
+ }
78
+ };
79
+ /**
80
+ * `value` with every part naming a field `visible` refuses left out, as `shape` says, or `undefined` when the whole
81
+ * value goes. One walker for every model the app sends: views, custom component props and composed pages' custom nodes.
82
+ */
83
+ const pruneByShape = (value, shape, visible) => {
84
+ const pruned = walk(value, shape, visible);
85
+ return pruned === gone ? void 0 : pruned;
86
+ };
87
+ //#endregion
88
+ export { pruneByShape, shapes };
package/dist/refs.js CHANGED
@@ -1,8 +1,12 @@
1
1
  //#region src/refs.ts
2
+ const refMark = Symbol("protobase.ref");
3
+ /** A field reference as a view builder hands it out (`r.status`), recognisable wherever it is passed, props included. */
4
+ const ref = (name) => Object.defineProperty({ name }, refMark, { value: true });
5
+ const isRef = (value) => typeof value === "object" && value !== null && value[refMark] === true;
2
6
  const createRefs = (make, known) => new Proxy({}, { get: (_, name) => {
3
7
  if (typeof name === "symbol") return void 0;
4
8
  if (known && !known.includes(name)) throw new Error(`Unknown field "${name}"`);
5
9
  return make(name);
6
10
  } });
7
11
  //#endregion
8
- export { createRefs };
12
+ export { createRefs, isRef, ref };
@@ -1,4 +1,4 @@
1
- import { ActionModel } from "./model.js";
1
+ import { ActionModel, AppAddress } from "./model.js";
2
2
  //#region src/view-actions.d.ts
3
3
  type ActionOptions = {
4
4
  label: string;
@@ -16,9 +16,15 @@ export declare const actions: {
16
16
  }) => ActionModel;
17
17
  /** Deletes the record (soft delete when the resource has one). */
18
18
  remove: (name: string, o: ActionOptions) => ActionModel;
19
- /** Opens `href`: a path in the app or a URL; `{field}` is replaced by the record's value, such as `/orders/{orderId}`. */
20
- link: (name: string, o: Omit<ActionOptions, "confirm"> & {
19
+ /**
20
+ * Goes `to` an address in the app, such as `{ kind: 'record', resource: 'orders', key: '{order_id}' }`, or opens an
21
+ * `href` that leaves it (`https:`, `mailto:`); `{field}` is replaced by the record's value. A path in the app is
22
+ * refused as an `href`, so every address in the app is written one way.
23
+ */
24
+ link: (name: string, o: Omit<ActionOptions, "confirm"> & ({
25
+ to: AppAddress;
26
+ } | {
21
27
  href: string;
22
- }) => ActionModel;
28
+ })) => ActionModel;
23
29
  };
24
30
  //#endregion
@@ -1,3 +1,4 @@
1
+ import { appHrefError } from "./address-template.js";
1
2
  //#region src/view-actions.ts
2
3
  const named = (name, o) => ({
3
4
  name,
@@ -22,14 +23,29 @@ const actions = {
22
23
  ...named(name, o),
23
24
  run: { kind: "delete" }
24
25
  }),
25
- /** Opens `href`: a path in the app or a URL; `{field}` is replaced by the record's value, such as `/orders/{orderId}`. */
26
- link: (name, o) => ({
27
- ...named(name, o),
28
- run: {
29
- kind: "link",
30
- href: o.href
31
- }
32
- })
26
+ /**
27
+ * Goes `to` an address in the app, such as `{ kind: 'record', resource: 'orders', key: '{order_id}' }`, or opens an
28
+ * `href` that leaves it (`https:`, `mailto:`); `{field}` is replaced by the record's value. A path in the app is
29
+ * refused as an `href`, so every address in the app is written one way.
30
+ */
31
+ link: (name, o) => {
32
+ if ("to" in o) return {
33
+ ...named(name, o),
34
+ run: {
35
+ kind: "link",
36
+ to: structuredClone(o.to)
37
+ }
38
+ };
39
+ const error = appHrefError(o.href);
40
+ if (error) throw new Error(`Action "${name}": ${error}`);
41
+ return {
42
+ ...named(name, o),
43
+ run: {
44
+ kind: "link",
45
+ href: o.href
46
+ }
47
+ };
48
+ }
33
49
  };
34
50
  //#endregion
35
51
  export { actions };
@@ -0,0 +1,42 @@
1
+ import { ListDisplayModel } from "./model.js";
2
+ import { Ref } from "./refs.js";
3
+ //#region src/view-displays.d.ts
4
+ /** A custom component as `component(name)` from `@protobase/layout` declares it. */
5
+ export type ComponentRef<P> = ((props: P) => unknown) & {
6
+ readonly blockName: string;
7
+ readonly custom: boolean;
8
+ };
9
+ /** A custom component's props as a view gives them: plain data, or `r.field` where it takes a field's name. */
10
+ export type ViewProps<P> = { [K in keyof Omit<P, 'children'>]: P[K] extends string ? P[K] | Ref : P[K]; };
11
+ type Naming = {
12
+ name?: string;
13
+ label?: string;
14
+ icon?: string;
15
+ };
16
+ /** The name of the list's own display, which every list has. */
17
+ export declare const tableDisplay = "table";
18
+ export declare const displays: {
19
+ /**
20
+ * A card per record: `title` (default: the view's title) over `subtitle`, `badge` drawn as a badge with its value
21
+ * labels, and a few small `meta` fields such as dates. Named `cards` unless `name` says otherwise.
22
+ */
23
+ cards: (fields: {
24
+ title?: Ref;
25
+ subtitle?: Ref;
26
+ badge?: Ref;
27
+ meta?: Ref[];
28
+ }, o?: Naming) => ListDisplayModel;
29
+ /**
30
+ * The list drawn by a component of the project, registered in `protobase.ui.tsx` under its name. It gets `props` and
31
+ * reads the rows with `useListDisplay()`; `fields` are the fields it shows, fetched with relation names.
32
+ */
33
+ custom: <P extends object>(component: ComponentRef<P>, o: Naming & {
34
+ name: string;
35
+ label: string;
36
+ fields: Ref[];
37
+ props?: ViewProps<P>;
38
+ }) => ListDisplayModel;
39
+ };
40
+ /** Every field a display names: what it shows, and the field references in a custom display's props. */
41
+ export declare const displayFields: (display: ListDisplayModel) => string[];
42
+ //#endregion
@@ -0,0 +1,61 @@
1
+ import { fieldRefsIn, markFieldRefs } from "./field-ref.js";
2
+ //#region src/view-displays.ts
3
+ const naming = ({ label, icon }) => ({
4
+ ...label !== void 0 && { label },
5
+ ...icon !== void 0 && { icon }
6
+ });
7
+ /** The name of the list's own display, which every list has. */
8
+ const tableDisplay = "table";
9
+ /** The component of a custom display or section, which must be a project's, not a built-in block. */
10
+ const customComponent = (component, where) => {
11
+ if (!component.custom) throw new Error(`${where} takes a component declared with component(...), not the built-in block ${component.blockName}`);
12
+ return component.blockName;
13
+ };
14
+ const displays = {
15
+ /**
16
+ * A card per record: `title` (default: the view's title) over `subtitle`, `badge` drawn as a badge with its value
17
+ * labels, and a few small `meta` fields such as dates. Named `cards` unless `name` says otherwise.
18
+ */
19
+ cards: (fields, o = {}) => ({
20
+ kind: "cards",
21
+ name: o.name ?? "cards",
22
+ ...naming(o),
23
+ ...fields.title && { title: fields.title.name },
24
+ ...fields.subtitle && { subtitle: fields.subtitle.name },
25
+ ...fields.badge && { badge: fields.badge.name },
26
+ ...fields.meta && { meta: fields.meta.map((ref) => ref.name) }
27
+ }),
28
+ /**
29
+ * The list drawn by a component of the project, registered in `protobase.ui.tsx` under its name. It gets `props` and
30
+ * reads the rows with `useListDisplay()`; `fields` are the fields it shows, fetched with relation names.
31
+ */
32
+ custom: (component, o) => ({
33
+ kind: "custom",
34
+ name: o.name,
35
+ label: o.label,
36
+ ...naming({ icon: o.icon }),
37
+ component: customComponent(component, "d.custom"),
38
+ fields: o.fields.map((ref) => ref.name),
39
+ ...o.props && { props: markFieldRefs(o.props) }
40
+ })
41
+ };
42
+ const displayName = /^[a-z][a-zA-Z0-9-]*$/;
43
+ /** What is wrong with a list's displays and default, by their names alone. */
44
+ const displayErrors = (list) => {
45
+ const names = (list.displays ?? []).map((display) => display.name);
46
+ return [
47
+ ...names.filter((name) => !displayName.test(name)).map((name) => `display name "${name}" must start with a lowercase letter and hold only letters, digits and dashes`),
48
+ ...names.filter((name) => name === tableDisplay).map(() => `"${tableDisplay}" is the list's own display; give the display another name`),
49
+ ...names.filter((name, index) => names.indexOf(name) !== index).map((name) => `display "${name}" is defined twice`),
50
+ ...list.default === void 0 || list.default === "table" || names.includes(list.default) ? [] : [`default "${list.default}" is not one of the displays (${[tableDisplay, ...names].join(", ")})`]
51
+ ];
52
+ };
53
+ /** Every field a display names: what it shows, and the field references in a custom display's props. */
54
+ const displayFields = (display) => display.kind === "cards" ? [
55
+ display.title,
56
+ display.subtitle,
57
+ display.badge,
58
+ ...display.meta ?? []
59
+ ].filter((name) => name !== void 0) : [...display.fields, ...fieldRefsIn(display.props)];
60
+ //#endregion
61
+ export { customComponent, displayErrors, displayFields, displays, tableDisplay };
package/dist/view.d.ts CHANGED
@@ -1,6 +1,7 @@
1
- import { ActionModel, ChartGranularity, ChartRange, FilterWidgetModel, LayoutItemModel, NavModel, NavRecentModel, ResourceModel, SaveFeedback, ViewModel } from "./model.js";
1
+ import { ActionModel, ChartGranularity, ChartRange, FilterWidgetModel, LayoutItemModel, ListDisplayModel, NavModel, NavRecentModel, ResourceModel, SaveFeedback, ViewModel } from "./model.js";
2
2
  import { FieldRefs, Ref } from "./refs.js";
3
3
  import { actions } from "./view-actions.js";
4
+ import { displays } from "./view-displays.js";
4
5
  import { FieldView } from "./view-field.js";
5
6
  import { widgets } from "./view-widgets.js";
6
7
  //#region src/view.d.ts
@@ -44,10 +45,16 @@ export declare class ViewBuilder<R extends {
44
45
  nav(nav: NavInput<R>): ViewBuilder<R>;
45
46
  help(help: string): ViewBuilder<R>;
46
47
  fields(build: (r: FieldViews<R>) => Record<string, FieldView>): ViewBuilder<R>;
47
- list(build: (r: FieldRefs<FieldsOf<R>>) => {
48
+ /**
49
+ * The list: its table's `columns`, `sort` and `search`, and other `displays` to switch to (`d.cards`, `d.custom`), one
50
+ * of which may be the `default` the list opens in. Throws when display names collide or `default` names none.
51
+ */
52
+ list(build: (r: FieldRefs<FieldsOf<R>>, d: typeof displays) => {
48
53
  columns: Ref[];
49
54
  sort?: Array<[Ref, 'asc' | 'desc']>;
50
55
  search?: Ref[];
56
+ displays?: ListDisplayModel[];
57
+ default?: string;
51
58
  }): ViewBuilder<R>;
52
59
  filters(build: (r: FieldRefs<FieldsOf<R>>, w: typeof widgets) => FilterWidgetModel[]): ViewBuilder<R>;
53
60
  layout(build: (r: FieldRefs<FieldsOf<R>>) => LayoutItemModel[]): ViewBuilder<R>;
@@ -58,7 +65,7 @@ export declare class ViewBuilder<R extends {
58
65
  }): ViewBuilder<R>;
59
66
  saveFeedback(saveFeedback: SaveFeedback): ViewBuilder<R>;
60
67
  actions(build: (a: typeof actions) => ActionModel[]): ViewBuilder<R>;
61
- /** With the resource's model, also verifies the title and search result fields and the sidebar's `recent` group. */
68
+ /** With the resource's model, also verifies the title, search result and list display fields and the sidebar's `recent` group. */
62
69
  toModel(resource?: ResourceModel): ViewModel;
63
70
  private refs;
64
71
  private next;
package/dist/view.js CHANGED
@@ -1,4 +1,5 @@
1
- import { createRefs } from "./refs.js";
1
+ import { createRefs, ref } from "./refs.js";
2
+ import { displayErrors, displayFields, displays } from "./view-displays.js";
2
3
  import { navRecentErrors } from "./nav-recent.js";
3
4
  import { actions } from "./view-actions.js";
4
5
  import { FieldView } from "./view-field.js";
@@ -50,13 +51,22 @@ var ViewBuilder = class ViewBuilder {
50
51
  ...fields
51
52
  } });
52
53
  }
54
+ /**
55
+ * The list: its table's `columns`, `sort` and `search`, and other `displays` to switch to (`d.cards`, `d.custom`), one
56
+ * of which may be the `default` the list opens in. Throws when display names collide or `default` names none.
57
+ */
53
58
  list(build) {
54
- const { columns, sort, search } = build(this.refs());
55
- return this.next({ list: {
59
+ const { columns, sort, search, displays: shown = [], default: opens } = build(this.refs(), displays);
60
+ const list = {
56
61
  columns: columns.map((ref) => ref.name),
57
62
  ...sort && { sort: sort.map(([ref, dir]) => [ref.name, dir]) },
58
- ...search && { search: search.map((ref) => ref.name) }
59
- } });
63
+ ...search && { search: search.map((ref) => ref.name) },
64
+ ...shown.length > 0 && { displays: shown },
65
+ ...opens !== void 0 && { default: opens }
66
+ };
67
+ const errors = displayErrors(list);
68
+ if (errors.length > 0) throw new Error(`View "${this.state.resource}": ${errors.join("; ")}`);
69
+ return this.next({ list });
60
70
  }
61
71
  filters(build) {
62
72
  return this.next({ filters: build(this.refs(), widgets) });
@@ -78,18 +88,22 @@ var ViewBuilder = class ViewBuilder {
78
88
  actions(build) {
79
89
  return this.next({ actions: build(actions) });
80
90
  }
81
- /** With the resource's model, also verifies the title and search result fields and the sidebar's `recent` group. */
91
+ /** With the resource's model, also verifies the title, search result and list display fields and the sidebar's `recent` group. */
82
92
  toModel(resource) {
83
- const { title, nav, searchResult } = this.state;
93
+ const { title, nav, searchResult, list } = this.state;
84
94
  if (resource && title !== void 0 && !Object.hasOwn(resource.fields, title)) throw new Error(`View "${this.state.resource}": title field "${title}" does not exist on the resource`);
85
95
  const missing = resource ? [...searchResult?.title ?? [], ...searchResult?.subtitle ? [searchResult.subtitle] : []].filter((name) => !Object.hasOwn(resource.fields, name)) : [];
86
96
  if (missing.length > 0) throw new Error(`View "${this.state.resource}": search result field "${missing[0]}" does not exist on the resource`);
97
+ for (const display of resource ? list?.displays ?? [] : []) {
98
+ const unknown = displayFields(display).find((name) => !Object.hasOwn(resource.fields, name));
99
+ if (unknown !== void 0) throw new Error(`View "${this.state.resource}": display "${display.name}" names field "${unknown}", which the resource does not have`);
100
+ }
87
101
  const recentErrors = resource && nav?.recent ? navRecentErrors(nav.recent, resource) : [];
88
102
  if (recentErrors.length > 0) throw new Error(`View "${this.state.resource}": nav.recent ${recentErrors.join("; ")}`);
89
103
  return structuredClone(this.state);
90
104
  }
91
105
  refs() {
92
- return createRefs((name) => ({ name }));
106
+ return createRefs(ref);
93
107
  }
94
108
  next(patch) {
95
109
  return new ViewBuilder({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/schema",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Resource, field, view and access definitions for Protobase configs.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {