@protobase/ui 0.11.0 → 0.12.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.
Files changed (58) hide show
  1. package/dist/app/app.d.ts +3 -0
  2. package/dist/app/app.js +30 -13
  3. package/dist/app/custom-section.js +7 -3
  4. package/dist/app/displays/cards-display.js +57 -32
  5. package/dist/app/displays/custom-display.js +6 -2
  6. package/dist/app/displays/list-displays.d.ts +4 -0
  7. package/dist/app/field-editor.js +31 -2
  8. package/dist/app/field-types/field-type-cells.js +26 -0
  9. package/dist/app/field-types/field-widget.d.ts +28 -0
  10. package/dist/app/field-types/shown-widget.js +21 -0
  11. package/dist/app/field-types/widget-part.js +33 -0
  12. package/dist/app/list-page.js +8 -1
  13. package/dist/app/pages/custom-boundary.js +17 -0
  14. package/dist/app/pages/field-value.js +19 -2
  15. package/dist/app/pages/merge-ui.d.ts +3 -3
  16. package/dist/app/pages/merge-ui.js +11 -5
  17. package/dist/app/pages/project-ui.d.ts +13 -3
  18. package/dist/app/pages/project-ui.js +8 -2
  19. package/dist/app/pages/render-node.js +8 -4
  20. package/dist/app/record-page.js +16 -4
  21. package/dist/data/meta-data.d.ts +3 -1
  22. package/dist/data/use-meta.js +2 -1
  23. package/dist/data-grid/columns.js +1 -1
  24. package/dist/index.d.ts +6 -3
  25. package/dist/index.js +4 -2
  26. package/dist/live/field-values.js +9 -1
  27. package/dist/package.js +5 -0
  28. package/dist/plugins/load-plugin.d.ts +16 -0
  29. package/dist/plugins/load-plugin.js +73 -0
  30. package/dist/plugins/merge-plugins.js +36 -0
  31. package/dist/plugins/plugin-api.d.ts +10 -0
  32. package/dist/plugins/plugin-api.js +12 -0
  33. package/dist/plugins/plugin-context.d.ts +14 -0
  34. package/dist/plugins/plugin-context.js +12 -0
  35. package/dist/plugins/plugin-gate.js +35 -0
  36. package/dist/plugins/plugin-manifest.js +23 -0
  37. package/dist/plugins/plugin-notice.js +45 -0
  38. package/dist/plugins/plugin-ui.js +45 -0
  39. package/dist/plugins/use-plugin-loads.js +57 -0
  40. package/dist/shell/assets/app-GUjJKojE.css +1 -0
  41. package/dist/shell/assets/app-heHm3H5D.js +65 -0
  42. package/dist/shell/assets/index-B6FP_5El.js +9 -0
  43. package/dist/shell/assets/index-DheSSTn2.css +2 -0
  44. package/dist/shell/assets/jsx-runtime-NZYk81nU.js +1 -0
  45. package/dist/shell/assets/react-Cvdyeg_0.js +1 -0
  46. package/dist/shell/assets/react-dom-B-VxWsjA.js +1 -0
  47. package/dist/shell/assets/rolldown-runtime-hePW80VL.js +1 -0
  48. package/dist/shell/assets/shared-protobase-ui-C4sf7CCI.js +1 -0
  49. package/dist/shell/assets/shared-react-DhkZ2M4l.js +1 -0
  50. package/dist/shell/assets/shared-react-dom-D508a1cq.js +1 -0
  51. package/dist/shell/assets/shared-react-jsx-runtime-xPQrWk0g.js +1 -0
  52. package/dist/shell/index.html +24 -0
  53. package/dist/shell/protobase.shell.json +8 -0
  54. package/dist/styles.css +2 -97
  55. package/dist/theme.css +42 -0
  56. package/dist/tokens.css +71 -0
  57. package/package.json +11 -7
  58. package/dist/app/pages/project-ui-provider.js +0 -15
package/dist/app/app.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { SidebarMode } from "../app-shell/sidebar-mode.js";
2
2
  import "../app-shell/index.js";
3
3
  import { ProjectUi } from "./pages/project-ui.js";
4
+ import { PluginLoader } from "../plugins/load-plugin.js";
4
5
  import { AssistantClient, AuthSession, Client, RuntimeClient } from "@protobase/client";
5
6
  //#region src/app/app.d.ts
