@protobase/layout 0.46.2 → 0.46.4

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.
@@ -177,9 +177,58 @@ const blockSpecs = {
177
177
  to: optional("address")
178
178
  }
179
179
  },
180
+ Tabs: {
181
+ children: true,
182
+ props: {}
183
+ },
184
+ Tab: {
185
+ children: true,
186
+ props: {
187
+ name: required("text"),
188
+ label: required("text")
189
+ }
190
+ },
191
+ Settings: {
192
+ children: false,
193
+ props: {
194
+ resource: required("text"),
195
+ fields: optional("texts"),
196
+ title: optional("text"),
197
+ description: optional("text"),
198
+ span: optional("span")
199
+ }
200
+ },
180
201
  Users: {
181
202
  children: false,
182
203
  props: {}
204
+ },
205
+ Profile: {
206
+ children: false,
207
+ props: {}
208
+ },
209
+ Password: {
210
+ children: false,
211
+ props: {}
212
+ },
213
+ Passkeys: {
214
+ children: false,
215
+ props: {}
216
+ },
217
+ TwoFactor: {
218
+ children: false,
219
+ props: {}
220
+ },
221
+ ConnectedAccounts: {
222
+ children: false,
223
+ props: {}
224
+ },
225
+ Devices: {
226
+ children: false,
227
+ props: {}
228
+ },
229
+ ConnectedApps: {
230
+ children: false,
231
+ props: {}
183
232
  }
184
233
  };
