@protobase/ui 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/app/app.js +67 -28
  2. package/dist/app/batch-failure.js +1 -1
  3. package/dist/app/bottom-bar-from-meta.js +37 -0
  4. package/dist/app/breadcrumb-for.js +18 -9
  5. package/dist/app/create/create-page.js +12 -6
  6. package/dist/app/current-address.d.ts +26 -0
  7. package/dist/app/current-address.js +41 -0
  8. package/dist/app/custom-section.js +37 -0
  9. package/dist/app/delete/record-diff.js +1 -1
  10. package/dist/app/displays/cards-display.js +76 -0
  11. package/dist/app/displays/custom-display.js +28 -0
  12. package/dist/app/displays/display-context.d.ts +21 -0
  13. package/dist/app/displays/display-context.js +21 -0
  14. package/dist/app/displays/display-controls.js +55 -0
  15. package/dist/app/displays/display-frame.js +30 -0
  16. package/dist/app/displays/list-displays.d.ts +11 -0
  17. package/dist/app/displays/list-displays.js +62 -0
  18. package/dist/app/displays/record-link.d.ts +19 -0
  19. package/dist/app/displays/record-link.js +25 -0
  20. package/dist/app/displays/use-display-rows.js +28 -0
  21. package/dist/app/field-editor.js +4 -4
  22. package/dist/app/index.html +3 -2
  23. package/dist/app/list-page.js +87 -37
  24. package/dist/app/main.js +3 -2
  25. package/dist/app/nav-from-meta.js +9 -2
  26. package/dist/app/nav-recent.js +6 -2
  27. package/dist/app/pages/action-href.js +1 -3
  28. package/dist/app/pages/blocks/card-row.js +1 -1
  29. package/dist/app/pages/blocks/link.js +20 -11
  30. package/dist/app/pages/blocks/modal-form.js +1 -1
  31. package/dist/app/pages/blocks/record-card.js +1 -1
  32. package/dist/app/pages/blocks/stat.js +7 -4
  33. package/dist/app/pages/blocks/table.js +1 -1
  34. package/dist/app/pages/field-value.js +8 -4
  35. package/dist/app/pages/merge-ui.js +3 -1
  36. package/dist/app/pages/problem.js +10 -0
  37. package/dist/app/pages/project-ui.d.ts +12 -4
  38. package/dist/app/pages/record-actions.js +6 -3
  39. package/dist/app/pages/record-table.js +7 -3
  40. package/dist/app/pages/render-node.js +3 -6
  41. package/dist/app/pages/ui-from-module.d.ts +9 -0
  42. package/dist/app/pages/ui-from-module.js +23 -0
  43. package/dist/app/pages/use-run-action.js +7 -7
  44. package/dist/app/record-page.js +51 -26
  45. package/dist/app/related-section.js +1 -1
  46. package/dist/app/router.js +4 -20
  47. package/dist/app/search/search-model.js +6 -2
  48. package/dist/app/search/use-global-search.js +1 -1
  49. package/dist/app/use-app-address.d.ts +24 -0
  50. package/dist/app/use-app-address.js +47 -0
  51. package/dist/app/use-related-records.js +8 -2
  52. package/dist/app/user-menu-from-meta.js +9 -2
  53. package/dist/app-shell/app-shell.d.ts +1 -0
  54. package/dist/app-shell/app-shell.js +27 -14
  55. package/dist/app-shell/bottom-bar.d.ts +1 -0
  56. package/dist/app-shell/bottom-bar.js +57 -0
  57. package/dist/app-shell/index.d.ts +1 -0
  58. package/dist/app-shell/profile-menu.js +35 -26
  59. package/dist/app-shell/sidebar.js +3 -2
  60. package/dist/app-shell/switch-group.js +29 -0
  61. package/dist/auth/auth-provider.js +5 -1
  62. package/dist/auth/sign-in-form.js +23 -12
  63. package/dist/auth/sign-in-page.js +3 -2
  64. package/dist/data/meta-data.d.ts +3 -1
  65. package/dist/data/use-meta.js +1 -0
  66. package/dist/index.d.ts +7 -1
  67. package/dist/index.js +5 -1
  68. package/dist/live/audit-fields.js +5 -0
  69. package/dist/live/naming.js +2 -2
  70. package/dist/live/use-live-rows.js +7 -2
  71. package/dist/primitives/segmented.js +8 -5
  72. package/dist/roles/acting-user.js +11 -0
  73. package/dist/roles/use-active-role.js +33 -0
  74. package/package.json +4 -4
