@protobase/ui 0.21.2 → 0.21.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.
Files changed (49) hide show
  1. package/dist/app/app.js +13 -1
  2. package/dist/app/current-address.js +2 -2
  3. package/dist/app/delete/blocked-delete.js +34 -0
  4. package/dist/app/delete/use-delete-flow.js +20 -2
  5. package/dist/app/displays/display-controls.js +3 -4
  6. package/dist/app/displays/list-cards.js +2 -2
  7. package/dist/app/displays/list-displays.js +58 -4
  8. package/dist/app/displays/record-card.js +2 -2
  9. package/dist/app/document-title.js +27 -0
  10. package/dist/app/embedded-list/embedded-columns.js +1 -1
  11. package/dist/app/embedded-list/embedded-display.js +6 -2
  12. package/dist/app/embedded-list/embedded-list.js +11 -2
  13. package/dist/app/field-editor.js +3 -6
  14. package/dist/app/hand-order/hand-sorted-list.js +10 -1
  15. package/dist/app/list-empty-state.js +31 -0
  16. package/dist/app/list-empty.js +23 -0
  17. package/dist/app/list-page.js +34 -7
  18. package/dist/app/numeric-input.js +21 -0
  19. package/dist/app/pages/field-value.js +1 -1
  20. package/dist/app/record-page.js +2 -2
  21. package/dist/app/related-dots/related-dots-columns.js +2 -1
  22. package/dist/app/related-dots/related-dots.js +1 -1
  23. package/dist/data/meta-data.d.ts +2 -0
  24. package/dist/data/use-meta.js +1 -0
  25. package/dist/data-grid/cell-text.js +30 -0
  26. package/dist/data-grid/cells.js +27 -35
  27. package/dist/data-grid/content-widths.js +81 -0
  28. package/dist/data-grid/data-grid.js +20 -7
  29. package/dist/data-grid/default-pins.js +13 -0
  30. package/dist/data-grid/grid-row.js +5 -1
  31. package/dist/data-grid/pinned-storage.js +2 -1
  32. package/dist/data-grid/truncated-text.js +16 -0
  33. package/dist/data-grid/use-fitted-columns.js +28 -0
  34. package/dist/data-grid/use-pinned-columns.js +6 -5
  35. package/dist/live/columns-from-model.js +6 -6
  36. package/dist/package.js +1 -1
  37. package/dist/shell/assets/app-CcsrYoRP.js +69 -0
  38. package/dist/shell/assets/index-C1fzTv_X.css +2 -0
  39. package/dist/shell/assets/{index-7lQIOMrK.js → index-DQ-VSGee.js} +1 -1
  40. package/dist/shell/assets/{maplibre-DEejTZ7I.js → maplibre-4azlIk9w.js} +1 -1
  41. package/dist/shell/assets/{markdown-content-ZzP9i4q4.js → markdown-content-DnIl00m7.js} +1 -1
  42. package/dist/shell/assets/{record-map-B-zsmXL2.js → record-map-CUEC6IyM.js} +1 -1
  43. package/dist/shell/assets/{records-map-Lj1MQgsW.js → records-map-ataN9m4I.js} +1 -1
  44. package/dist/shell/assets/{shared-protobase-ui-Bnd7T1j4.js → shared-protobase-ui-Cibu09RP.js} +1 -1
  45. package/dist/shell/index.html +4 -4
  46. package/dist/shell/protobase.shell.json +1 -1
  47. package/package.json +4 -4
  48. package/dist/shell/assets/app-BnuJWZWd.js +0 -69
  49. package/dist/shell/assets/index-B48ty05p.css +0 -2
package/dist/app/app.js CHANGED
@@ -21,6 +21,7 @@ import { useProjectUi } from "./pages/project-ui.js";
21
21
  import { AssistantButton, AssistantPanel } from "./assistant-shell.js";
22
22
  import { bottomBarFromMeta } from "./bottom-bar-from-meta.js";
23
23
  import { breadcrumbFor } from "./breadcrumb-for.js";
24
+ import { documentTitle, titleParts, useDocumentTitle } from "./document-title.js";
24
25
  import { sourceStatusOf } from "../data/with-sources.js";
25
26
  import { MetaGate, Notice, useAdminMeta } from "./meta-gate.js";
26
27
  import { useAppAddress } from "./use-app-address.js";
@@ -128,15 +129,26 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
128
129
  const outsideOrganizations = Boolean(organizationSettings) && current !== void 0 && !current.organization;
129
130
  const organizationPage = organizationSettings && user ? organizationPageTitles[path] : void 0;
130
131
  const accountPage = !user ? void 0 : path === "/-/account" ? "Sign-in & security" : path === "/-/sign-in-policy" ? "Sign-in policy" : organizationPage;
132
+ const resourceLabel = view?.names?.plural ?? (resource && humanize(resource));
131
133
  const breadcrumb = accountPage ? [accountPage] : breadcrumbFor({
132
134
  basePath,
133
135
  address,
134
136
  group: group?.label,
135
137
  page,
136
- resourceLabel: view?.names?.plural ?? (resource && humanize(resource)),
138
+ resourceLabel,
137
139
  recordTitle: title,
138
140
  recordLoading: titleLoading
139
141
  });