185
234
  const formats = [
package/dist/blocks.d.ts CHANGED
@@ -183,8 +183,36 @@ export type LinkProps = ({
183
183
  }) & {
184
184
  children?: LayoutChildren;
185
185
  };
186
+ /** One route per `Tab` below the page's address: the first tab at the page's own, each other at `/<page>/<tab name>`. */
187
+ export type TabsProps = {
188
+ children?: LayoutChildren;
189
+ };
190
+ /** One tab of a `Tabs`: `name` is its address, after the page's, `label` the word it shows. */
191
+ export type TabProps = {
192
+ name: string;
193
+ label: string;
194
+ children?: LayoutChildren;
195
+ };
196
+ /**
197
+ * The signed-in person's own record of `resource`, a resource whose `.owner(...)` is its key, as settings: each of
198
+ * `fields` as a control that saves on change, its default until the first save creates the record. Every caller, an
199
+ * admin too, reads and writes only their own record of it. `fields` defaults to every field a person can set.
200
+ */
201
+ export type SettingsProps = {
202
+ resource: string;
203
+ fields?: string[];
204
+ title?: string;
205
+ description?: string;
206
+ span?: GridSpan;
207
+ };
186
208
  /** Everyone who can sign in, for admins: inviting by email, adding accounts, changing roles and blocking. Takes no props. */
187
209
  export type UsersProps = Record<string, never>;
210
+ /**
211
+ * A part of the signed-in person's own account, for the `account` page only: their profile, password, passkeys,
212
+ * two-factor authentication, connected accounts or connected apps. Each takes no props, loads its own data, and hides
213
+ * itself where it does not apply, such as Connected apps without MCP.
214
+ */
215
+ export type AccountBlockProps = Record<string, never>;
188
216
  export declare const Page: Block<PageProps>;
189
217
  export declare const Grid: Block<GridProps>;
190
218
  export declare const Card: Block<CardProps>;
@@ -201,5 +229,15 @@ export declare const ModalForm: Block<ModalFormProps>;
201
229
  export declare const Action: Block<ActionProps>;
202
230
  export declare const Show: Block<ShowProps>;
203
231
  export declare const Link: Block<LinkProps>;
232
+ export declare const Tabs: Block<TabsProps>;
233
+ export declare const Tab: Block<TabProps>;
234
+ export declare const Settings: Block<SettingsProps>;
204
235
  export declare const Users: Block<UsersProps>;
236
+ export declare const Profile: Block<AccountBlockProps>;
237
+ export declare const Password: Block<AccountBlockProps>;
238
+ export declare const Passkeys: Block<AccountBlockProps>;
239
+ export declare const TwoFactor: Block<AccountBlockProps>;
240
+ export declare const ConnectedAccounts: Block<AccountBlockProps>;
241
+ export declare const Devices: Block<AccountBlockProps>;
242
+ export declare const ConnectedApps: Block<AccountBlockProps>;
205
243
  //#endregion
package/dist/blocks.js CHANGED
@@ -16,6 +16,16 @@ const ModalForm = defineBlock("ModalForm", false);
16
16
  const Action = defineBlock("Action", false);
17
17
  const Show = defineBlock("Show", false);
18
18
  const Link = defineBlock("Link", false);
19
+ const Tabs = defineBlock("Tabs", false);
20
+ const Tab = defineBlock("Tab", false);
21
+ const Settings = defineBlock("Settings", false);
19
22
  const Users = defineBlock("Users", false);
23
+ const Profile = defineBlock("Profile", false);
24
+ const Password = defineBlock("Password", false);
25
+ const Passkeys = defineBlock("Passkeys", false);
26
+ const TwoFactor = defineBlock("TwoFactor", false);
27
+ const ConnectedAccounts = defineBlock("ConnectedAccounts", false);
28
+ const Devices = defineBlock("Devices", false);
29
+ const ConnectedApps = defineBlock("ConnectedApps", false);
20
30
  //#endregion
21
- export { Action, Card, CardRow, Chart, Field, Grid, Link, ModalForm, Page, Progress, RecordCard, RecordCards, RecordRow, Show, Stat, Table, Users };
31
+ export { Action, Card, CardRow, Chart, ConnectedAccounts, ConnectedApps, Devices, Field, Grid, Link, ModalForm, Page, Passkeys, Password, Profile, Progress, RecordCard, RecordCards, RecordRow, Settings, Show, Stat, Tab, Table, Tabs, TwoFactor, Users };
@@ -0,0 +1,6 @@
1
+ //#region src/check-account.d.ts
2
+ /** The page that holds the signed-in person's own account. */
3
+ export declare const accountPageName = "account";
4
+ /** The blocks that act on the signed-in person's own sign-in, so they belong on the account page alone. */
5
+ export declare const accountBlocks: ReadonlySet<string>;
6
+ //#endregion
@@ -0,0 +1,25 @@
1
+ import { walkTree } from "./walk.js";
2
+ //#region src/check-account.ts
3
+ /** The page that holds the signed-in person's own account. */
4
+ const accountPageName = "account";
5
+ /** The blocks that act on the signed-in person's own sign-in, so they belong on the account page alone. */
6
+ const accountBlocks = /* @__PURE__ */ new Set([
7
+ "Profile",
8
+ "Password",
9
+ "Passkeys",
10
+ "TwoFactor",
11
+ "ConnectedAccounts",
12
+ "Devices",
13
+ "ConnectedApps"
14
+ ]);
15
+ /** Each account block on a page other than the account page, with where it is. */
16
+ const accountBlockProblems = (name, tree) => {
17
+ if (name === "account") return [];
18
+ const problems = [];
19
+ walkTree(tree, (node, { path }) => {
20
+ if (!node.custom && accountBlocks.has(node.type)) problems.push(`${path}: <${node.type}> acts on the signed-in person's own account, so it is only allowed on the "${accountPageName}" page`);
21
+ });
22
+ return problems;
23
+ };
24
+ //#endregion
25
+ export { accountBlockProblems, accountBlocks, accountPageName };
@@ -1,5 +1,6 @@
1
1
  import { dataProps } from "./node.js";
2
2
  import { walkTree } from "./walk.js";
3
+ import { settingsProblems } from "./settings.js";
3
4
  import { chartProblems } from "./check-chart.js";
4
5
  import { resolveFieldPath } from "./field-path.js";
5
6
  import { conditionProblems } from "./check-condition.js";
@@ -73,6 +74,9 @@ const nodeProblems = (node, scope, { models, actions, pages, createPages }) => {
73
74
  }
74
75
  for (const name of Object.keys(node.props.values ?? {})) if (!Object.hasOwn(model.fields, name)) problems.push(`values: unknown field "${name}" on ${model.name}`);
75
76
  break;
77
+ case "Settings":
78
+ if (model) problems.push(...settingsProblems(model, node.props.fields));
79
+ break;
76
80
  case "Action":
77
81
  if (resource !== void 0 && !actions(resource).has(String(node.props.name))) problems.push(`no action "${node.props.name}" on ${resource}; declare it with view('${resource}').actions(...)`);
78
82
  break;
@@ -1,6 +1,7 @@
1
1
  import { blockSpecs, propProblems } from "./block-specs.js";
2
- import { parseCondition } from "./condition.js";
3
2
  import { walkTree } from "./walk.js";
3
+ import { parseCondition } from "./condition.js";
4
+ import { tabsProblems } from "./check-tabs.js";
4
5
  import { appHrefError } from "@protobase/schema/internal";
5
6
  //#region src/check-structure.ts
6
7
  /** Problems that need a record around the element, which only a `RecordCard` or a `CardRow` card provides. */
@@ -33,8 +34,8 @@ const linkProblems = ({ props }) => {
33
34
  };
34
35
  /**
35
36
  * Checks a page's tree without the models: the root is one `Page`, every built-in element has known props of the right
36
- * kind, elements that show a record's fields sit inside one, and conditions parse. Returns the problems, each with
37
- * where it is.
37
+ * kind, elements that show a record's fields sit inside one, tabs are placed as their address needs, and conditions
38
+ * parse. Returns the problems, each with where it is.
38
39
  */
39
40
  const structureProblems = (tree) => {
40
41
  const problems = [];
@@ -55,7 +56,7 @@ const structureProblems = (tree) => {
55
56
  if (!parsed.ok) report(`"when" is not a valid condition: ${parsed.message}`);
56
57
  }
57
58
  });