package/dist/app/app.js CHANGED
@@ -13,9 +13,11 @@ import { humanize } from "../live/naming.js";
13
13
  import { ToastProvider } from "../toasts/toast-provider.js";
14
14
  import { useProjectUi } from "./pages/project-ui.js";
15
15
  import { AssistantButton, AssistantPanel } from "./assistant-shell.js";
16
+ import { bottomBarFromMeta } from "./bottom-bar-from-meta.js";
16
17
  import { breadcrumbFor } from "./breadcrumb-for.js";
17
- import { Router, matchRoute, useRouter } from "./router.js";
18
18
  import { MetaGate, Notice, useAdminMeta } from "./meta-gate.js";
19
+ import { Router, useRouter } from "./router.js";
20
+ import { useAppAddress } from "./use-app-address.js";
19
21
  import { CreatePage } from "./create/create-page.js";
20
22
  import { ListPage } from "./list-page.js";
21
23
  import { MembersPage } from "../organizations/members-page.js";
@@ -23,6 +25,8 @@ import { InvitationsPage } from "../organizations/invitations-page.js";
23
25
  import { reopenIn, useOrganizations } from "../organizations/use-organizations.js";
24
26
  import { OrganizationPage } from "../organizations/organization-page.js";
25
27
  import { OrganizationsPage } from "../organizations/organizations-page.js";
28
+ import { actingUser } from "../roles/acting-user.js";
29
+ import { useActiveRole } from "../roles/use-active-role.js";
26
30
  import { userMenuFromMeta } from "./user-menu-from-meta.js";
27
31
  import { navFromMeta } from "./nav-from-meta.js";
28
32
  import { ComposedPage } from "./pages/composed-page.js";
@@ -32,34 +36,51 @@ import { useGlobalSearch } from "./search/use-global-search.js";
32
36
  import { RuntimeUpdateButton } from "./runtime/runtime-update.js";
33
37
  import { StaffBanner } from "./staff-banner.js";
34
38
  import { useNavRecent } from "./use-nav-recent.js";
35
- import { useEffect, useState } from "react";
39
+ import { useCallback, useEffect, useState } from "react";
36
40
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
37
41
  //#region src/app/app.tsx
42
+ const placeOf = (address) => address?.kind === "page" ? address.page : address?.resource;
43
+ const recordOf = (address) => address?.kind === "record" ? address : void 0;
38
44
  const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
39
45
  const meta = useAdminMeta();
40
46
  const { state, session, signOut, stopStaffSession, organizations: organizationSettings } = useAuth();
41
47
  if (state.kind !== "signed-in") throw new Error("The shell is only rendered for a signed-in user");
42
48
  const { user, staff } = state;
43
- const { path, params, basePath, navigate } = useRouter();
44
- const route = matchRoute(path);
49
+ const { path, params, basePath } = useRouter();
50
+ const { address, go, open } = useAppAddress();
51
+ const place = placeOf(address);
52
+ const shown = recordOf(address);
45
53
  const search = useGlobalSearch();
46
- const recent = useNavRecent(meta, basePath, route);
54
+ const recent = useNavRecent(meta, basePath, {
55
+ resource: place,
56
+ key: shown?.key
57
+ });
47
58
  const groups = navFromMeta(meta, basePath, recent);
48
- const first = Object.keys(meta.pages)[0] ?? Object.keys(meta.resources)[0];
49
- const page = route.resource && !route.key ? meta.pages[route.resource] : void 0;
50
- const open = (href) => navigate(href.startsWith(basePath) ? href.slice(basePath.length) : href);
59
+ const firstPage = Object.keys(meta.pages)[0];
60
+ const firstResource = Object.keys(meta.resources)[0];
61
+ const page = address?.kind === "page" ? meta.pages[address.page] : void 0;
51
62
  useEffect(() => {
52
- if (!route.resource && first) navigate(`/${first}`, { replace: true });
63
+ if (path !== "/") return;
64
+ if (firstPage) go({
65
+ kind: "page",
66
+ page: firstPage
67
+ }, { replace: true });
68
+ else if (firstResource) go({
69
+ kind: "list",
70
+ resource: firstResource
71
+ }, { replace: true });
53
72
  }, [
54
- route.resource,
55
- first,
56
- navigate
73
+ path,
74
+ firstPage,
75
+ firstResource,
76
+ go
57
77
  ]);
