@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.
- package/dist/app/app.js +13 -1
- package/dist/app/current-address.js +2 -2
- package/dist/app/delete/blocked-delete.js +34 -0
- package/dist/app/delete/use-delete-flow.js +20 -2
- package/dist/app/displays/display-controls.js +3 -4
- package/dist/app/displays/list-cards.js +2 -2
- package/dist/app/displays/list-displays.js +58 -4
- package/dist/app/displays/record-card.js +2 -2
- package/dist/app/document-title.js +27 -0
- package/dist/app/embedded-list/embedded-columns.js +1 -1
- package/dist/app/embedded-list/embedded-display.js +6 -2
- package/dist/app/embedded-list/embedded-list.js +11 -2
- package/dist/app/field-editor.js +3 -6
- package/dist/app/hand-order/hand-sorted-list.js +10 -1
- package/dist/app/list-empty-state.js +31 -0
- package/dist/app/list-empty.js +23 -0
- package/dist/app/list-page.js +34 -7
- package/dist/app/numeric-input.js +21 -0
- package/dist/app/pages/field-value.js +1 -1
- package/dist/app/record-page.js +2 -2
- package/dist/app/related-dots/related-dots-columns.js +2 -1
- package/dist/app/related-dots/related-dots.js +1 -1
- package/dist/data/meta-data.d.ts +2 -0
- package/dist/data/use-meta.js +1 -0
- package/dist/data-grid/cell-text.js +30 -0
- package/dist/data-grid/cells.js +27 -35
- package/dist/data-grid/content-widths.js +81 -0
- package/dist/data-grid/data-grid.js +20 -7
- package/dist/data-grid/default-pins.js +13 -0
- package/dist/data-grid/grid-row.js +5 -1
- package/dist/data-grid/pinned-storage.js +2 -1
- package/dist/data-grid/truncated-text.js +16 -0
- package/dist/data-grid/use-fitted-columns.js +28 -0
- package/dist/data-grid/use-pinned-columns.js +6 -5
- package/dist/live/columns-from-model.js +6 -6
- package/dist/package.js +1 -1
- package/dist/shell/assets/app-CcsrYoRP.js +69 -0
- package/dist/shell/assets/index-C1fzTv_X.css +2 -0
- package/dist/shell/assets/{index-7lQIOMrK.js → index-DQ-VSGee.js} +1 -1
- package/dist/shell/assets/{maplibre-DEejTZ7I.js → maplibre-4azlIk9w.js} +1 -1
- package/dist/shell/assets/{markdown-content-ZzP9i4q4.js → markdown-content-DnIl00m7.js} +1 -1
- package/dist/shell/assets/{record-map-B-zsmXL2.js → record-map-CUEC6IyM.js} +1 -1
- package/dist/shell/assets/{records-map-Lj1MQgsW.js → records-map-ataN9m4I.js} +1 -1
- package/dist/shell/assets/{shared-protobase-ui-Bnd7T1j4.js → shared-protobase-ui-Cibu09RP.js} +1 -1
- package/dist/shell/index.html +4 -4
- package/dist/shell/protobase.shell.json +1 -1
- package/package.json +4 -4
- package/dist/shell/assets/app-BnuJWZWd.js +0 -69
- 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
|
|
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;
|
|
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) =>
|
|
105
|
-
|
|
106
|
-
const
|
|
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
|
|
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,
|
|
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
|
package/dist/app/field-editor.js
CHANGED
|
@@ -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 {
|
|
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 };
|
package/dist/app/list-page.js
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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 };
|