58
- return problems;
59
+ return [...problems, ...tabsProblems(tree)];
59
60
  };
60
61
  //#endregion
61
62
  export { structureProblems };
@@ -0,0 +1,36 @@
1
+ import { walkTree } from "./walk.js";
2
+ //#region src/check-tabs.ts
3
+ const tabName = /^[a-z][a-zA-Z0-9-]*$/;
4
+ const isBuiltIn = (child, type) => typeof child !== "string" && !child.custom && child.type === type;
5
+ const ownProblems = (tabs) => {
6
+ const problems = [];
7
+ if (tabs.children.some((child) => !isBuiltIn(child, "Tab"))) problems.push("holds only <Tab> elements");
8
+ const names = tabs.children.filter((child) => isBuiltIn(child, "Tab")).map((tab) => tab.props.name);
9
+ if (names.length === 0) problems.push("holds no <Tab>");
10
+ for (const name of names.filter((name) => typeof name === "string" && !tabName.test(name))) problems.push(`tab name "${String(name)}" must start with a lowercase letter and hold only letters, digits and dashes`);
11
+ for (const name of new Set(names.filter((name, index) => names.indexOf(name) !== index))) problems.push(`two tabs are named "${String(name)}"`);
12
+ return problems;
13
+ };
14
+ /**
15
+ * Problems with a page's tabs: the tabs take the page's address, so a page holds at most one `Tabs`, directly in its
16
+ * `Page`, each `Tab` sits directly in it, and every tab has a name of its own that fits in an address.
17
+ */
18
+ const tabsProblems = (tree) => {
19
+ const problems = [];
20
+ const placed = /* @__PURE__ */ new Set();
21
+ let count = 0;
22
+ walkTree(tree, (node, { path }) => {
23
+ if (node.custom) return;
24
+ if (node.type === "Tabs") {
25
+ count += 1;
26
+ if (!tree.children.includes(node)) problems.push(`${path}: <Tabs> only sits directly in the <Page>`);
27
+ if (count === 2) problems.push(`${path}: a page holds one <Tabs>, as its tabs take the page's address`);
28
+ for (const child of node.children) if (isBuiltIn(child, "Tab")) placed.add(child);
29
+ ownProblems(node).forEach((problem) => problems.push(`${path}: ${problem}`));
30
+ }
31
+ if (node.type === "Tab" && !placed.has(node)) problems.push(`${path}: <Tab> only sits directly in a <Tabs>`);
32
+ });
33
+ return problems;
34
+ };
35
+ //#endregion
36
+ export { tabsProblems };
package/dist/index.d.ts CHANGED
@@ -1,10 +1,13 @@
1
1
  import { Block, LayoutChild, LayoutChildren, LayoutNode, LayoutValue, isLayoutNode } from "./node.js";