58
- const view = route.resource ? meta.views[route.resource] : void 0;
59
- const group = groups.find((entry) => entry.items.some((item) => item.id === route.resource));
60
- const model = route.resource ? meta.resources[route.resource] : void 0;
61
- const record = useRecord(route.resource ?? "", route.key ?? "", Boolean(model && route.key && route.key !== "new"));
62
- const title = model && record.data ? recordTitle(model, view, record.data.record, route.key ?? "") : route.key;
78
+ const resource = address?.kind === "page" ? void 0 : address?.resource;
79
+ const view = resource ? meta.views[resource] : void 0;
80
+ const group = groups.find((entry) => entry.items.some((item) => item.id === place));
81
+ const model = resource ? meta.resources[resource] : void 0;
82
+ const record = useRecord(shown?.resource ?? "", shown?.key ?? "", Boolean(model && shown));
83
+ const title = model && shown && record.data ? recordTitle(model, view, record.data.record, shown.key) : shown?.key;
63
84
  const { shell } = useProjectUi();
64
85
  const Actions = shell?.actions;
65
86
  const RightPanel = shell?.rightPanel;
@@ -80,7 +101,9 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
80
101
  url: runtimeUrl,
81
102
  client: runtime
82
103
  });
83
- const admin = isAdmin(user);
104
+ const role = useActiveRole(useCallback(() => reopenIn(basePath), [basePath]));
105
+ const acting = actingUser(user, role.current);
106
+ const admin = isAdmin(acting);
84
107
  const organizations = useOrganizations(Boolean(organizationSettings));
85
108
  const current = organizations.current;
86
109
  const outsideOrganizations = Boolean(organizationSettings) && current !== void 0 && !current.organization;
@@ -88,10 +111,10 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
88
111
  const accountPage = path === "/-/account" ? "Sign-in & security" : path === "/-/sign-in-policy" ? "Sign-in policy" : organizationPage;
89
112
  const breadcrumb = accountPage ? [accountPage] : breadcrumbFor({
90
113
  basePath,
91
- route,
114
+ address,
92
115
  group: group?.label,
93
116
  page,
94
- resourceLabel: view?.names?.plural ?? (route.resource && humanize(route.resource)),
117
+ resourceLabel: view?.names?.plural ?? (resource && humanize(resource)),
95
118
  recordTitle: title
96
119
  });