142
+ const singular = view?.names?.singular ?? (resource && humanize(resource));
143
+ useDocumentTitle(documentTitle(titleParts({
144
+ address,
145
+ page,
146
+ accountPage,
147
+ resourceLabel,
148
+ singular,
149
+ recordTitle: title,
150
+ recordLoading: titleLoading
151
+ }), meta.appName));
140
152
  const content = user && path === "/-/account" ? /* @__PURE__ */ jsx(AccountSecurityPage, { email: user.email }) : user && path === "/-/sign-in-policy" ? admin ? /* @__PURE__ */ jsx(SignInPolicyPage, {}) : /* @__PURE__ */ jsx(Notice, {
141
153
  title: "Admins only",
142
154
  children: "Only an admin can change how people sign in."
@@ -1,4 +1,4 @@
1
- import { defaultDisplay } from "./displays/list-displays.js";
1
+ import { defaultDisplay, listDefaultDisplay } from "./displays/list-displays.js";
2
2
  import { defaultOrderBy } from "./list-order.js";
3
3
  import { encodeKey } from "@protobase/schema/internal";
4
4
  //#region src/app/current-address.ts
@@ -22,7 +22,7 @@ const listedResource = (resources, views, resource) => {
22
22
  };
23
23
  /** What a list shows when its address names nothing: its default display for the screen, the list's sort, the filters in a bar. */
24
24
  const listDefaults = (model, view, xs = false) => ({
25
- display: defaultDisplay(view, xs),
25
+ display: model ? listDefaultDisplay(model, view, xs) : defaultDisplay(view, xs),
26
26
  orderBy: model ? defaultOrderBy(model, view) : "",
27
27
  filterLayout: "bar"
28
28
  });
@@ -0,0 +1,34 @@
1
+ import { humanize } from "../../live/naming.js";
2
+ import { createWhere, printFilter } from "@protobase/schema";
3
+ //#region src/app/delete/blocked-delete.ts
4
+ const where$1 = createWhere();
5
+ /** What a delete other records still point at says, such as "Orders still point at this company." */
6
+ const blockedDeleteText = ({ blockingPlural, noun }) => `${blockingPlural} still point at this ${noun.toLowerCase()}.`;
7
+ /**
8
+ * Why the delete was refused, naming the records that point at it by their view's plural, and, when the server names
9
+ * the field pointing at it, a link to their list filtered to those records. `undefined` for a resource the app does not show.
10
+ */
11
+ const blockedDelete = ({ blockedBy, resources, views, noun, key }) => {
12
+ const { resource, field } = blockedBy;
13
+ if (!resources[resource]) return void 0;
14
+ const plural = views[resource]?.names?.plural ?? humanize(resource);
15
+ const text = blockedDeleteText({
16
+ blockingPlural: plural,
17
+ noun
18
+ });
19
+ if (!field || key === void 0) return { text };
20
+ const list = {
21
+ kind: "list",
22
+ resource,
23
+ filter: printFilter(where$1.eq(field, key))
24
+ };
25
+ return {
26
+ text,
27
+ link: {
28
+ label: `Show ${plural.toLowerCase()}`,
29
+ to: list
30
+ }
31
+ };
32
+ };
33
+ //#endregion
34
+ export { blockedDelete, blockedDeleteText };
@@ -1,6 +1,9 @@
1
1
  import { useClient } from "../../data/api-provider.js";
2
2
  import { humanize } from "../../live/naming.js";
3
3
  import { useToast } from "../../toasts/toast-provider.js";
4
+ import { useAdminMeta } from "../meta-gate.js";
5
+ import { useAppAddress } from "../use-app-address.js";
6
+ import { blockedDelete } from "./blocked-delete.js";
4
7
  import { BulkDeleteDialog, ConfirmDeleteDialog, ConflictDialog } from "./dialogs.js";
5
8
  import { diffRecords } from "./record-diff.js";
6
9
  import { useState } from "react";
@@ -21,6 +24,15 @@ const useDeleteFlow = ({ model, view, onDeleted, onReview, restore }) => {
21
24
  const [busy, setBusy] = useState(false);
22
25
  const noun = view?.names?.singular ?? humanize(model.name);
23
26
  const plural = view?.names?.plural ?? humanize(model.name);
27
+ const { resources, views } = useAdminMeta();
28
+ const { href, open } = useAppAddress();
29
+ const blocked = (error, target) => error.blockedBy && blockedDelete({
30
+ blockedBy: error.blockedBy,
31
+ resources,
32
+ views,
33
+ noun,
34
+ ...model.primaryKey.length === 1 && { key: target.key }
35
+ });
24
36
  const refresh = () => {
25
37
  for (const prefix of [
26
38
  "page",
@@ -87,10 +99,16 @@ const useDeleteFlow = ({ model, view, onDeleted, onReview, restore }) => {
87
99
  } catch (error) {
88
100
  if (!isApiError(error)) throw error;
89
101
  setFlow(void 0);
102
+ const why = blocked(error, target);
90
103
  toast.show({
91
104
  state: "error",
92
105
  title: `Could not delete ${target.title}`,
93
- description: error.message
106
+ description: why?.text ?? error.message,
107
+ ...why?.link && { action: {
108
+ label: why.link.label,
109
+ href: href(why.link.to),
110
+ onNavigate: open
111
+ } }
94
112
  });
95
113
  } finally {
96
114
  setBusy(false);
@@ -106,7 +124,7 @@ const useDeleteFlow = ({ model, view, onDeleted, onReview, restore }) => {
106
124
  if (!isApiError(error)) throw error;
107
125
  return {
108
126
  title: target.title,
109
- message: error.message
127
+ message: blocked(error, target)?.text ?? error.message
110
128
  };
111
129
  }
112
130
  };
@@ -18,11 +18,10 @@ const icons = {
18
18
  map: Map
19
19
  };
20
20
  /**
21
- * The list page's switch between the table and the view's displays, as buttons or, on a phone, as a menu; nothing for a
22
- * list with only its table.
21
+ * The list page's switch between the table and the view's displays, or `choices`, as buttons or, on a phone, as a menu;
22
+ * nothing for a list with only its table.
23
23
  */
24
- const DisplaySwitch = ({ view, value, onChange, menu = false }) => {
25
- const choices = displayChoices(view);
24
+ const DisplaySwitch = ({ view, value, onChange, menu = false, choices = displayChoices(view) }) => {
26
25
  if (choices.length < 2) return null;
27
26
  const options = choices.map((choice) => ({
28
27
  value: choice.name,
@@ -13,7 +13,7 @@ const plainHeight = 44;
13
13
  * The list page's cards display: a card per record (see `RecordCard`), scrolled like the table. An embedded list,
14
14
  * which shows a few cards whole, draws them with `CardsDisplay`.
15
15
  */
16
- const ListCards = ({ model, view, display, list }) => {
16
+ const ListCards = ({ model, view, display, list, empty }) => {
17
17
  const plural = view?.names?.plural ?? humanize(model.name);
18
18
  const pictured = display.card === void 0 && (display.image !== void 0 || display.photos !== void 0);
19
19
  if (list.isLoading) return /* @__PURE__ */ jsx("div", {
@@ -24,7 +24,7 @@ const ListCards = ({ model, view, display, list }) => {
24
24
  title: "Could not load rows",
25
25
  error: list.error
26
26
  });
27
- if (!list.source || list.source.total === 0) return /* @__PURE__ */ jsxs("p", {
27
+ if (!list.source || list.source.total === 0) return empty ?? /* @__PURE__ */ jsxs("p", {
28
28
  className: "py-8 text-center text-muted-foreground",
29
29
  children: [
30
30
  "No ",
@@ -1,5 +1,6 @@
1
1
  import { recordKey, recordTitleField } from "../../live/model-helpers.js";
2
2
  import { humanize } from "../../live/naming.js";
3
+ import { listColumnNames } from "../../live/columns-from-model.js";
3
4
  import { displayValue } from "../../live/display-value.js";
4
5
  import { photosKey } from "../../live/row-photos.js";
5
6
  import { filledFields } from "../filled-fields.js";
@@ -18,6 +19,40 @@ const defaultDisplay = (view, xs, preferred) => {
18
19
  const cards = displaysOf(view).filter((display) => display.kind === "cards");
19
20
  return cards.find((display) => display.name === fallback)?.name ?? cards[0]?.name ?? fallback;
20
21
  };
22
+ /** The name of the list a phone shows for a list without a cards display. */
23
+ const rowsDisplay = "rows";
24
+ /**
25
+ * The list a phone shows for a list without a cards display, as a cards display of a row per record: the title, the
26
+ * first enum column as a badge on the right, and the next two columns, booleans left out, on a muted line below.
27
+ * `undefined` for a view with a cards display, or a display of its own named `rows`.
28
+ */
29
+ const phoneRows = (model, view) => {
30
+ if (displaysOf(view).some((display) => display.kind === "cards" || display.name === "rows")) return void 0;
31
+ const title = recordTitleField(model, view);
32
+ const candidates = listColumnNames(model, view).filter((name) => name !== title && model.fields[name] !== void 0);
33
+ const badge = candidates.find((name) => model.fields[name].type === "enum");
34
+ const footer = candidates.filter((name) => name !== badge && model.fields[name].type !== "boolean").slice(0, 2);
35
+ return {
36
+ kind: "cards",
37
+ name: rowsDisplay,
38
+ label: "List",
39
+ icon: "list",
40
+ title,
41
+ ...badge !== void 0 && { badge },
42
+ footer
43
+ };
44
+ };
45
+ const isPhoneRows = (view, display) => display.name === "rows" && !displaysOf(view).some((other) => other.name === "rows");
46
+ /**
47
+ * The display a list page opens in when its address names none: `defaultDisplay`, but below the `sm` breakpoint the
48
+ * phone's rows (`phoneRows`) in place of the table.
49
+ */
50
+ const listDefaultDisplay = (model, view, xs) => {
51
+ const display = defaultDisplay(view, xs);
52
+ return xs && display === tableDisplay && phoneRows(model, view) ? rowsDisplay : display;
53
+ };
54
+ /** The display a list page shows for the address's `name`: `shownDisplay`, or the phone's rows when it names them, on any screen. */
55
+ const shownListDisplay = (model, view, name) => (name === "rows" ? phoneRows(model, view) : void 0) ?? shownDisplay(view, name);
21
56
  /** The display a list shows for the address's `name`: `undefined` for the table, and the view's default for a name it does not have. */
22
57
  const shownDisplay = (view, name) => {
23
58
  if (name === tableDisplay) return void 0;
@@ -38,6 +73,15 @@ const displayChoices = (view) => [{
38
73
  label: display.label ?? humanize(display.name),
39
74
  icon: display.icon ?? defaultIcons[display.kind]
40
75
  }))];
76
+ /** The list page's display switch: `displayChoices`, and below the `sm` breakpoint the phone's rows when it has them. */
77
+ const listDisplayChoices = (model, view, xs) => {
78
+ const choices = displayChoices(view);
79
+ return xs && phoneRows(model, view) ? [...choices, {
80
+ name: rowsDisplay,
81
+ label: "List",
82
+ icon: "list"
83
+ }] : choices;
84
+ };
41
85
  /** The fields a cards display shows, its title the view's title field when it names none. */
42
86
  const cardFields = (model, view, display) => ({
43
87
  image: display.image,
@@ -101,9 +145,19 @@ const shownFields = (model, view, display) => {
101
145
  ].filter((name) => name !== void 0);
102
146
  };
103
147
  /** The fields to label the list's rows for: the table's columns and every display's, so one load serves them all. */
104
- const loadedFields = (model, view, columns) => [.../* @__PURE__ */ new Set([...columns, ...displaysOf(view).flatMap((display) => shownFields(model, view, display))])];
105
- /** The sortable fields a display shows, for the sort menu that stands in for column headers. */
106
- const sortFields = (model, view, display) => [...new Set(shownFields(model, view, display))].filter((name) => model.fields[name]?.sortable);
148
+ const loadedFields = (model, view, columns) => {
149
+ const rows = phoneRows(model, view);
150
+ const displays = rows ? [...displaysOf(view), rows] : displaysOf(view);
151
+ return [.../* @__PURE__ */ new Set([...columns, ...displays.flatMap((display) => shownFields(model, view, display))])];
152
+ };
153
+ /**
154
+ * The sortable fields a display shows, for the sort menu that stands in for column headers; for the phone's rows, those
155
+ * of the table's columns and the title, as the table's headers sort by.
156
+ */
157
+ const sortFields = (model, view, display) => {
158
+ const shown = isPhoneRows(view, display) ? [recordTitleField(model, view), ...listColumnNames(model, view)] : shownFields(model, view, display);
159
+ return [...new Set(shown)].filter((name) => model.fields[name]?.sortable);
160
+ };
107
161
  const isLabelled = (value) => typeof value === "object" && value !== null && "id" in value && "name" in value;
108
162
  /**
109
163
  * A loaded row, its relations as `{ id, name }`, split into the record as the API gives it, the relations' names and
@@ -128,4 +182,4 @@ const displayRow = (model, row) => {
128
182
  };
129
183
  };
130
184
  //#endregion
131
- export { amountParts, cardFields, defaultDisplay, displayChoices, displayRow, displaysOf, filledAmount, filledMeta, loadedFields, mapCards, shownDisplay, shownFields, sortFields };
185
+ export { amountParts, cardFields, defaultDisplay, displayChoices, displayRow, displaysOf, filledAmount, filledMeta, listDefaultDisplay, listDisplayChoices, loadedFields, mapCards, phoneRows, rowsDisplay, shownDisplay, shownFields, shownListDisplay, sortFields };
@@ -3,14 +3,14 @@ import { CustomBoundary } from "../pages/custom-boundary.js";
3
3
  import { recordKey } from "../../live/model-helpers.js";
4
4
  import { humanize } from "../../live/naming.js";
5
5
  import { useProjectUi } from "../pages/project-ui.js";
6
+ import { markdownExcerpt } from "../../markdown/markdown-excerpt.js";
7
+ import { isMarkdown } from "../../markdown/markdown-hint.js";
6
8
  import { displayValue } from "../../live/display-value.js";
7
9
  import { amountParts, cardFields, filledAmount, filledMeta } from "./list-displays.js";
8
10
  import { AppLink } from "../use-app-address.js";
9
11
  import { shownWidget } from "../field-types/shown-widget.js";
10
12
  import { WidgetPart } from "../field-types/widget-part.js";
11
13
  import { Markdown } from "../../markdown/markdown.js";
12
- import { isMarkdown } from "../../markdown/markdown-hint.js";
13
- import { markdownExcerpt } from "../../markdown/markdown-excerpt.js";
14
14
  import { ValueBadge } from "../value-badge.js";
15
15
  import { useSeedRecord } from "../record-preview.js";
16
16
  import { namedIcon } from "../named-icon.js";
@@ -0,0 +1,27 @@
1
+ import { useEffect } from "react";
2
+ //#region src/app/document-title.ts
3
+ /** What a tab names, most specific first: the record and its list, the new record and its list, the list, or the page. */
4
+ const titleParts = ({ address, page, accountPage, resourceLabel, singular, recordTitle, recordLoading }) => {
5
+ if (accountPage) return [accountPage];
6
+ if (page) return [page.title];
7
+ if (!address || address.kind === "page") return [];
8
+ const list = resourceLabel ? [resourceLabel] : [];
9
+ if (address.kind === "list") return list;
10
+ if (address.kind === "create") return [...singular ? [`New ${singular.toLowerCase()}`] : [], ...list];
11
+ const record = recordTitle ?? (recordLoading ? void 0 : address.key);
12
+ return [...record ? [record] : [], ...list];
13
+ };
14
+ /** A tab's title: its parts, then the app's name, as in "Nordlicht GmbH · Companies · Protobase". */
15
+ const documentTitle = (parts, appName) => [...parts.filter(Boolean), appName].join(" · ");
16
+ /** Shows `text` as the tab's title while mounted, then puts back the title it found. */
17
+ const useDocumentTitle = (text) => {
18
+ useEffect(() => {
19
+ const before = document.title;
20
+ document.title = text;
21
+ return () => {
22
+ document.title = before;
23
+ };
24
+ }, [text]);
25
+ };
26
+ //#endregion
27
+ export { documentTitle, titleParts, useDocumentTitle };
@@ -1,7 +1,7 @@
1
1
  import { humanize } from "../../live/naming.js";
2
+ import { columnsFromModel } from "../../live/columns-from-model.js";
2
3
  import { displayRow } from "../displays/list-displays.js";
3
4
  import { scopeOf } from "../pages/use-layout-data.js";
4
- import { columnsFromModel } from "../../live/columns-from-model.js";
5
5
  import { FieldValue } from "../pages/field-value.js";
6
6
  import { usePathValue } from "../pages/use-path-value.js";
7
7
  import { withFieldTypeCells } from "../field-types/field-type-cells.js";
@@ -18,13 +18,17 @@ const embeddedDisplay = (view, xs, { picked, preferred }) => {
18
18
  return defaultDisplay(view, xs, preferred !== void 0 && names.includes(preferred) ? preferred : void 0);
19
19
  };
20
20
  /**
21
- * The resource's list with the embedded list's filter, order and display, leaving out what the list does by default, and
21
+ * The resource's list with the embedded list's filter, order and display, leaving out what the list does by default (an
22
+ * embedded list's own default display among it, so a phone opens the list in its rows rather than the table), and
22
23
  * the saved search whose results it shows, if any. `resource` names another list of the same records, such as the
23
24
  * favorites that list a user's saved listings.
24
25
  */
25
26
  const viewAllAddress = (model, view, xs, { resource = model.name, ...list }) => changedList({
26
27
  kind: "list",
27
28
  resource
28
- }, list, listDefaults(model, view, xs));
29
+ }, list, {
30
+ ...listDefaults(model, view, xs),
31
+ display: defaultDisplay(view, xs)
32
+ });
29
33
  //#endregion
30
34
  export { embeddedDisplay, embeddedDisplayKey, readPickedDisplay, viewAllAddress, writePickedDisplay };
@@ -5,10 +5,10 @@ import { humanize } from "../../live/naming.js";
5
5
  import { useProjectUi } from "../pages/project-ui.js";
6
6
  import { useAdminMeta } from "../meta-gate.js";
7
7
  import { ErrorBanner } from "../error-banner.js";
8
+ import { listColumnNames } from "../../live/columns-from-model.js";
8
9
  import { displayRow, loadedFields, shownDisplay } from "../displays/list-displays.js";
9
10
  import { defaultOrderBy, parseSort, sortToOrderBy } from "../list-order.js";
10
11
  import { AppLink, useAppAddress } from "../use-app-address.js";
11
- import { listColumnNames } from "../../live/columns-from-model.js";
12
12
  import { DataGrid } from "../../data-grid/data-grid.js";
13
13
  import { viewAllAddress } from "./embedded-display.js";
14
14
  import { BoardDisplay } from "../displays/board-display.js";
@@ -40,7 +40,7 @@ const Loading = () => /* @__PURE__ */ jsx("div", {
40
40
  const EmbeddedList = ({ model, view, filter, sort, columns, limit, display, empty, row = false, viewAll, link = true }) => {
41
41
  const { resources, views } = useAdminMeta();
42
42
  const { fields: fieldTypes } = useProjectUi();
43
- const { go } = useAppAddress();
43
+ const { go, href } = useAppAddress();
44
44
  const xs = useIsXs();
45
45
  const [orderBy, setOrderBy] = useState(sort || defaultOrderBy(model, view));
46
46
  const names = columns ?? listColumnNames(model, view);
@@ -125,6 +125,14 @@ const EmbeddedList = ({ model, view, filter, sort, columns, limit, display, empt
125
125
  resource: model.name,
126
126
  key: recordKey(model, row)
127
127
  });
128
+ const recordLink = (row) => ({
129
+ href: href({
130
+ kind: "record",
131
+ resource: model.name,
132
+ key: recordKey(model, row)
133
+ }),
134
+ onFollow: () => openRecord(row)
135
+ });
128
136
  const singular = view?.names?.singular ?? humanize(model.name);
129
137
  return /* @__PURE__ */ jsxs("div", {
130
138
  className: "flex flex-col gap-3",
@@ -136,6 +144,7 @@ const EmbeddedList = ({ model, view, filter, sort, columns, limit, display, empt
136
144
  sort: parseSort(orderBy),
137
145
  onSortChange: (next) => setOrderBy(sortToOrderBy(next)),
138
146
  onOpenRow: openRecord,
147
+ rowLink: recordLink,
139
148
  rowActions: [{
140
149
  label: `Open ${singular.toLowerCase()}`,
141
150
  onSelect: openRecord
@@ -6,6 +6,7 @@ import { humanize } from "../live/naming.js";
6
6
  import { fromDraft, isEditable, toFieldDraft } from "../live/field-values.js";
7
7
  import { Switch } from "../primitives/switch.js";
8
8
  import { Textarea } from "../primitives/textarea.js";
9
+ import { isMarkdown } from "../markdown/markdown-hint.js";
9
10
  import { displayValue } from "../live/display-value.js";
10
11
  import { AppLink } from "./use-app-address.js";
11
12
  import { RelationPicker } from "./relation-picker.js";
@@ -15,16 +16,11 @@ import { useShownWidget } from "./field-types/shown-widget.js";
15
16
  import { WidgetPart } from "./field-types/widget-part.js";
16
17
  import { Markdown } from "../markdown/markdown.js";
17
18
  import { MarkdownEditor } from "../markdown/markdown-editor.js";
18
- import { isMarkdown } from "../markdown/markdown-hint.js";
19
+ import { numeric, numericInputProps } from "./numeric-input.js";
19
20
  import { ArrowUpRight } from "lucide-react";
20
21
  import { jsx, jsxs } from "react/jsx-runtime";
21
22
  import { enumLabel } from "@protobase/schema";
22
23
  //#region src/app/field-editor.tsx
23
- const numeric = /* @__PURE__ */ new Set([
24
- "integer",
25
- "bigint",
26
- "decimal"
27
- ]);
28
24
  const BuiltInEditor = ({ field, hints, value, stored, relationLabel, relationTo, relationPicker, invalid, locked, placeholder, reveal, resource, onChange }) => {
29
25
  const control = useFieldControl();
30
26
  if (field.type === "file") return /* @__PURE__ */ jsx(FileField, {
@@ -136,6 +132,7 @@ const BuiltInEditor = ({ field, hints, value, stored, relationLabel, relationTo,
136
132
  trailing: hints?.suffix ? /* @__PURE__ */ jsx("span", { children: hints.suffix.trim() }) : void 0,
137
133
  placeholder,
138
134
  className: cn(numeric.has(field.type) && "text-right tabular-nums"),
135
+ ...numericInputProps(field.type),
139
136
  value: String(value),
140
137
  onChange: (event) => onChange(event.target.value)
141
138
  });
@@ -20,7 +20,7 @@ import { jsx, jsxs } from "react/jsx-runtime";
20
20
  const HandSortedList = ({ model, view, field, filter, columns }) => {
21
21
  const { resources, views } = useAdminMeta();
22
22
  const { fields: fieldTypes } = useProjectUi();
23
- const { go } = useAppAddress();
23
+ const { go, href } = useAppAddress();
24
24
  const specs = useMemo(() => embeddedColumns(model, view, columns, {
25
25
  resources,
26
26
  views,
@@ -80,6 +80,14 @@ const HandSortedList = ({ model, view, field, filter, columns }) => {
80
80
  resource: model.name,
81
81
  key: recordKey(model, row)
82
82
  });
83
+ const recordLink = (row) => ({
84
+ href: href({
85
+ kind: "record",
86
+ resource: model.name,
87
+ key: recordKey(model, row)
88
+ }),
89
+ onFollow: () => openRecord(row)
90
+ });
83
91
  return /* @__PURE__ */ jsx(DataGrid, {
84
92
  fit: true,
85
93
  scroll: "parent",
@@ -87,6 +95,7 @@ const HandSortedList = ({ model, view, field, filter, columns }) => {
87
95
  source,
88
96
  latencyMs: 0,
89
97
  onOpenRow: openRecord,
98
+ rowLink: recordLink,
90
99
  rowNumbers: false,
91
100
  reorder: {
92
101
  label: (row) => recordTitle(model, view, row, row.id),
@@ -0,0 +1,31 @@
1
+ //#region src/app/list-empty-state.ts
2
+ /** Why a list has no rows, and the buttons that help: clear the search, clear the filters, or create the first record. */
3
+ const listEmptyState = ({ plural, search, filtered, scoped, canCreate }) => {
4
+ const noun = plural.toLowerCase();
5
+ if (search) return {
6
+ message: `No ${noun} match “${search}”.`,
7
+ clearSearch: true,
8
+ clearFilters: filtered,
9
+ create: false
10
+ };
11
+ if (filtered) return {
12
+ message: `No ${noun} match these filters.`,
13
+ clearSearch: false,
14
+ clearFilters: true,
15
+ create: false
16
+ };
17
+ if (scoped) return {
18
+ message: `No ${noun} match.`,
19
+ clearSearch: false,
20
+ clearFilters: false,
21
+ create: false
22
+ };
23
+ return {
24
+ message: `No ${noun} yet.`,
25
+ clearSearch: false,
26
+ clearFilters: false,
27
+ create: canCreate
28
+ };
29
+ };
30
+ //#endregion
31
+ export { listEmptyState };
@@ -0,0 +1,23 @@
1
+ import { Button } from "../primitives/button.js";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ //#region src/app/list-empty.tsx
4
+ /** A list without rows: why (see `listEmptyState`), with buttons to clear its search or filters, or its New button. */
5
+ const ListEmpty = ({ state, onClearSearch, onClearFilters, create }) => /* @__PURE__ */ jsxs("div", {
6
+ className: "flex flex-col items-center gap-3 py-8 text-center text-muted-foreground",
7
+ children: [/* @__PURE__ */ jsx("p", { children: state.message }), (state.clearSearch || state.clearFilters || state.create && create) && /* @__PURE__ */ jsxs("div", {
8
+ className: "flex flex-wrap justify-center gap-2",
9
+ children: [
10
+ state.clearSearch && /* @__PURE__ */ jsx(Button, {
11
+ onClick: onClearSearch,
12
+ children: "Clear search"
13
+ }),
14
+ state.clearFilters && /* @__PURE__ */ jsx(Button, {
15
+ onClick: onClearFilters,
16
+ children: "Clear filters"
17
+ }),
18
+ state.create && create
19
+ ]
20
+ })]
21
+ });
22
+ //#endregion
23
+ export { ListEmpty };
@@ -1,4 +1,4 @@
1
- import { useIsDesktop } from "../lib/use-media-query.js";
1
+ import { useIsDesktop, useIsXs } from "../lib/use-media-query.js";
2
2
  import { usePageScroll } from "../app-shell/page-scroll.js";
3
3
  import { PageHeader } from "../app-shell/page-header.js";
4
4
  import { useAuth } from "../auth/auth-provider.js";
@@ -9,12 +9,12 @@ import { useProjectUi } from "./pages/project-ui.js";
9
9
  import { Notice, useAdminMeta, usePermissions } from "./meta-gate.js";
10
10
  import { Sheet } from "../primitives/sheet.js";
11
11
  import { ErrorBanner } from "./error-banner.js";
12
- import { displayRow, loadedFields, shownDisplay } from "./displays/list-displays.js";
12
+ import { columnsFromModel, listColumnNames } from "../live/columns-from-model.js";
13
+ import { displayRow, listDisplayChoices, loadedFields, shownListDisplay } from "./displays/list-displays.js";
13
14
  import { parseSort, sortToOrderBy } from "./list-order.js";
14
15
  import { listedResource } from "./current-address.js";
15
16
  import { AppLink, useAppAddress } from "./use-app-address.js";
16
17
  import { can, rowAllows } from "./permissions.js";
17
- import { columnsFromModel, listColumnNames } from "../live/columns-from-model.js";
18
18
  import { pinnedStorageKey } from "../data-grid/pinned-storage.js";
19
19
  import { DataGrid } from "../data-grid/data-grid.js";
20
20
  import { useLiveRows } from "../live/use-live-rows.js";
@@ -42,6 +42,8 @@ import { createOffered } from "./create/create-offered.js";
42
42
  import { ImportButton } from "./import/import-button.js";
43
43
  import { exportOffered, exportsSelection } from "./export/export-options.js";
44
44
  import { useExportFlow } from "./export/use-export-flow.js";
45
+ import { ListEmpty } from "./list-empty.js";
46
+ import { listEmptyState } from "./list-empty-state.js";
45
47
  import { ListCards } from "./displays/list-cards.js";
46
48
  import { useListScroll } from "./list-scroll.js";
47
49
  import { ListChart } from "./list-chart.js";
@@ -113,7 +115,7 @@ const ListPage = ({ address }) => {
113
115
  };
114
116
  const LoadedList = ({ model, address, scope, heading }) => {
115
117
  const meta = useAdminMeta();
116
- const { set, go } = useAppAddress();
118
+ const { set, go, href } = useAppAddress();
117
119
  const resource = model.name;
118
120
  const view = meta.views[resource];
119
121
  const fields = useMemo(() => widgetFields(view, model), [view, model]);
@@ -131,6 +133,7 @@ const LoadedList = ({ model, address, scope, heading }) => {
131
133
  const extras = parsed.ok ? parsed.extras : [];
132
134
  const { orderBy, filterLayout: layout } = address;
133
135
  const desktop = useIsDesktop();
136
+ const xs = useIsXs();
134
137
  const [sheetOpen, setSheetOpen] = useState(false);
135
138
  const scoped = scope ? [...extras, scope] : extras;
136
139
  const config = useFilterConfig({
@@ -162,7 +165,7 @@ const LoadedList = ({ model, address, scope, heading }) => {
162
165
  orderBy,
163
166
  related
164
167
  });
165
- const display = shownDisplay(view, address.display);
168
+ const display = shownListDisplay(model, view, address.display);
166
169
  const permissions = usePermissions(resource);
167
170
  const handOrder = scope ? void 0 : listHandOrder(model, permissions);
168
171
  const [sorting, setSorting] = useState(false);
@@ -321,6 +324,7 @@ const LoadedList = ({ model, address, scope, heading }) => {
321
324
  });
322
325
  const displaySwitch = (menu) => !handSorting && /* @__PURE__ */ jsx(DisplaySwitch, {
323
326
  view,
327
+ choices: listDisplayChoices(model, view, xs),
324
328
  value: display?.name ?? tableDisplay,
325
329
  onChange: (name) => set({ display: name }),
326
330
  menu
@@ -334,12 +338,21 @@ const LoadedList = ({ model, address, scope, heading }) => {
334
338
  key: recordKey(model, row)
335
339
  });
336
340
  };
341
+ const recordLink = (row) => ({
342
+ href: href({
343
+ kind: "record",
344
+ resource,
345
+ key: recordKey(model, row)
346
+ }),
347
+ onFollow: () => openRecord(row)
348
+ });
337
349
  const table = rows.source ? /* @__PURE__ */ jsx(DataGrid, {
338
350
  columns,
339
351
  source: rows.source,
340
352
  sort: parseSort(orderBy),
341
353
  onSortChange: (sort) => set({ orderBy: sortToOrderBy(sort) }),
342
354
  onOpenRow: openRecord,
355
+ rowLink: recordLink,
343
356
  cellEditor: cellEditing.editor,
344
357
  rowPatches: cellEditing.patches,
345
358
  initialScrollIndex: scroll.initialIndex,
@@ -371,13 +384,27 @@ const LoadedList = ({ model, address, scope, heading }) => {
371
384
  initialIndex: scroll.initialIndex,
372
385
  onScrollIndex: scroll.remember
373
386
  };
387
+ const emptyState = listEmptyState({
388
+ plural,
389
+ search: search?.kind === "search" ? search.text : "",
390
+ filtered: chips.length > 0,
391
+ scoped: Boolean(scope) || extras.some((clause) => clause.kind !== "search"),
392
+ canCreate: Boolean(createButton)
393
+ });
394
+ const empty = /* @__PURE__ */ jsx(ListEmpty, {
395
+ state: emptyState,
396
+ onClearSearch: () => commitSearch(""),
397
+ onClearFilters: () => setFilter(emptyFilterState),
398
+ create: createButton
399
+ });
374
400
  const drawDisplay = () => {
375
- if (!display) return table;
401
+ if (!display) return rows.source?.total === 0 && !rows.error ? empty : table;
376
402
  if (display.kind === "cards") return /* @__PURE__ */ jsx(ListCards, {
377
403
  model,
378
404
  view,
379
405
  display,
380
- list: cardsList
406
+ list: cardsList,
407
+ empty
381
408
  }, rows.key.join(" "));
382
409
  if (display.kind === "map") return /* @__PURE__ */ jsx(MapDisplay, {
383
410
  model,
@@ -0,0 +1,21 @@
1
+ //#region src/app/numeric-input.ts
2
+ /** Field types whose values are numbers: right-aligned, with a number keyboard on phones. */
3
+ const numeric = /* @__PURE__ */ new Set([
4
+ "integer",
5
+ "bigint",
6
+ "decimal"
7
+ ]);
8
+ /** The keyboard a phone shows for a field: a number pad for whole numbers, one with a decimal key for decimals. Enter saves or does nothing, hence `done`. */
9
+ const numericInputProps = (type) => {
10
+ if (type === "integer" || type === "bigint") return {
11
+ inputMode: "numeric",
12
+ enterKeyHint: "done"
13
+ };
14
+ if (type === "decimal") return {
15
+ inputMode: "decimal",
16
+ enterKeyHint: "done"
17
+ };
18
+ return {};
19
+ };
20
+ //#endregion
21
+ export { numeric, numericInputProps };