2
- import { Action, ActionProps, ButtonVariant, Card, CardProps, CardRow, CardRowProps, CardWidth, CardsLayout, Chart, ChartBucket, ChartProps, ChartType, DataProps, Elements, Field, FieldProps, Grid, GridProps, GridSpan, Link, LinkProps, ModalForm, ModalFormProps, Page, PageProps, Progress, ProgressProps, RecordCard, RecordCardProps, RecordCards, RecordCardsProps, RecordRow, RecordRowProps, Show, ShowProps, Stat, StatProps, Table, TableProps, Users, UsersProps } from "./blocks.js";
2
+ import { AccountBlockProps, Action, ActionProps, ButtonVariant, Card, CardProps, CardRow, CardRowProps, CardWidth, CardsLayout, Chart, ChartBucket, ChartProps, ChartType, ConnectedAccounts, ConnectedApps, DataProps, Devices, Elements, Field, FieldProps, Grid, GridProps, GridSpan, Link, LinkProps, ModalForm, ModalFormProps, Page, PageProps, Passkeys, Password, Profile, Progress, ProgressProps, RecordCard, RecordCardProps, RecordCards, RecordCardsProps, RecordRow, RecordRowProps, Settings, SettingsProps, Show, ShowProps, Stat, StatProps, Tab, TabProps, Table, TableProps, Tabs, TabsProps, TwoFactor, Users, UsersProps } from "./blocks.js";
3
3
  import { ComponentOptions, component } from "./component.js";
4
- import { usersPage } from "./presets/users-page.js";
4
+ import { accountBlocks, accountPageName } from "./check-account.js";
5
5
  import { PageDefinition, PageModel, PageOptions, isPageDefinition, page } from "./page.js";
6
+ import { settingsFields, settingsResources, withSettingsFields } from "./settings.js";
7
+ import { accountPage, accountTabs } from "./presets/account-page.js";
8
+ import { usersPage } from "./presets/users-page.js";
6
9
  import { cardsLayoutOf } from "./cards-layout.js";
7
- import { Location, walkTree } from "./walk.js";
10
+ import { Location, mapTree, walkTree } from "./walk.js";
8
11
  import { checkPages } from "./check-page.js";
9
12
  import { conditionProblems, conditionReadable } from "./check-condition.js";
10
13
  import { prunePage } from "./prune-page.js";
@@ -12,4 +15,4 @@ import { countedResource, evaluateCondition, parseCondition } from "./condition.
12
15
  import { PathStep, resolveFieldPath } from "./field-path.js";
13
16
  import { jsx_runtime_d_exports } from "./jsx-runtime.js";
14
17
  import { FieldRefValue, fieldRef } from "@protobase/schema";
15
- export { Action, type ActionProps, type Block, type ButtonVariant, Card, type CardProps, CardRow, type CardRowProps, type CardWidth, type CardsLayout, Chart, type ChartBucket, type ChartProps, type ChartType, type ComponentOptions, type DataProps, type Elements, Field, type FieldProps, type FieldRefValue, Grid, type GridProps, type GridSpan, type LayoutChild, type LayoutChildren, type LayoutNode, type LayoutValue, Link, type LinkProps, type Location, ModalForm, type ModalFormProps, Page, type PageDefinition, type PageModel, type PageOptions, type PageProps, type PathStep, Progress, type ProgressProps, RecordCard, type RecordCardProps, RecordCards, type RecordCardsProps, RecordRow, type RecordRowProps, Show, type ShowProps, Stat, type StatProps, Table, type TableProps, Users, type UsersProps, cardsLayoutOf, checkPages, component, conditionProblems, conditionReadable, countedResource, evaluateCondition, fieldRef, isLayoutNode, isPageDefinition, jsx_runtime_d_exports as jsxRuntime, page, parseCondition, prunePage, resolveFieldPath, usersPage, walkTree };
18
+ export { type AccountBlockProps, Action, type ActionProps, type Block, type ButtonVariant, Card, type CardProps, CardRow, type CardRowProps, type CardWidth, type CardsLayout, Chart, type ChartBucket, type ChartProps, type ChartType, type ComponentOptions, ConnectedAccounts, ConnectedApps, type DataProps, Devices, type Elements, Field, type FieldProps, type FieldRefValue, Grid, type GridProps, type GridSpan, type LayoutChild, type LayoutChildren, type LayoutNode, type LayoutValue, Link, type LinkProps, type Location, ModalForm, type ModalFormProps, Page, type PageDefinition, type PageModel, type PageOptions, type PageProps, Passkeys, Password, type PathStep, Profile, Progress, type ProgressProps, RecordCard, type RecordCardProps, RecordCards, type RecordCardsProps, RecordRow, type RecordRowProps, Settings, type SettingsProps, Show, type ShowProps, Stat, type StatProps, Tab, type TabProps, Table, type TableProps, Tabs, type TabsProps, TwoFactor, Users, type UsersProps, accountBlocks, accountPage, accountPageName, accountTabs, cardsLayoutOf, checkPages, component, conditionProblems, conditionReadable, countedResource, evaluateCondition, fieldRef, isLayoutNode, isPageDefinition, jsx_runtime_d_exports as jsxRuntime, mapTree, page, parseCondition, prunePage, resolveFieldPath, settingsFields, settingsResources, usersPage, walkTree, withSettingsFields };
package/dist/index.js CHANGED
@@ -1,9 +1,12 @@
1
1
  import { isLayoutNode } from "./node.js";