6
7
  export type AppProps = {
@@ -22,6 +23,8 @@ export type AppProps = {
22
23
  assistant?: AssistantClient;
23
24
  /** A ready-made client of the runtime endpoint, for stories and tests; used only when `/meta` names one. */
24
25
  runtime?: RuntimeClient;
26
+ /** How the plugins `/meta` names are loaded, for stories and tests; the page's `fetch` and `import()` by default. */
27
+ pluginLoader?: PluginLoader;
25
28
  };
26
29
  /** The admin: sign-in first, then a sidebar from `/meta`, `/:page` composed pages, `/:resource` lists and `/:resource/:key` records. Fills its parent. */
27
30
  export declare const App: ({ auth, workspace, ...rest }: AppProps) => import("react").JSX.Element;
package/dist/app/app.js CHANGED
@@ -18,6 +18,7 @@ import { breadcrumbFor } from "./breadcrumb-for.js";
18
18
  import { MetaGate, Notice, useAdminMeta } from "./meta-gate.js";
19
19
  import { Router, useRouter } from "./router.js";
20
20
  import { useAppAddress } from "./use-app-address.js";
21
+ import { CustomBoundary } from "./pages/custom-boundary.js";
21
22
  import { CreatePage } from "./create/create-page.js";
22
23
  import { ListPage } from "./list-page.js";
23
24
  import { MembersPage } from "../organizations/members-page.js";
@@ -30,7 +31,8 @@ import { useActiveRole } from "../roles/use-active-role.js";
30
31
  import { userMenuFromMeta } from "./user-menu-from-meta.js";
31
32
  import { navFromMeta } from "./nav-from-meta.js";
32
33
  import { ComposedPage } from "./pages/composed-page.js";
33
- import { ProjectUiProvider } from "./pages/project-ui-provider.js";
34
+ import { PluginGate, usePluginsOff } from "../plugins/plugin-gate.js";
35
+ import { PluginNotice } from "../plugins/plugin-notice.js";
34
36
  import { RecordPage } from "./record-page.js";
35
37
  import { useGlobalSearch } from "./search/use-global-search.js";
36
38
  import { RuntimeUpdateButton } from "./runtime/runtime-update.js";
@@ -104,6 +106,7 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
104
106
  const role = useActiveRole(useCallback(() => reopenIn(basePath), [basePath]));
105
107
  const acting = actingUser(user, role.current);
106
108
  const admin = isAdmin(acting);
109
+ const pluginsOff = usePluginsOff();
107
110
  const organizations = useOrganizations(Boolean(organizationSettings));
108
111
  const current = organizations.current;
109
112
  const outsideOrganizations = Boolean(organizationSettings) && current !== void 0 && !current.organization;
@@ -185,26 +188,39 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
185
188
  onSelect: open
186
189
  } : void 0,
187
190
  actions: (Actions || runtimeButton || assistantButton) && /* @__PURE__ */ jsxs(Fragment$1, { children: [
188
- Actions && /* @__PURE__ */ jsx(Actions, {}),
191
+ Actions && /* @__PURE__ */ jsx(CustomBoundary, {
192
+ name: "shell.actions",
193
+ fallback: null,
194
+ children: /* @__PURE__ */ jsx(Actions, {})
195
+ }),
189
196
  runtimeButton,
190
197
  assistantButton
191
198
  ] }),
192
- rightPanel: (RightPanel || assistantPanel) && /* @__PURE__ */ jsxs(Fragment$1, { children: [RightPanel && /* @__PURE__ */ jsx(RightPanel, {}), assistantPanel] }),
199
+ rightPanel: (RightPanel || assistantPanel) && /* @__PURE__ */ jsxs(Fragment$1, { children: [RightPanel && /* @__PURE__ */ jsx(CustomBoundary, {
200
+ name: "shell.rightPanel",
201
+ fallback: null,
202
+ children: /* @__PURE__ */ jsx(RightPanel, {})
203
+ }), assistantPanel] }),
193
204
  children: content
194
205
  });
195
- if (!staff) return appShell;
206
+ const pluginNotice = admin && pluginsOff.length > 0;
207
+ if (!staff && !pluginNotice) return appShell;
196
208
  return /* @__PURE__ */ jsxs("div", {
197
209
  className: "flex h-full min-h-0 flex-col",
198
- children: [/* @__PURE__ */ jsx(StaffBanner, {
199
- signIn: staff,
200
- onStop: stopStaffSession
201
- }), /* @__PURE__ */ jsx("div", {
202
- className: "min-h-0 flex-1",
203
- children: appShell
204
- })]
210
+ children: [
211
+ staff && /* @__PURE__ */ jsx(StaffBanner, {
212
+ signIn: staff,
213
+ onStop: stopStaffSession
214
+ }),
215
+ pluginNotice && /* @__PURE__ */ jsx(PluginNotice, { off: pluginsOff }),
216
+ /* @__PURE__ */ jsx("div", {
217
+ className: "min-h-0 flex-1",
218
+ children: appShell
219
+ })
220
+ ]
205
221
  });
206
222
  };