97
120
  const content = path === "/-/account" ? /* @__PURE__ */ jsx(AccountSecurityPage, { email: user.email }) : path === "/-/sign-in-policy" ? admin ? /* @__PURE__ */ jsx(SignInPolicyPage, {}) : /* @__PURE__ */ jsx(Notice, {
@@ -107,23 +130,34 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
107
130
  }) : path === "/-/organization/members" ? /* @__PURE__ */ jsx(MembersPage, { current }) : /* @__PURE__ */ jsx(InvitationsPage, { current }) : organizationPage && path === "/-/organizations" || outsideOrganizations ? /* @__PURE__ */ jsx(OrganizationsPage, {
108
131
  state: organizations,
109
132
  basePath
110
- }) : !route.resource ? /* @__PURE__ */ jsx(Notice, {
133
+ }) : !address ? path === "/" ? /* @__PURE__ */ jsx(Notice, {
111
134
  title: "Choose a resource",
112
135
  children: "Pick one from the sidebar."
113
- }) : page ? /* @__PURE__ */ jsx(ComposedPage, { name: page.name }, page.name) : route.key === "new" ? /* @__PURE__ */ jsx(CreatePage, { resource: route.resource }, `${route.resource}/new`) : route.key ? /* @__PURE__ */ jsx(RecordPage, {
114
- resource: route.resource,
115
- recordKey: route.key
116
- }, `${route.resource}/${route.key}`) : /* @__PURE__ */ jsx(ListPage, { resource: route.resource }, route.resource);
136
+ }) : /* @__PURE__ */ jsx(Notice, {
137
+ title: "Not found",
138
+ children: "There is nothing at this address."
139
+ }) : address.kind === "page" ? page ? /* @__PURE__ */ jsx(ComposedPage, { name: page.name }, page.name) : /* @__PURE__ */ jsxs(Notice, {
140
+ title: "Not found",
141
+ children: [
142
+ "There is no page called ",
143
+ address.page,
144
+ "."
145
+ ]
146
+ }) : address.kind === "create" ? /* @__PURE__ */ jsx(CreatePage, { resource: address.resource }, `${address.resource}/new`) : address.kind === "record" ? /* @__PURE__ */ jsx(RecordPage, {
147
+ resource: address.resource,
148
+ recordKey: address.key
149
+ }, `${address.resource}/${address.key}`) : /* @__PURE__ */ jsx(ListPage, { address }, address.resource);
117
150
  const appShell = /* @__PURE__ */ jsx(AppShell, {
118
151
  sidebarMode: sidebarMode ?? "text-small",
119
- activeItem: route.resource ?? "",
152
+ activeItem: place ?? "",
120
153
  breadcrumb,
121
- user: userToShell(user),
154
+ user: userToShell(acting),
122
155
  onSignOut: () => void signOut(),
123
156
  workspace: current?.organization?.name ?? workspace,
124
157
  navGroups: groups,
158
+ bottomBar: bottomBarFromMeta(meta, basePath, place),
125
159
  userMenu: [
126
- ...userMenuFromMeta(meta, basePath, route.resource),
160
+ ...userMenuFromMeta(meta, basePath, place),
127
161
  ...organizationSettings ? organizationMenuItems(basePath, path, current) : [],
128
162
  ...accountMenuItems(basePath, path, admin)
129
163
  ],
@@ -135,6 +169,11 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
135
169
  others: organizations.organizations.filter((organization) => organization.id !== current?.organization?.id),
136
170
  onSwitch: (id) => void session.organizations.switchTo(id).then(() => reopenIn(basePath))
137
171
  },
172
+ roles: role.current?.active ? {
173
+ active: role.current.active,
174
+ roles: role.current.roles,
175
+ onSwitch: role.switchTo
176
+ } : void 0,
138
177
  onNavigate: open,
139
178
  search: search.available ? {
140
179
  placeholder: search.placeholder,
@@ -4,7 +4,7 @@ const label = (op) => {
4
4
  const resource = typeof op.resource === "string" ? op.resource : op.resource.toModel().name;
5
5
  return op.op === "reorder" ? `reorder ${resource} by ${op.field}` : `${op.op} ${resource}`;
6
6
  };
7
- /** "Operation 2 (reorder invoiceLines by position) failed: ..." so the person knows which part was refused. */
7
+ /** "Operation 2 (reorder invoice_lines by position) failed: ..." so the person knows which part was refused. */
8
8
  const describeBatchFailure = (error, ops) => {
9
9
  const index = error.operation;
10
10
  const op = index === void 0 ? void 0 : ops[index];
@@ -0,0 +1,37 @@
1
+ import { iconFor } from "./icons.js";
2
+ import { menuIcon } from "./menu-icons.js";
3
+ import { ExternalLink, LayoutDashboard } from "lucide-react";
4
+ import { appPath } from "@protobase/schema";
5
+ //#region src/app/bottom-bar-from-meta.ts
6
+ /** The bottom bar's tabs from `/meta`: resources and pages open in the app (active while open), links open a new tab. */
7
+ const bottomBarFromMeta = (meta, basePath, activeResource) => (meta.bottomBar?.items ?? []).map((item, index) => {
8
+ if (item.kind === "link") return {
9
+ id: `link-${index}`,
10
+ label: item.label,
11
+ icon: menuIcon(item.icon, ExternalLink),
12
+ href: item.href,
13
+ external: true
14
+ };
15
+ if (item.kind === "page") return {
16
+ id: item.page,
17
+ label: item.label,
18
+ icon: menuIcon(item.icon ?? meta.pages[item.page]?.icon, LayoutDashboard),
19
+ href: appPath({
20
+ kind: "page",
21
+ page: item.page
22
+ }, basePath),
23
+ active: item.page === activeResource
24
+ };
25
+ return {
26
+ id: item.resource,
27
+ label: item.label,
28
+ icon: menuIcon(item.icon, iconFor(item.resource)),
29
+ href: appPath({
30
+ kind: "list",
31
+ resource: item.resource
32
+ }, basePath),
33
+ active: item.resource === activeResource
34
+ };
35
+ });
36
+ //#endregion
37
+ export { bottomBarFromMeta };
@@ -1,21 +1,30 @@
1
+ import { appPath } from "@protobase/schema";
1
2
  //#region src/app/breadcrumb-for.ts
2
3
  /** Group, then the page or resource list, then the record (or "New"): each with a page of its own links to it. */
3
- const breadcrumbFor = ({ basePath, route, group, page, resourceLabel, recordTitle }) => {
4
+ const breadcrumbFor = ({ basePath, address, group, page, resourceLabel, recordTitle }) => {
4
5
  const groupCrumb = group ? [group] : [];
5
6
  if (page) return [...groupCrumb, {
6
7
  label: page.title,
7
- href: `${basePath}/${page.name}`
8
+ href: appPath({
9
+ kind: "page",
10
+ page: page.name
11
+ }, basePath)
8
12
  }];
9
- if (!route.resource) return ["Admin"];
10
- const list = `${basePath}/${route.resource}`;
13
+ if (!address || address.kind === "page") return ["Admin"];
11
14
  const resource = resourceLabel ? [{
12
15
  label: resourceLabel,
13
- href: list
16
+ href: appPath({
17
+ kind: "list",
18
+ resource: address.resource
19
+ }, basePath)
14
20
  }] : [];
15
- if (!route.key) return [...groupCrumb, ...resource];
16
- const record = {
17
- label: route.key === "new" ? "New" : recordTitle ?? route.key,
18
- href: `${list}/${encodeURIComponent(route.key)}`
21
+ if (address.kind === "list") return [...groupCrumb, ...resource];
22
+ const record = address.kind === "create" ? {
23
+ label: "New",
24
+ href: appPath(address, basePath)
25
+ } : {
26
+ label: recordTitle ?? address.key,
27
+ href: appPath(address, basePath)
19
28
  };
20
29
  return [
21
30
  ...groupCrumb,
@@ -1,9 +1,9 @@
1
1
  import { useClient } from "../../data/api-provider.js";
2
2
  import { recordKey } from "../../live/model-helpers.js";
3
3
  import { humanize } from "../../live/naming.js";
4
- import { useRouter } from "../router.js";
5
- import { FieldEditor } from "../field-editor.js";
6
4
  import { Notice, useAdminMeta, usePermissions } from "../meta-gate.js";
5
+ import { useAppAddress } from "../use-app-address.js";
6
+ import { FieldEditor } from "../field-editor.js";
7
7
  import { can } from "../permissions.js";
8
8
  import { useLiveSave } from "../use-live-save.js";
9
9
  import { CreateRecordForm } from "./create-record-form.js";
@@ -12,7 +12,6 @@ import { useState } from "react";
12
12
  import { isApiError } from "@protobase/client";
13
13
  import { jsx, jsxs } from "react/jsx-runtime";
14
14
  import { useQueryClient } from "@tanstack/react-query";
15
- import { encodeKey } from "@protobase/schema";
16
15
  //#region src/app/create/create-page.tsx
17
16
  const without = (source, name) => Object.fromEntries(Object.entries(source).filter(([key]) => key !== name));
18
17
  /** `/:resource/new`: a form from the view's layout; on success it opens the new record. */
@@ -44,7 +43,7 @@ const LoadedCreate = ({ resource }) => {
44
43
  const view = meta.views[resource];
45
44
  const client = useClient();
46
45
  const queryClient = useQueryClient();
47
- const { navigate } = useRouter();
46
+ const { go } = useAppAddress();
48
47
  const noun = view?.names?.singular ?? humanize(resource);
49
48
  const feedback = useLiveSave(view?.saveFeedback ?? "toast", `New ${noun.toLowerCase()}`);
50
49
  const sections = createSections(model, view);
@@ -72,7 +71,11 @@ const LoadedCreate = ({ resource }) => {
72
71
  "facets",
73
72
  "series"
74
73
  ]) queryClient.invalidateQueries({ queryKey: [prefix, resource] });
75
- navigate(`/${resource}/${encodeKey(recordKey(model, created.record))}`, { replace: true });
74
+ go({
75
+ kind: "record",
76
+ resource,
77
+ key: recordKey(model, created.record)
78
+ }, { replace: true });
76
79
  });
77
80
  const formSections = sections.map((section) => ({
78
81
  title: section.title,
@@ -112,7 +115,10 @@ const LoadedCreate = ({ resource }) => {
112
115
  saving: feedback.phase === "saving",
113
116
  message: feedback.message,
114
117
  onSubmit: () => void submit(),
115
- onCancel: () => navigate(`/${resource}`)
118
+ onCancel: () => go({
119
+ kind: "list",
120
+ resource
121
+ })
116
122
  });
117
123
  };
118
124
  //#endregion
@@ -0,0 +1,26 @@
1
+ import { AppAddress, FilterLayout, ResourceModel, ViewModel } from "@protobase/schema";
2
+ //#region src/app/current-address.d.ts
3
+ /** A list as the app shows it: every value there, the view's default where the address names none. */
4
+ export type ListAddress = {
5
+ kind: 'list';
6
+ resource: string;
7
+ display: string;
8
+ filter: string;
9
+ orderBy: string;
10
+ filterLayout: FilterLayout;
11
+ };
12
+ /** The address the app shows, as logical values. A record's key is in the text form the record API takes. */
13
+ export type CurrentAddress = ListAddress | {
14
+ kind: 'record';
15
+ resource: string;
16
+ key: string;
17
+ } | {
18
+ kind: 'create';
19
+ resource: string;
20
+ } | {
21
+ kind: 'page';
22
+ page: string;
23
+ };
24
+ /** What can change about the list the app shows; `undefined` returns a value to its default. */
25
+ export type ListChange = Partial<Omit<ListAddress, 'kind' | 'resource'>>;
26
+ //#endregion
@@ -0,0 +1,41 @@
1
+ import { defaultOrderBy } from "./list-order.js";
2
+ import { encodeKey, tableDisplay } from "@protobase/schema";
3
+ //#region src/app/current-address.ts
4
+ /** What a list shows when its address names nothing: the view's default display, the list's sort, the filters in a bar. */
5
+ const listDefaults = (model, view) => ({
6
+ display: view?.list?.default ?? tableDisplay,
7
+ orderBy: model ? defaultOrderBy(model, view) : "",
8
+ filterLayout: "bar"
9
+ });
10
+ /** The address as read from the location, with a list's defaults filled in and a record's key as text. */
11
+ const currentAddress = (read, defaults) => {
12
+ if (read?.kind === "record") return {
13
+ kind: "record",
14
+ resource: read.resource,
15
+ key: encodeKey(read.key)
16
+ };
17
+ if (read?.kind !== "list") return read;
18
+ return {
19
+ ...defaults(read.resource),
20
+ filter: "",
21
+ ...read
22
+ };
23
+ };
24
+ /** The list address after `change`, leaving out every value that is empty or its default, so `/<resource>` stays clean. */
25
+ const changedList = (read, change, defaults) => {
26
+ const next = {
27
+ ...read,
28
+ ...change
29
+ };
30
+ const differs = (name) => next[name] !== void 0 && next[name] !== defaults[name];
31
+ return {
32
+ kind: "list",
33
+ resource: read.resource,
34
+ ...next.filter && { filter: next.filter },
35
+ ...differs("display") && { display: next.display },
36
+ ...differs("orderBy") && { orderBy: next.orderBy },
37
+ ...differs("filterLayout") && { filterLayout: next.filterLayout }
38
+ };
39
+ };
40
+ //#endregion
41
+ export { changedList, currentAddress, listDefaults };
@@ -0,0 +1,37 @@
1
+ import { useProjectUi } from "./pages/project-ui.js";
2
+ import { Section } from "../record-view/section.js";
3
+ import { SidebarCard } from "../record-view/record-sidebar.js";
4
+ import { Problem } from "./pages/problem.js";
5
+ import { RecordScope } from "./pages/record-scope.js";
6
+ import { jsx, jsxs } from "react/jsx-runtime";
7
+ import { unmarkFieldRefs } from "@protobase/schema";
8
+ //#region src/app/custom-section.tsx
9
+ /**
10
+ * A component of the record layout (`l.custom`), drawn with the record around it, so `useLayoutRecord()` gives it, in a
11
+ * section or a sidebar card under its title when it has one. The record page around it stays the framework's.
12
+ */
13
+ const CustomSection = ({ item, scope }) => {
14
+ const { components } = useProjectUi();
15
+ const Component = components?.[item.component];
16
+ const body = Component ? /* @__PURE__ */ jsx(RecordScope, {
17
+ value: scope,
18
+ children: /* @__PURE__ */ jsx(Component, { ...unmarkFieldRefs(item.props ?? {}) })
19
+ }) : /* @__PURE__ */ jsxs(Problem, { children: [
20
+ "No component is registered as ",
21
+ item.component,
22
+ ". Export it from protobase.ui.tsx."
23
+ ] });
24
+ if (item.sidebar) return item.title ? /* @__PURE__ */ jsx(SidebarCard, {
25
+ title: item.title,
26
+ children: body
27
+ }) : body;
28
+ return item.title ? /* @__PURE__ */ jsx(Section, {
29
+ title: item.title,
30
+ children: body
31
+ }) : /* @__PURE__ */ jsx("section", {
32
+ className: "py-8",
33
+ children: body
34
+ });
35
+ };
36
+ //#endregion
37
+ export { CustomSection };
@@ -1,7 +1,7 @@
1
1
  import { humanize } from "../../live/naming.js";
2
2
  import { displayValue } from "../../live/display-value.js";
3
3
  //#region src/app/delete/record-diff.ts
4
- const volatile = /* @__PURE__ */ new Set(["updatedAt"]);
4
+ const volatile = /* @__PURE__ */ new Set(["updated_at", "updatedAt"]);
5
5
  /** The fields whose stored value differs between two reads of a record, for the "changed since you opened it" dialog. */
6
6
  const diffRecords = (model, view, before, after) => Object.values(model.fields).flatMap((field) => {
7
7
  if (volatile.has(field.name) || JSON.stringify(before[field.name] ?? null) === JSON.stringify(after[field.name] ?? null)) return [];
@@ -0,0 +1,76 @@
1
+ import { Badge } from "../../primitives/badge.js";
2
+ import { humanize } from "../../live/naming.js";
3
+ import { AppLink } from "../use-app-address.js";
4
+ import { displayValue } from "../../live/display-value.js";
5
+ import { Skeleton } from "../../primitives/skeleton.js";
6
+ import { toneFor } from "../../live/columns-from-model.js";
7
+ import { DisplayFooter, DisplayState } from "./display-frame.js";
8
+ import { cardFields } from "./list-displays.js";
9
+ import { jsx, jsxs } from "react/jsx-runtime";
10
+ //#region src/app/displays/cards-display.tsx
11
+ 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. */
13
+ const CardsDisplay = ({ model, view, display, list }) => {
14
+ const { title, subtitle, badge, meta } = cardFields(model, view, display);
15
+ const label = (name) => view?.fields[name]?.label ?? humanize(name);
16
+ const text = (row, name) => {
17
+ const field = model.fields[name];
18
+ if (!field) return "";
19
+ return row.labels[name] ?? displayValue(field, row.record[name], view?.fields[name]);
20
+ };
21
+ const plural = (view?.names?.plural ?? humanize(model.name)).toLowerCase();
22
+ return /* @__PURE__ */ jsxs(DisplayState, {
23
+ list,
24
+ empty: `No ${plural} match.`,
25
+ loading: /* @__PURE__ */ jsx("div", {
26
+ className: grid,
27
+ children: Array.from({ length: 6 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: "h-24" }, index))
28
+ }),
29
+ children: [/* @__PURE__ */ jsx("ul", {
30
+ className: grid,
31
+ "aria-label": view?.names?.plural ?? humanize(model.name),
32
+ children: list.rows.map((row) => {
33
+ const status = badge === void 0 ? void 0 : row.record[badge];
34
+ return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs(AppLink, {
35
+ to: {
36
+ kind: "record",
37
+ resource: model.name,
38
+ key: row.key
39
+ },
40
+ 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
+ ]
70
+ }) }, row.key);
71
+ })
72
+ }), /* @__PURE__ */ jsx(DisplayFooter, { list })]
73
+ });
74
+ };
75
+ //#endregion
76
+ export { CardsDisplay };
@@ -0,0 +1,28 @@
1
+ import { useProjectUi } from "../pages/project-ui.js";
2
+ import { Problem } from "../pages/problem.js";
3
+ import { ListDisplayProvider } from "./display-context.js";
4
+ import { jsx, jsxs } from "react/jsx-runtime";
5
+ import { unmarkFieldRefs } from "@protobase/schema";
6
+ //#region src/app/displays/custom-display.tsx
7
+ /**
8
+ * A display the project draws: the component registered under its name gets the display's props, field references
9
+ * as the names they reference, and reads the list page's rows with `useListDisplay()`.
10
+ */
11
+ const CustomDisplay = ({ display, list }) => {
12
+ const { components } = useProjectUi();
13
+ const Component = components?.[display.component];
14
+ if (!Component) return /* @__PURE__ */ jsxs(Problem, { children: [
15
+ "No component is registered as ",
16
+ display.component,
17
+ ". Export it from protobase.ui.tsx."
18
+ ] });
19
+ return /* @__PURE__ */ jsx(ListDisplayProvider, {
20
+ value: list,
21
+ children: /* @__PURE__ */ jsx("div", {
22
+ className: "min-h-0 flex-1 overflow-y-auto",
23
+ children: /* @__PURE__ */ jsx(Component, { ...unmarkFieldRefs(display.props ?? {}) })
24
+ })
25
+ });
26
+ };
27
+ //#endregion
28
+ export { CustomDisplay };
@@ -0,0 +1,21 @@
1
+ import { ListDisplayRow } from "./list-displays.js";
2
+ import { ReactNode } from "react";
3
+ //#region src/app/displays/display-context.d.ts
4
+ /** The rows a list display draws: the list page loads them through the list API, with its filter, search and order. */
5
+ export type ListDisplay = {
6
+ resource: string;
7
+ rows: ListDisplayRow[];
8
+ /** How many records match, as the server counts them. */
9
+ total: number;
10
+ loading: boolean;
11
+ error: Error | null;
12
+ /** Whether more rows can be loaded with `loadMore`. */
13
+ more: boolean;
14
+ loadMore: () => void;
15
+ };
16
+ /**
17
+ * The list a custom display (`d.custom`) draws. The list page owns the rows, the address and the data: a display never
18
+ * fetches its list, changes the address or keeps its own history.
19
+ */
20
+ export declare const useListDisplay: () => ListDisplay;
21
+ //#endregion
@@ -0,0 +1,21 @@
1
+ import { createContext, useContext } from "react";
2
+ import { jsx } from "react/jsx-runtime";
3
+ //#region src/app/displays/display-context.tsx
4
+ const ListDisplayContext = createContext(void 0);
5
+ const ListDisplayProvider = ({ value, children }) => /* @__PURE__ */ jsx(ListDisplayContext.Provider, {
6
+ value,
7
+ children
8
+ });
9
+ /**
10
+ * The list a custom display (`d.custom`) draws. The list page owns the rows, the address and the data: a display never
11
+ * fetches its list, changes the address or keeps its own history.
12
+ */
13
+ const useListDisplay = () => {
14
+ const display = useContext(ListDisplayContext);
15
+ if (!display) throw new Error("useListDisplay is used inside a list display, which the list page draws");
16
+ return display;
17
+ };
18
+ /** The list display around a component, or `undefined` outside one. */
19
+ const useOptionalListDisplay = () => useContext(ListDisplayContext);
20
+ //#endregion
21
+ export { ListDisplayProvider, useListDisplay, useOptionalListDisplay };
@@ -0,0 +1,55 @@
1
+ import { humanize } from "../../live/naming.js";
2
+ import { parseSort } from "../list-order.js";
3
+ import { displayChoices, sortFields } from "./list-displays.js";
4
+ import { Segmented } from "../../primitives/segmented.js";
5
+ import { Calendar, Columns3, LayoutGrid, List, SquareKanban, Table2 } from "lucide-react";
6
+ import { jsx, jsxs } from "react/jsx-runtime";
7
+ //#region src/app/displays/display-controls.tsx
8
+ /** The icons a display's `icon` may name; another name shows the label alone. */
9
+ const icons = {
10
+ table: Table2,
11
+ cards: LayoutGrid,
12
+ kanban: SquareKanban,
13
+ board: Columns3,
14
+ list: List,
15
+ calendar: Calendar
16
+ };
17
+ /** The list page's switch between the table and the view's displays; nothing for a list with only its table. */
18
+ const DisplaySwitch = ({ view, value, onChange }) => {
19
+ const choices = displayChoices(view);
20
+ if (choices.length < 2) return null;
21
+ return /* @__PURE__ */ jsx(Segmented, {
22
+ label: "Display",
23
+ value,
24
+ onChange,
25
+ options: choices.map((choice) => ({
26
+ value: choice.name,
27
+ label: choice.label,
28
+ icon: choice.icon === void 0 ? void 0 : icons[choice.icon]
29
+ }))
30
+ });
31
+ };
32
+ const directions = [["asc", "ascending"], ["desc", "descending"]];
33
+ /** The order of a display without column headers, over the sortable fields it shows and the order the list has now. */
34
+ const SortMenu = ({ model, view, display, orderBy, onChange }) => {
35
+ const current = parseSort(orderBy);
36
+ const fields = sortFields(model, view, display);
37
+ const names = fields.includes(current.columnId) ? fields : [current.columnId, ...fields];
38
+ const label = (name) => view?.fields[name]?.label ?? humanize(name);
39
+ return /* @__PURE__ */ jsx("select", {
40
+ "aria-label": "Sort",
41
+ value: `${current.columnId} ${current.direction}`,
42
+ onChange: (event) => onChange(event.target.value),
43
+ className: "h-7 rounded-md border border-border-strong bg-background px-2 text-xs shadow-sm focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20",
44
+ children: names.flatMap((name) => directions.map(([direction, word]) => /* @__PURE__ */ jsxs("option", {
45
+ value: `${name} ${direction}`,
46
+ children: [
47
+ label(name),
48
+ ", ",
49
+ word
50
+ ]
51
+ }, `${name} ${direction}`)))
52
+ });
53
+ };
54
+ //#endregion
55
+ export { DisplaySwitch, SortMenu };