2
- import { Action, Card, CardRow, Chart, Field, Grid, Link, ModalForm, Page, Progress, RecordCard, RecordCards, RecordRow, Show, Stat, Table, Users } from "./blocks.js";
2
+ import { Action, Card, CardRow, Chart, ConnectedAccounts, ConnectedApps, Devices, Field, Grid, Link, ModalForm, Page, Passkeys, Password, Profile, Progress, RecordCard, RecordCards, RecordRow, Settings, Show, Stat, Tab, Table, Tabs, TwoFactor, Users } from "./blocks.js";
3
3
  import { component } from "./component.js";
4
+ import { mapTree, walkTree } from "./walk.js";
5
+ import { accountBlocks, accountPageName } from "./check-account.js";
6
+ import { settingsFields, settingsResources, withSettingsFields } from "./settings.js";
4
7
  import { countedResource, evaluateCondition, parseCondition } from "./condition.js";
5
- import { walkTree } from "./walk.js";
6
8
  import { isPageDefinition, page } from "./page.js";
9
+ import { accountPage, accountTabs } from "./presets/account-page.js";
7
10
  import { usersPage } from "./presets/users-page.js";
8
11
  import { cardsLayoutOf } from "./cards-layout.js";
9
12
  import { resolveFieldPath } from "./field-path.js";
@@ -12,4 +15,4 @@ import { checkPages } from "./check-page.js";
12
15
  import { prunePage } from "./prune-page.js";
13
16
  import { jsx_runtime_exports } from "./jsx-runtime.js";
14
17
  import { fieldRef } from "@protobase/schema";
15
- export { Action, Card, CardRow, Chart, Field, Grid, Link, ModalForm, Page, Progress, RecordCard, RecordCards, RecordRow, Show, Stat, Table, Users, cardsLayoutOf, checkPages, component, conditionProblems, conditionReadable, countedResource, evaluateCondition, fieldRef, isLayoutNode, isPageDefinition, jsx_runtime_exports as jsxRuntime, page, parseCondition, prunePage, resolveFieldPath, usersPage, walkTree };
18
+ export { Action, Card, CardRow, Chart, ConnectedAccounts, ConnectedApps, Devices, Field, Grid, Link, ModalForm, Page, Passkeys, Password, Profile, Progress, RecordCard, RecordCards, RecordRow, Settings, Show, Stat, Tab, Table, Tabs, TwoFactor, Users, accountBlocks, accountPage, accountPageName, accountTabs, cardsLayoutOf, checkPages, component, conditionProblems, conditionReadable, countedResource, evaluateCondition, fieldRef, isLayoutNode, isPageDefinition, jsx_runtime_exports as jsxRuntime, mapTree, page, parseCondition, prunePage, resolveFieldPath, settingsFields, settingsResources, usersPage, walkTree, withSettingsFields };
package/dist/page.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { accountBlockProblems } from "./check-account.js";
1
2
  import { structureProblems } from "./check-structure.js";