207
- const Authenticated = ({ baseUrl, client, basePath, initialUrl, workspace, sidebarMode, ui, assistant, runtime }) => {
223
+ const Authenticated = ({ baseUrl, client, basePath, initialUrl, workspace, sidebarMode, ui, assistant, runtime, pluginLoader }) => {
208
224
  const { session, markSignedOut } = useAuth();
209
225
  return /* @__PURE__ */ jsx(ApiProvider, {
210
226
  baseUrl,
@@ -214,8 +230,9 @@ const Authenticated = ({ baseUrl, client, basePath, initialUrl, workspace, sideb
214
230
  children: /* @__PURE__ */ jsx(Router, {
215
231
  basePath,
216
232
  initialUrl,
217
- children: /* @__PURE__ */ jsx(MetaGate, { children: /* @__PURE__ */ jsx(ToastProvider, { children: /* @__PURE__ */ jsx(ProjectUiProvider, {
233
+ children: /* @__PURE__ */ jsx(MetaGate, { children: /* @__PURE__ */ jsx(ToastProvider, { children: /* @__PURE__ */ jsx(PluginGate, {
218
234
  ui,
235
+ ...pluginLoader && { loader: pluginLoader },
219
236
  children: /* @__PURE__ */ jsx(Routes, {
220
237
  workspace,
221
238
  sidebarMode,
@@ -1,7 +1,8 @@
1
1
  import { useProjectUi } from "./pages/project-ui.js";
2
+ import { Problem } from "./pages/problem.js";
3
+ import { CustomBoundary } from "./pages/custom-boundary.js";
2
4
  import { Section } from "../record-view/section.js";
3
5
  import { SidebarCard } from "../record-view/record-sidebar.js";
4
- import { Problem } from "./pages/problem.js";
5
6
  import { RecordScope } from "./pages/record-scope.js";
6
7
  import { jsx, jsxs } from "react/jsx-runtime";
7
8
  import { unmarkFieldRefs } from "@protobase/schema";
@@ -15,11 +16,14 @@ const CustomSection = ({ item, scope }) => {
15
16
  const Component = components?.[item.component];
16
17
  const body = Component ? /* @__PURE__ */ jsx(RecordScope, {
17
18
  value: scope,
18
- children: /* @__PURE__ */ jsx(Component, { ...unmarkFieldRefs(item.props ?? {}) })
19
+ children: /* @__PURE__ */ jsx(CustomBoundary, {
20
+ name: item.component,
21
+ children: /* @__PURE__ */ jsx(Component, { ...unmarkFieldRefs(item.props ?? {}) })
22
+ })
19
23
  }) : /* @__PURE__ */ jsxs(Problem, { children: [
20
24
  "No component is registered as ",
21
25
  item.component,
22
- ". Export it from protobase.ui.tsx."
26
+ ". Export it from protobase.ui.tsx, or load the plugin that provides it."
23
27
  ] });
24
28
  if (item.sidebar) return item.title ? /* @__PURE__ */ jsx(SidebarCard, {
25
29
  title: item.title,
@@ -1,15 +1,22 @@
1
1
  import { Badge } from "../../primitives/badge.js";
2
2
  import { humanize } from "../../live/naming.js";
3
+ import { useProjectUi } from "../pages/project-ui.js";
3
4
  import { AppLink } from "../use-app-address.js";
4
5
  import { displayValue } from "../../live/display-value.js";
6
+ import { shownWidget } from "../field-types/shown-widget.js";
7
+ import { CustomBoundary } from "../pages/custom-boundary.js";
8
+ import { WidgetPart } from "../field-types/widget-part.js";
5
9
  import { Skeleton } from "../../primitives/skeleton.js";
6
10
  import { toneFor } from "../../live/columns-from-model.js";
7
11
  import { DisplayFooter, DisplayState } from "./display-frame.js";
8
12
  import { cardFields } from "./list-displays.js";
9
- import { jsx, jsxs } from "react/jsx-runtime";
13
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
10
14
  //#region src/app/displays/cards-display.tsx
11
15
  const grid = "grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-3";
12
- /** A card per record, opening its page: the title over the subtitle, the badge beside it and small meta fields below. */
16
+ /**
17
+ * A card per record, opening its page: the title over the subtitle, the badge beside it and small meta fields below;
18
+ * or, with a `card` component, what it draws of the row, the built-in content again when it is missing or throws.
19
+ */
13
20
  const CardsDisplay = ({ model, view, display, list }) => {
14
21
  const { title, subtitle, badge, meta } = cardFields(model, view, display);
15
22
  const label = (name) => view?.fields[name]?.label ?? humanize(name);
@@ -18,6 +25,19 @@ const CardsDisplay = ({ model, view, display, list }) => {
18
25
  if (!field) return "";
19
26
  return row.labels[name] ?? displayValue(field, row.record[name], view?.fields[name]);
20
27
  };
28
+ const { fields: fieldTypes, components } = useProjectUi();
29
+ const Card = display.card === void 0 ? void 0 : components?.[display.card];
30
+ const shownValue = (row, name) => {
31
+ const field = model.fields[name];
32
+ const shown = field && shownWidget(fieldTypes, field, view?.fields[name]);
33
+ return shown ? /* @__PURE__ */ jsx(WidgetPart, {
34
+ shown,
35
+ part: "display",
36
+ field,
37
+ value: row.record[name],
38
+ fallback: text(row, name)
39
+ }) : text(row, name);
40
+ };
21
41
  const plural = (view?.names?.plural ?? humanize(model.name)).toLowerCase();
22
42
  return /* @__PURE__ */ jsxs(DisplayState, {
23
43
  list,
@@ -31,42 +51,47 @@ const CardsDisplay = ({ model, view, display, list }) => {
31
51
  "aria-label": view?.names?.plural ?? humanize(model.name),
32
52
  children: list.rows.map((row) => {
33
53
  const status = badge === void 0 ? void 0 : row.record[badge];
34
- return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs(AppLink, {
54
+ const builtIn = /* @__PURE__ */ jsxs(Fragment, { children: [
55
+ /* @__PURE__ */ jsxs("span", {
56
+ className: "flex items-start justify-between gap-2",
57
+ children: [/* @__PURE__ */ jsx("span", {
58
+ className: "min-w-0 truncate font-medium",
59
+ children: shownValue(row, title)
60
+ }), badge !== void 0 && status !== null && status !== void 0 && /* @__PURE__ */ jsx(Badge, {
61
+ tone: toneFor(String(status)),
62
+ children: text(row, badge)
63
+ })]
64
+ }),
65
+ subtitle !== void 0 && /* @__PURE__ */ jsx("span", {
66
+ className: "truncate text-muted-foreground",
67
+ children: shownValue(row, subtitle)
68
+ }),
69
+ meta.length > 0 && /* @__PURE__ */ jsx("dl", {
70
+ className: "mt-auto flex flex-wrap gap-x-3 gap-y-0.5 pt-1 text-xs text-muted-foreground",
71
+ children: meta.map((name) => /* @__PURE__ */ jsxs("div", {
72
+ className: "flex gap-1",
73
+ children: [/* @__PURE__ */ jsx("dt", {
74
+ className: "sr-only",
75
+ children: label(name)
76
+ }), /* @__PURE__ */ jsx("dd", {
77
+ className: "tabular-nums",
78
+ children: shownValue(row, name)
79
+ })]
80
+ }, name))
81
+ })
82
+ ] });
83
+ return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(AppLink, {
35
84
  to: {
36
85
  kind: "record",
37
86
  resource: model.name,
38
87
  key: row.key
39
88
  },
40
89
  className: "flex h-full flex-col gap-1.5 rounded-lg border border-border bg-background p-3 text-[13px] hover:border-border-strong hover:bg-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30",
41
- children: [
42
- /* @__PURE__ */ jsxs("span", {
43
- className: "flex items-start justify-between gap-2",
44
- children: [/* @__PURE__ */ jsx("span", {
45
- className: "min-w-0 truncate font-medium",
46
- children: text(row, title)
47
- }), badge !== void 0 && status !== null && status !== void 0 && /* @__PURE__ */ jsx(Badge, {
48
- tone: toneFor(String(status)),
49
- children: text(row, badge)
50
- })]
51
- }),
52
- subtitle !== void 0 && /* @__PURE__ */ jsx("span", {
53
- className: "truncate text-muted-foreground",
54
- children: text(row, subtitle)
55
- }),
56
- meta.length > 0 && /* @__PURE__ */ jsx("dl", {
57
- className: "mt-auto flex flex-wrap gap-x-3 gap-y-0.5 pt-1 text-xs text-muted-foreground",
58
- children: meta.map((name) => /* @__PURE__ */ jsxs("div", {
59
- className: "flex gap-1",
60
- children: [/* @__PURE__ */ jsx("dt", {
61
- className: "sr-only",
62
- children: label(name)
63
- }), /* @__PURE__ */ jsx("dd", {
64
- className: "tabular-nums",
65
- children: text(row, name)
66
- })]
67
- }, name))
68
- })
69
- ]
90
+ children: Card ? /* @__PURE__ */ jsx(CustomBoundary, {
91
+ name: display.card,
92
+ fallback: builtIn,
93
+ children: /* @__PURE__ */ jsx(Card, { row })
94
+ }) : builtIn
70
95
  }) }, row.key);
71
96
  })
72
97
  }), /* @__PURE__ */ jsx(DisplayFooter, { list })]
@@ -1,5 +1,6 @@
1
1
  import { useProjectUi } from "../pages/project-ui.js";
2
2
  import { Problem } from "../pages/problem.js";
3
+ import { CustomBoundary } from "../pages/custom-boundary.js";
3
4
  import { ListDisplayProvider } from "./display-context.js";
4
5
  import { jsx, jsxs } from "react/jsx-runtime";
5
6
  import { unmarkFieldRefs } from "@protobase/schema";
@@ -14,13 +15,16 @@ const CustomDisplay = ({ display, list }) => {
14
15
  if (!Component) return /* @__PURE__ */ jsxs(Problem, { children: [
15
16
  "No component is registered as ",
16
17
  display.component,
17
- ". Export it from protobase.ui.tsx."
18
+ ". Export it from protobase.ui.tsx, or load the plugin that provides it."
18
19
  ] });
19
20
  return /* @__PURE__ */ jsx(ListDisplayProvider, {
20
21
  value: list,
21
22
  children: /* @__PURE__ */ jsx("div", {
22
23
  className: "min-h-0 flex-1 overflow-y-auto",
23
- children: /* @__PURE__ */ jsx(Component, { ...unmarkFieldRefs(display.props ?? {}) })
24
+ children: /* @__PURE__ */ jsx(CustomBoundary, {
25
+ name: display.component,
26
+ children: /* @__PURE__ */ jsx(Component, { ...unmarkFieldRefs(display.props ?? {}) })
27
+ })
24
28
  })
25
29
  });
26
30
  };
@@ -8,4 +8,8 @@ export type ListDisplayRow = {
8
8
  /** The name of the record a relation field points at, for the relations the list shows. */
9
9
  labels: Record<string, string>;
10
10
  };
11
+ /** What a card component of a cards display (`d.cards(..., { card })`) gets: the record it draws, of the display's fields. */
12
+ export type RecordCardProps = {
13
+ row: ListDisplayRow;
14
+ };
11
15
  //#endregion
@@ -4,9 +4,11 @@ import { Switch } from "../primitives/switch.js";
4
4
  import { AppLink } from "./use-app-address.js";
5
5
  import { RelationPicker } from "./relation-picker.js";
6
6
  import { displayValue } from "../live/display-value.js";
7
- import { isEditable } from "../live/field-values.js";
7
+ import { fromDraft, isEditable, toFieldDraft } from "../live/field-values.js";
8
8
  import { SensitiveField } from "./sensitive-field.js";
9
9
  import { FileField } from "./file-field.js";
10
+ import { useShownWidget } from "./field-types/shown-widget.js";
11
+ import { WidgetPart } from "./field-types/widget-part.js";
10
12
  import { jsx, jsxs } from "react/jsx-runtime";
11
13
  import { enumLabel } from "@protobase/schema";
12
14
  //#region src/app/field-editor.tsx
@@ -15,7 +17,7 @@ const numeric = /* @__PURE__ */ new Set([
15
17
  "bigint",
16
18
  "decimal"
17
19
  ]);
18
- const FieldEditor = ({ field, hints, value, stored, relationLabel, relationTo, relationPicker, invalid, locked, placeholder, reveal, resource, onChange }) => {
20
+ const BuiltInEditor = ({ field, hints, value, stored, relationLabel, relationTo, relationPicker, invalid, locked, placeholder, reveal, resource, onChange }) => {
19
21
  if (field.type === "file") return /* @__PURE__ */ jsx(FileField, {
20
22
  field,
21
23
  hints,
@@ -94,5 +96,32 @@ const FieldEditor = ({ field, hints, value, stored, relationLabel, relationTo, r
94
96
  onChange: (event) => onChange(event.target.value)
95
97
  });
96
98
  };
99
+ /**
100
+ * A field on a form: its field type's editor when the view chooses one that has an editor and the field may change,
101
+ * its display when it may not, and the built-in input or value otherwise, and when the field type throws.
102
+ */
103
+ const FieldEditor = (props) => {
104
+ const { field, hints, value, stored, invalid, locked, onChange } = props;
105
+ const shown = useShownWidget(field, hints);
106
+ const builtIn = /* @__PURE__ */ jsx(BuiltInEditor, { ...props });
107
+ if (!shown) return builtIn;
108
+ if (locked || !isEditable(field)) return /* @__PURE__ */ jsx(WidgetPart, {
109
+ shown,
110
+ part: "display",
111
+ field,
112
+ value: stored,
113
+ fallback: builtIn
114
+ });
115
+ if (field.type === "file" || field.type === "relation") return builtIn;
116
+ return /* @__PURE__ */ jsx(WidgetPart, {
117
+ shown,
118
+ part: "editor",
119
+ field,
120
+ value: fromDraft(field, value),
121
+ invalid: Boolean(invalid),
122
+ onChange: (next) => onChange(toFieldDraft(field, next)),
123
+ fallback: builtIn
124
+ });
125
+ };
97
126
  //#endregion
98
127
  export { FieldEditor };
@@ -0,0 +1,26 @@
1
+ import { shownWidget } from "./shown-widget.js";
2
+ import { WidgetPart } from "./widget-part.js";
3
+ import { renderCell } from "../../data-grid/cells.js";
4
+ import { jsx } from "react/jsx-runtime";
5
+ //#region src/app/field-types/field-type-cells.tsx
6
+ /**
7
+ * The list's columns, where the view shows a field with a field type drawn by the field type's cell (else its
8
+ * display), with the built-in cell as its fallback.
9
+ */
10
+ const withFieldTypeCells = (columns, model, view, fieldTypes) => columns.map((spec) => {
11
+ const field = model.fields[spec.id];
12
+ const shown = field && shownWidget(fieldTypes, field, view?.fields[spec.id]);
13
+ if (!shown) return spec;
14
+ return {
15
+ ...spec,
16
+ render: (row) => /* @__PURE__ */ jsx(WidgetPart, {
17
+ shown,
18
+ part: "cell",
19
+ field,
20
+ value: row[spec.id],
21
+ fallback: renderCell(spec, spec.value ? spec.value(row) : row[spec.id])
22
+ })
23
+ };
24
+ });
25
+ //#endregion
26
+ export { withFieldTypeCells };
@@ -0,0 +1,28 @@
1
+ import { ComponentType } from "react";
2
+ import { FieldModel, FieldType, JsonValue } from "@protobase/schema";
3
+ //#region src/app/field-types/field-widget.d.ts
4
+ /** What a field type's display and cell get: the field, its value as the API gives it (null when empty) and the view's props. */
5
+ export type FieldWidgetProps<V = unknown, P = Record<string, JsonValue>> = {
6
+ field: FieldModel;
7
+ value: V | null;
8
+ /** What the view gives in `.widget(name, props)`; `{}` without props. */
9
+ props: P;
10
+ };
11
+ /** What a field type's editor gets besides: a way to set the value, and whether the value it holds is refused. */
12
+ export type FieldWidgetEditorProps<V = unknown, P = Record<string, JsonValue>> = FieldWidgetProps<V, P> & {
13
+ onChange: (value: V | null) => void;
14
+ invalid: boolean;
15
+ };
16
+ /**
17
+ * A field type a plugin registers in `definePlugin({ fields: { rating: ... } })`, which a view chooses for a field with
18
+ * `r.score.widget('rating', props)`: the storage types it shows (`of`), how it shows a value on record pages, cards and
19
+ * page blocks (`display`), how it edits one on forms (`editor`; else the built-in input) and in a table (`cell`; else
20
+ * `display`). On a field of another storage type, and when it throws, the field shows as it would without it.
21
+ */
22
+ export type FieldWidget = {
23
+ of: FieldType[];
24
+ display: ComponentType<FieldWidgetProps<any, any>>;
25
+ editor?: ComponentType<FieldWidgetEditorProps<any, any>>;
26
+ cell?: ComponentType<FieldWidgetProps<any, any>>;
27
+ };
28
+ //#endregion
@@ -0,0 +1,21 @@
1
+ import { useProjectUi } from "../pages/project-ui.js";
2
+ //#region src/app/field-types/shown-widget.ts
3
+ /**
4
+ * The field type the view's `hints` choose for `field`, when one of `fields` has that name and shows the field's
5
+ * storage type; otherwise undefined, and the field shows as it would without one. A sensitive field never comes with
6
+ * its value, so it keeps its own.
7
+ */
8
+ const shownWidget = (fields, field, hints) => {
9
+ const chosen = hints?.widget;
10
+ if (!chosen || field.sensitive || !fields || !Object.hasOwn(fields, chosen.name)) return void 0;
11
+ const widget = fields[chosen.name];
12
+ return widget.of.includes(field.type) ? {
13
+ name: chosen.name,
14
+ widget,
15
+ props: chosen.props ?? {}
16
+ } : void 0;
17
+ };
18
+ /** `shownWidget` with the field types of the project's UI and its plugins. */
19
+ const useShownWidget = (field, hints) => shownWidget(useProjectUi().fields, field, hints);
20
+ //#endregion
21
+ export { shownWidget, useShownWidget };
@@ -0,0 +1,33 @@
1
+ import { CustomBoundary } from "../pages/custom-boundary.js";
2
+ import { jsx } from "react/jsx-runtime";
3
+ //#region src/app/field-types/widget-part.tsx
4
+ const partOf = ({ shown, part, field, value, onChange, invalid = false }) => {
5
+ const { widget, props } = shown;
6
+ const shared = {
7
+ field,
8
+ value: value ?? null,
9
+ props
10
+ };
11
+ if (part !== "editor") {
12
+ const Show = part === "cell" ? widget.cell ?? widget.display : widget.display;
13
+ return /* @__PURE__ */ jsx(Show, { ...shared });
14
+ }
15
+ const Editor = widget.editor;
16
+ return Editor && onChange ? /* @__PURE__ */ jsx(Editor, {
17
+ ...shared,
18
+ onChange,
19
+ invalid
20
+ }) : void 0;
21
+ };
22
+ /** One part of a field type for a field, inside a boundary that draws the built-in rendering instead when it throws. */
23
+ const WidgetPart = ({ fallback, ...rest }) => {
24
+ const element = partOf(rest);
25
+ if (!element) return fallback;
26
+ return /* @__PURE__ */ jsx(CustomBoundary, {
27
+ name: `The field type ${rest.shown.name}`,
28
+ fallback,
29
+ children: element
30
+ });
31
+ };
32
+ //#endregion
33
+ export { WidgetPart };
@@ -4,6 +4,7 @@ import { useAuth } from "../auth/auth-provider.js";
4
4
  import { Button } from "../primitives/button.js";
5
5
  import { recordKey, recordTitleField } from "../live/model-helpers.js";
6
6
  import { humanize } from "../live/naming.js";
7
+ import { useProjectUi } from "./pages/project-ui.js";
7
8
  import { parseSort, sortToOrderBy } from "./list-order.js";
8
9
  import { Notice, useAdminMeta, usePermissions } from "./meta-gate.js";
9
10
  import { useAppAddress } from "./use-app-address.js";
@@ -29,6 +30,7 @@ import { DisplaySwitch, SortMenu } from "./displays/display-controls.js";
29
30
  import { useDisplayRows } from "./displays/use-display-rows.js";
30
31
  import { ListChart } from "./list-chart.js";
31
32
  import { ListSearch } from "./list-search.js";
33
+ import { withFieldTypeCells } from "./field-types/field-type-cells.js";
32
34
  import { useMemo, useState } from "react";
33
35
  import { Plus, SlidersHorizontal } from "lucide-react";
34
36
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
@@ -58,7 +60,12 @@ const LoadedList = ({ model, address }) => {
58
60
  const resource = model.name;
59
61
  const view = meta.views[resource];
60
62
  const fields = useMemo(() => widgetFields(view, model), [view, model]);
61
- const columns = useMemo(() => columnsFromModel(model, view), [model, view]);
63
+ const { fields: fieldTypes } = useProjectUi();
64
+ const columns = useMemo(() => withFieldTypeCells(columnsFromModel(model, view), model, view, fieldTypes), [
65
+ model,
66
+ view,
67
+ fieldTypes
68
+ ]);
62
69
  const filterText = address.filter;
63
70
  const parsed = useMemo(() => textToState(fields, filterText), [fields, filterText]);
64
71
  const state = parsed.ok ? parsed.state : emptyFilterState;
@@ -0,0 +1,17 @@
1
+ import { Problem } from "./problem.js";
2
+ import { Component } from "react";
3
+ import { jsxs } from "react/jsx-runtime";
4
+ //#region src/app/pages/custom-boundary.tsx
5
+ /** Draws a warning in place of a project's or plugin's component that throws, so one component cannot blank the app. */
6
+ var CustomBoundary = class extends Component {
7
+ state = { failed: false };
8
+ static getDerivedStateFromError() {
9
+ return { failed: true };
10
+ }
11
+ render() {
12
+ if (!this.state.failed) return this.props.children;
13
+ return this.props.fallback === void 0 ? /* @__PURE__ */ jsxs(Problem, { children: [this.props.name, " could not be drawn."] }) : this.props.fallback;
14
+ }
15
+ };
16
+ //#endregion
17
+ export { CustomBoundary };
@@ -6,6 +6,8 @@ import { useAdminMeta } from "../meta-gate.js";
6
6
  import { AppLink } from "../use-app-address.js";
7
7
  import { fileShown } from "../../live/file-values.js";
8
8
  import { displayValue } from "../../live/display-value.js";
9
+ import { useShownWidget } from "../field-types/shown-widget.js";
10
+ import { WidgetPart } from "../field-types/widget-part.js";
9
11
  import { toneFor } from "../../live/columns-from-model.js";
10
12
  import { fetchLabels } from "../../live/relation-labels.js";
11
13
  import { jsx } from "react/jsx-runtime";
@@ -33,8 +35,7 @@ const RelationValue = ({ field, value, known }) => {
33
35
  children: known ?? label.data ?? id
34
36
  });
35
37
  };
36
- /** A stored value as the record page shows it: the view's hints, badges for statuses, names for relations (`relationLabel` when already known). */
37
- const FieldValue = ({ model, field, value, format, relationLabel }) => {
38
+ const BuiltInValue = ({ model, field, value, format, relationLabel }) => {
38
39
  const { views } = useAdminMeta();
39
40
  const hints = {
40
41
  ...views[model.name]?.fields[field.name],
@@ -67,5 +68,21 @@ const FieldValue = ({ model, field, value, format, relationLabel }) => {
67
68
  children: text
68
69
  });
69
70
  };
71
+ /**
72
+ * A stored value as the record page shows it: the display of the field type the view chooses, else the view's hints,
73
+ * badges for statuses, names for relations (`relationLabel` when already known).
74
+ */
75
+ const FieldValue = (props) => {
76
+ const { views } = useAdminMeta();
77
+ const shown = useShownWidget(props.field, views[props.model.name]?.fields[props.field.name]);
78
+ const builtIn = /* @__PURE__ */ jsx(BuiltInValue, { ...props });
79
+ return shown ? /* @__PURE__ */ jsx(WidgetPart, {
80
+ shown,
81
+ part: "display",
82
+ field: props.field,
83
+ value: props.value,
84
+ fallback: builtIn
85
+ }) : builtIn;
86
+ };
70
87
  //#endregion
71
88
  export { FieldValue };
@@ -1,9 +1,9 @@
1
1
  import { ProjectUi } from "./project-ui.js";
2
2
  //#region src/app/pages/merge-ui.d.ts
3
3
  /**
4
- * One UI config from several, each after the ones it `extends`, the last being the app's own: components and action
5
- * handlers are all kept, and one name defined by two configs is an error naming both; every shell slot is drawn,
6
- * the extended configs' first.
4
+ * One UI config from several, each after the ones it `extends`, the last being the app's own: components, action
5
+ * handlers and field types are all kept, and one name defined by two configs is an error naming both; every shell slot
6
+ * is drawn, the extended configs' first.
7
7
  */
8
8
  export declare const mergeUi: (...uis: ProjectUi[]) => ProjectUi;
9
9
  //#endregion
@@ -1,6 +1,10 @@
1
1
  import { uiFromModule } from "./ui-from-module.js";
2
2
  import { jsx } from "react/jsx-runtime";
3
3
  //#region src/app/pages/merge-ui.tsx
4
+ /** Two UI configs define one component or action handler differently. */
5
+ var UiConflictError = class extends Error {
6
+ name = "UiConflictError";
7
+ };
4
8
  const configOf = (parent) => Object.keys(parent).some((key) => key === "default" || /^[A-Z]/.test(key)) ? uiFromModule(parent) : parent;
5
9
  const flatten = (ui, label) => [...(ui.extends ?? []).map(configOf).flatMap((parent, index) => flatten(parent, parent.name ?? `${label} extends[${index}]`)), {
6
10
  ui,
@@ -10,7 +14,7 @@ const union = (kind, named, pick) => {
10
14
  const merged = {};
11
15
  const owners = /* @__PURE__ */ new Map();
12
16
  for (const { ui, label } of named) for (const [name, value] of Object.entries(pick(ui) ?? {})) {
13
- if (Object.hasOwn(merged, name) && merged[name] !== value) throw new Error(`${kind} "${name}" is defined twice, by ${owners.get(name)} and by ${label}; rename one of them`);
17
+ if (Object.hasOwn(merged, name) && merged[name] !== value) throw new UiConflictError(`${kind} "${name}" is defined twice, by ${owners.get(name)} and by ${label}; rename one of them`);
14
18
  merged[name] = value;
15
19
  owners.set(name, owners.get(name) ?? label);
16
20
  }
@@ -22,14 +26,15 @@ const slot = (slots) => {
22
26
  return All;
23
27
  };
24
28
  /**
25
- * One UI config from several, each after the ones it `extends`, the last being the app's own: components and action
26
- * handlers are all kept, and one name defined by two configs is an error naming both; every shell slot is drawn,
27
- * the extended configs' first.
29
+ * One UI config from several, each after the ones it `extends`, the last being the app's own: components, action
30
+ * handlers and field types are all kept, and one name defined by two configs is an error naming both; every shell slot
31
+ * is drawn, the extended configs' first.
28
32
  */
29
33
  const mergeUi = (...uis) => {
30
34
  const named = uis.flatMap((ui, index) => flatten(ui, ui.name ?? `UI config ${index + 1}`));
31
35
  const components = union("The component", named, (ui) => ui.components);
32
36
  const actions = union("The action handler", named, (ui) => ui.actions);
37
+ const fields = union("The field type", named, (ui) => ui.fields);
33
38
  const actionSlot = slot(named.flatMap(({ ui }) => ui.shell?.actions ? [ui.shell.actions] : []));
34
39
  const rightPanel = slot(named.flatMap(({ ui }) => ui.shell?.rightPanel ? [ui.shell.rightPanel] : []));
35
40
  const name = uis.at(-1)?.name;
@@ -37,6 +42,7 @@ const mergeUi = (...uis) => {
37
42
  ...name && { name },
38
43
  components,
39
44
  actions,
45
+ ...Object.keys(fields).length > 0 && { fields },
40
46
  ...(actionSlot || rightPanel) && { shell: {
41
47
  ...actionSlot && { actions: actionSlot },
42
48
  ...rightPanel && { rightPanel }
@@ -44,4 +50,4 @@ const mergeUi = (...uis) => {
44
50
  };
45
51
  };
46
52
  //#endregion
47
- export { mergeUi };
53
+ export { UiConflictError, mergeUi };