2
3
  //#region src/page.ts
3
4
  const pageName = /^[a-z][a-zA-Z0-9-]*$/;
@@ -7,7 +8,7 @@ const pageName = /^[a-z][a-zA-Z0-9-]*$/;
7
8
  */
8
9
  const page = (name, tree, options = {}) => {
9
10
  if (!pageName.test(name)) throw new Error(`Page name "${name}" must start with a lowercase letter and hold only letters, digits and dashes`);
10
- const problems = structureProblems(tree);
11
+ const problems = [...structureProblems(tree), ...accountBlockProblems(name, tree)];
11
12
  if (problems.length > 0) throw new Error(`Page "${name}" is not a valid layout:\n${problems.map((problem) => ` - ${problem}`).join("\n")}`);
12
13
  const model = {
13
14
  name,
@@ -0,0 +1,20 @@
1
+ import { LayoutNode } from "../node.js";
2
+ import { PageDefinition } from "../page.js";
3
+ import "../index.js";
4
+ //#region src/presets/account-page.d.ts
5
+ /**
6
+ * The standard tabs of the account page, by name, for an account page of the app's own: list the ones to keep, in
7
+ * the order to show them, beside tabs of its own. Written as calls rather than JSX, as `usersPage` is.
8
+ */
9
+ export declare const accountTabs: {
10
+ profile: LayoutNode;
11
+ security: LayoutNode;
12
+ devices: LayoutNode;
13
+ apps: LayoutNode;
14
+ };
15
+ /**
16
+ * The account page everyone signed in has at `/-/account`, each tab below it (`/-/account/security`). The server
17
+ * serves it when the config exports no page named `account`; exporting one replaces it.
18
+ */
19
+ export declare const accountPage: PageDefinition;
20
+ //#endregion
@@ -0,0 +1,45 @@
1
+ import { ConnectedAccounts, ConnectedApps, Devices, Page, Passkeys, Password, Profile, Tab, Tabs, TwoFactor } from "../blocks.js";
2
+ import { accountPageName } from "../check-account.js";
3
+ import { page } from "../page.js";
4
+ //#region src/presets/account-page.ts
5
+ /**
6
+ * The standard tabs of the account page, by name, for an account page of the app's own: list the ones to keep, in
7
+ * the order to show them, beside tabs of its own. Written as calls rather than JSX, as `usersPage` is.
8
+ */
9
+ const accountTabs = {
10
+ profile: Tab({
11
+ name: "profile",
12
+ label: "Profile",
13
+ children: [Profile({})]
14
+ }),
15
+ security: Tab({
16
+ name: "security",
17
+ label: "Sign-in & security",
18
+ children: [
19
+ Password({}),
20
+ Passkeys({}),
21
+ TwoFactor({}),
22
+ ConnectedAccounts({})
23
+ ]
24
+ }),
25
+ devices: Tab({
26
+ name: "devices",
27
+ label: "Devices",
28
+ children: [Devices({})]
29
+ }),
30
+ apps: Tab({
31
+ name: "apps",
32
+ label: "Connected apps",
33
+ children: [ConnectedApps({})]
34
+ })
35
+ };
36
+ /**
37
+ * The account page everyone signed in has at `/-/account`, each tab below it (`/-/account/security`). The server
38
+ * serves it when the config exports no page named `account`; exporting one replaces it.
39
+ */
40
+ const accountPage = page(accountPageName, Page({
41
+ title: "Account",
42
+ children: Tabs({ children: Object.values(accountTabs) })
43
+ }));
44
+ //#endregion
45
+ export { accountPage, accountTabs };
@@ -16,9 +16,9 @@ export type CallerAccess = {
16
16
  /**
17
17
  * The page as one caller may see it, or `undefined` when its roles leave the caller out. Every element that names a
18
18
  * resource, field, filter, sort or action the caller cannot use is left out, with what it contains, custom components
19
- * by their `resource` and field references (`fieldRef`) too; table columns and form fields are narrowed to what the
20
- * caller may read and write. Data still comes from the API, which applies
21
- * the same access rules (row filters included) to every request.
19
+ * by their `resource` and field references (`fieldRef`) too, and so is a tab left with nothing in it; table columns and
20
+ * form fields are narrowed to what the caller may read and write. Data still comes from the API, which applies the same
21
+ * access rules (row filters included) to every request.
22
22
  */
23
23
  export declare const prunePage: (page: PageModel, roles: readonly string[], access: CallerAccess) => PageModel | undefined;
24
24
  //#endregion
@@ -1,6 +1,6 @@
1
1
  import { dataProps } from "./node.js";
2
- import { countedResource, parseCondition } from "./condition.js";
3
2
  import { mapTree } from "./walk.js";
3
+ import { countedResource, parseCondition } from "./condition.js";
4
4
  import { chartFields } from "./check-chart.js";
5
5
  import { resolveFieldPath } from "./field-path.js";
6
6
  import { elementResource, linkSource } from "./check-page.js";
@@ -80,6 +80,17 @@ const prune = (node, scope, access) => {
80
80
  }
81
81
  } : null;
82
82
  }
83
+ case "Settings": {
84
+ if (!model) return null;
85
+ const fields = (node.props.fields ?? []).filter((name) => Object.hasOwn(model.fields, name));
86
+ return fields.length > 0 ? {
87
+ ...node,
88
+ props: {
89
+ ...node.props,
90
+ fields
91
+ }
92
+ } : null;
93
+ }
83
94
  case "Action": return resource !== void 0 && model && access.actions(resource).has(String(node.props.name)) ? node : null;
84
95
  case "Link": return node.props.to === void 0 || addressErrors(node.props.to, {
85
96
  models,
@@ -95,15 +106,17 @@ const prune = (node, scope, access) => {
95
106
  return counted !== void 0 ? Boolean(models[counted]) : readable(name);
96
107
  }) ? node : null;
97
108
  }
109
+ case "Tab":
110
+ case "Tabs": return node.children.length > 0 ? node : null;
98
111
  default: return node;
99
112
  }
100
113
  };
101
114
  /**
102
115
  * The page as one caller may see it, or `undefined` when its roles leave the caller out. Every element that names a
103
116
  * resource, field, filter, sort or action the caller cannot use is left out, with what it contains, custom components
104
- * by their `resource` and field references (`fieldRef`) too; table columns and form fields are narrowed to what the
105
- * caller may read and write. Data still comes from the API, which applies
106
- * the same access rules (row filters included) to every request.
117
+ * by their `resource` and field references (`fieldRef`) too, and so is a tab left with nothing in it; table columns and
118
+ * form fields are narrowed to what the caller may read and write. Data still comes from the API, which applies the same
119
+ * access rules (row filters included) to every request.
107
120
  */
108
121
  const prunePage = (page, roles, access) => {
109
122
  if (page.roles && !page.roles.some((role) => roles.includes(role))) return void 0;
@@ -0,0 +1,10 @@
1
+ import { PageModel } from "./page.js";
2
+ import { ResourceModel } from "@protobase/schema";
3
+ //#region src/settings.d.ts
4
+ /** The fields a `Settings` of `model` shows without `fields`: every one a person can set, in the model's order. */
5
+ export declare const settingsFields: (model: ResourceModel) => string[];
6
+ /** The resources the pages' `Settings` blocks show, which every caller reads and writes only their own records of. */
7
+ export declare const settingsResources: (pages: readonly PageModel[]) => Set<string>;
8
+ /** The page with each `Settings` without `fields` given its default fields, from the full models, before pruning. */
9
+ export declare const withSettingsFields: (page: PageModel, models: Record<string, ResourceModel>) => PageModel;
10
+ //#endregion
@@ -0,0 +1,65 @@
1
+ import { mapTree, walkTree } from "./walk.js";
2
+ //#region src/settings.ts
3
+ const setBySystem = (model) => /* @__PURE__ */ new Set([
4
+ ...model.primaryKey,
5
+ model.owner,
6
+ model.tenant,
7
+ model.softDelete,
8
+ model.position?.field
9
+ ]);
10
+ /** The fields a `Settings` of `model` shows without `fields`: every one a person can set, in the model's order. */
11
+ const settingsFields = (model) => {
12
+ const system = setBySystem(model);
13
+ return Object.entries(model.fields).filter(([name, field]) => !system.has(name) && !field.readOnly && !field.sensitive && !field.file).map(([name]) => name);
14
+ };
15
+ /**
16
+ * What keeps `model` from holding settings: an owner that is its whole key, so each person has one record, and no
17
+ * tenant, as settings are personal; and the `fields` it names.
18
+ */
19
+ const settingsProblems = (model, fields) => {
20
+ const { name, owner, primaryKey, tenant } = model;
21
+ if (owner === void 0) return [`${name} has no .owner(...), so its records are not each person's own`];
22
+ const problems = [];
23
+ if (primaryKey.length !== 1 || primaryKey[0] !== owner) problems.push(`the owner "${owner}" must be the key of ${name}, so each person has one record: .primaryKey((r) => r.${owner})`);
24
+ if (tenant !== void 0) problems.push(`${name} has a tenant, and settings are each person's own: leave out .tenant()`);
25
+ if (fields === void 0) {
26
+ if (settingsFields(model).length === 0) problems.push(`${name} has no field a person can set`);
27
+ return problems;
28
+ }
29
+ const system = setBySystem(model);
30
+ for (const field of fields) {
31
+ const found = Object.hasOwn(model.fields, field) ? model.fields[field] : void 0;
32
+ if (!found) problems.push(`unknown field "${field}" on ${name}`);
33
+ else if (system.has(field)) problems.push(`"${field}" is set by the server on ${name}, so it is no setting`);
34
+ else if (found.sensitive) problems.push(`"${field}" is sensitive, and Settings does not show sensitive fields`);
35
+ else if (found.file) problems.push(`"${field}" is a file, and Settings does not upload files`);
36
+ }
37
+ return problems;
38
+ };
39
+ const isSettings = (node) => !node.custom && node.type === "Settings" && typeof node.props.resource === "string";
40
+ /** The resources the pages' `Settings` blocks show, which every caller reads and writes only their own records of. */
41
+ const settingsResources = (pages) => {
42
+ const found = /* @__PURE__ */ new Set();
43
+ for (const page of pages) walkTree(page.tree, (node) => void (isSettings(node) && found.add(String(node.props.resource))));
44
+ return found;
45
+ };
46
+ /** The page with each `Settings` without `fields` given its default fields, from the full models, before pruning. */
47
+ const withSettingsFields = (page, models) => {
48
+ const tree = mapTree(page.tree, (node) => {
49
+ if (!isSettings(node) || node.props.fields !== void 0) return node;
50
+ const model = models[String(node.props.resource)];
51
+ return model ? {
52
+ ...node,
53
+ props: {
54
+ ...node.props,
55
+ fields: settingsFields(model)
56
+ }
57
+ } : node;
58
+ });
59
+ return {
60
+ ...page,
61
+ tree
62
+ };
63
+ };
64
+ //#endregion
65
+ export { settingsFields, settingsProblems, settingsResources, withSettingsFields };
package/dist/walk.d.ts CHANGED
@@ -9,6 +9,11 @@ export type Location = {
9
9
  path: string;
10
10
  scope: Scope;
11
11
  };
12
+ /**
13
+ * Rebuilds the tree bottom-up through `visit`, which sees each element (with its children and element props already
14
+ * rebuilt) and returns it, a replacement, or `null` to leave it out. Text children are kept as they are.
15
+ */
16
+ export declare const mapTree: (node: LayoutNode, visit: (node: LayoutNode, at: Location) => LayoutNode | null, at?: Location) => LayoutNode | null;
12
17
  /** Calls `visit` for every element, parents first. */
13
18
  export declare const walkTree: (node: LayoutNode, visit: (node: LayoutNode, at: Location) => void, at?: Location) => void;
14
19
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/layout",
3
- "version": "0.46.2",
3
+ "version": "0.46.4",
4
4
  "description": "Composed pages for Protobase, written as JSX that compiles to plain data, with its JSX runtime.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -31,7 +31,7 @@
31
31
  "access": "public"
32
32
  },
33
33
  "dependencies": {
34
- "@protobase/schema": "0.46.2",
34
+ "@protobase/schema": "0.46.4",
35
35
  "aip-parsers": "^1.0.0"
36
36
  }
37
37
  }