@protobase/ui 0.40.6 → 0.40.8
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 +12 -3
- package/dist/app/comments/comment-composer.js +2 -0
- package/dist/app/create/create-page.js +10 -2
- package/dist/app/messages.js +12 -0
- package/dist/app/new-version/messages.js +24 -0
- package/dist/app/new-version/new-version-banner.js +37 -0
- package/dist/app/new-version/new-version-button.js +27 -0
- package/dist/app/new-version/new-version-toast.js +43 -0
- package/dist/app/new-version/use-modal-open.js +19 -0
- package/dist/app/pages/blocks/modal-form.js +4 -0
- package/dist/app/pages/blocks/record-cards.js +14 -4
- package/dist/app/record-page.js +1 -1
- package/dist/app/related-cards/related-cards.js +18 -13
- package/dist/app/router.js +68 -26
- package/dist/app-shell/app-shell.js +6 -3
- package/dist/app-shell/sidebar-notice.js +12 -0
- package/dist/app-shell/sidebar.d.ts +1 -1
- package/dist/app-shell/sidebar.js +2 -1
- package/dist/data/api-provider.js +1 -0
- package/dist/data/meta-data.d.ts +9 -0
- package/dist/data/use-meta.d.ts +3 -2
- package/dist/data/use-meta.js +8 -4
- package/dist/data/version-freeze.js +17 -0
- package/dist/package.js +1 -1
- package/dist/primitives/snap-row-width.js +13 -0
- package/dist/primitives/snap-row.js +21 -0
- package/dist/shell/assets/app-DXbtlR1O.js +9 -0
- package/dist/shell/assets/{index-BX850JXd.css → index-DT92y8N1.css} +1 -1
- package/dist/shell/assets/{index-GTJA-4i3.js → index-hbOjGIrj.js} +2 -2
- package/dist/shell/assets/{records-map-DVCcd765.js → records-map-DW4-2p9A.js} +2 -2
- package/dist/shell/assets/{shared-protobase-ui-D1hRi4LJ.js → shared-protobase-ui-DOLAGMZi.js} +2 -2
- package/dist/shell/index.html +4 -4
- package/dist/shell/protobase.shell.json +1 -1
- package/dist/shell/protobase.sw.js +2 -2
- package/dist/shell-source-maps/assets/app-DXbtlR1O.js.map +1 -0
- package/dist/shell-source-maps/assets/{index-GTJA-4i3.js.map → index-hbOjGIrj.js.map} +1 -1
- package/dist/shell-source-maps/assets/{records-map-DVCcd765.js.map → records-map-DW4-2p9A.js.map} +1 -1
- package/dist/shell-source-maps/assets/{shared-protobase-ui-D1hRi4LJ.js.map → shared-protobase-ui-DOLAGMZi.js.map} +1 -1
- package/dist/toasts/toast-provider.js +11 -2
- package/dist/toasts/toast.js +3 -2
- package/package.json +4 -4
- package/dist/shell/assets/app-Di5oi8xq.js +0 -9
- package/dist/shell-source-maps/assets/app-Di5oi8xq.js.map +0 -1
package/dist/app/app.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { setFallbackLocale } from "../locale/active-locale.js";
|
|
2
2
|
import { accountMenuItems } from "../account/account-pages.js";
|
|
3
|
+
import { useSidebarNoticeShown } from "../app-shell/sidebar-notice.js";
|
|
3
4
|
import { AppShell } from "../app-shell/app-shell.js";
|
|
4
5
|
import { AuthProvider, useAuth } from "../auth/auth-provider.js";
|
|
5
6
|
import { AccountSecurityPage } from "../account/account-security-page.js";
|
|
@@ -52,6 +53,9 @@ import { PluginNotice } from "../plugins/plugin-notice.js";
|
|
|
52
53
|
import { RecordPage } from "./record-page.js";
|
|
53
54
|
import { SourceNotice } from "./source-notice.js";
|
|
54
55
|
import { useGlobalSearch } from "./search/use-global-search.js";
|
|
56
|
+
import { NewVersionBanner } from "./new-version/new-version-banner.js";
|
|
57
|
+
import { NewVersionButton } from "./new-version/new-version-button.js";
|
|
58
|
+
import { NewVersionToast } from "./new-version/new-version-toast.js";
|
|
55
59
|
import { RuntimeUpdateButton } from "./runtime/runtime-update.js";
|
|
56
60
|
import { StaffBanner } from "./staff-banner.js";
|
|
57
61
|
import { useNavRecent } from "./use-nav-recent.js";
|
|
@@ -140,6 +144,8 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
|
|
|
140
144
|
url: runtimeUrl,
|
|
141
145
|
client: runtime
|
|
142
146
|
});
|
|
147
|
+
const sidebarBanner = useSidebarNoticeShown(sidebarMode ?? "text-small");
|
|
148
|
+
const reloadButton = meta.versionChange && !sidebarBanner && /* @__PURE__ */ jsx(NewVersionButton, {});
|
|
143
149
|
const role = useActiveRole(useCallback(() => reopenIn(basePath), [basePath]), Boolean(user));
|
|
144
150
|
const acting = user && actingUser(user, role.current);
|
|
145
151
|
const admin = acting ? isAdmin(acting) : false;
|
|
@@ -241,6 +247,7 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
|
|
|
241
247
|
onSwitch: role.switchTo
|
|
242
248
|
} : void 0,
|
|
243
249
|
onNavigate: open,
|
|
250
|
+
sidebarNotice: /* @__PURE__ */ jsx(NewVersionBanner, {}),
|
|
244
251
|
search: search.available ? {
|
|
245
252
|
placeholder: search.placeholder,
|
|
246
253
|
text: search.text,
|
|
@@ -250,12 +257,13 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
|
|
|
250
257
|
groups: search.groups,
|
|
251
258
|
onSelect: open
|
|
252
259
|
} : void 0,
|
|
253
|
-
actions: (Actions || runtimeButton || assistantButton) && /* @__PURE__ */ jsxs(Fragment$1, { children: [
|
|
260
|
+
actions: (Actions || reloadButton || runtimeButton || assistantButton) && /* @__PURE__ */ jsxs(Fragment$1, { children: [
|
|
254
261
|
Actions && /* @__PURE__ */ jsx(CustomBoundary, {
|
|
255
262
|
name: "shell.actions",
|
|
256
263
|
fallback: null,
|
|
257
264
|
children: /* @__PURE__ */ jsx(Actions, {})
|
|
258
265
|
}),
|
|
266
|
+
reloadButton,
|
|
259
267
|
runtimeButton,
|
|
260
268
|
assistantButton
|
|
261
269
|
] }),
|
|
@@ -285,13 +293,14 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
|
|
|
285
293
|
};
|
|
286
294
|
const Authenticated = ({ baseUrl, client, sources, workspace, sidebarMode, ui, assistant, runtime }) => {
|
|
287
295
|
const { state, session, markSignedOut } = useAuth();
|
|
296
|
+
const sidebarBanner = useSidebarNoticeShown(sidebarMode ?? "text-small");
|
|
288
297
|
return /* @__PURE__ */ jsx(ApiProvider, {
|
|
289
298
|
baseUrl,
|
|
290
299
|
client,
|
|
291
300
|
sources,
|
|
292
301
|
token: state.kind === "visitor" ? void 0 : session.token,
|
|
293
302
|
onUnauthenticated: markSignedOut,
|
|
294
|
-
children: /* @__PURE__ */ jsx(MetaGate, { children: /* @__PURE__ */
|
|
303
|
+
children: /* @__PURE__ */ jsx(MetaGate, { children: /* @__PURE__ */ jsxs(ToastProvider, { children: [!sidebarBanner && /* @__PURE__ */ jsx(NewVersionToast, {}), /* @__PURE__ */ jsx(PluginGate, {
|
|
295
304
|
ui,
|
|
296
305
|
children: /* @__PURE__ */ jsx(Routes, {
|
|
297
306
|
workspace,
|
|
@@ -299,7 +308,7 @@ const Authenticated = ({ baseUrl, client, sources, workspace, sidebarMode, ui, a
|
|
|
299
308
|
assistant,
|
|
300
309
|
runtime
|
|
301
310
|
})
|
|
302
|
-
}) }) })
|
|
311
|
+
})] }) })
|
|
303
312
|
});
|
|
304
313
|
};
|
|
305
314
|
const PublicFormGate = ({ baseUrl, client, ui, children }) => {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { cn } from "../../lib/cn.js";
|
|
2
2
|
import { Button } from "../../primitives/button.js";
|
|
3
|
+
import { useNavigationGuard } from "../router.js";
|
|
3
4
|
import { useToast } from "../../toasts/toast-provider.js";
|
|
4
5
|
import { refusalReason } from "../displays/board-columns.js";
|
|
5
6
|
import { Textarea } from "../../primitives/textarea.js";
|
|
@@ -29,6 +30,7 @@ const CommentComposer = ({ resource, recordKey }) => {
|
|
|
29
30
|
const listId = useId();
|
|
30
31
|
const toast = useToast();
|
|
31
32
|
const post = usePostComment(resource, recordKey);
|
|
33
|
+
useNavigationGuard(draft.trim() !== "" || post.isPending, "input-lost");
|
|
32
34
|
const mention = activeMention(draft, caret);
|
|
33
35
|
const open = focused && mention !== void 0 && mention.start !== closed;
|
|
34
36
|
const people = useMentionable(resource, recordKey, open ? mention.query : void 0);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { text } from "../messages.js";
|
|
2
|
+
import { useNavigationGuard } from "../router.js";
|
|
2
3
|
import { useClient } from "../../data/api-provider.js";
|
|
3
4
|
import { Notice, useAdminMeta, usePermissions } from "../meta-gate.js";
|
|
4
5
|
import { humanize } from "../../live/naming.js";
|
|
@@ -50,6 +51,12 @@ const LoadedCreate = ({ resource, values }) => {
|
|
|
50
51
|
const [errors, setErrors] = useState({});
|
|
51
52
|
const [rejections, setRejections] = useState(0);
|
|
52
53
|
const formRef = useFocusFirstInvalid(rejections);
|
|
54
|
+
const [newRecord, setNewRecord] = useState();
|
|
55
|
+
const [touched, setTouched] = useState(false);
|
|
56
|
+
useNavigationGuard(!newRecord && (touched || feedback.phase === "saving"), "input-lost");
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (newRecord) go(newRecord, { replace: true });
|
|
59
|
+
}, [newRecord, go]);
|
|
53
60
|
useEffect(() => {
|
|
54
61
|
for (const [name, key] of Object.entries(values ?? {})) {
|
|
55
62
|
const target = meta.resources[model.fields[name]?.relation?.resource ?? ""];
|
|
@@ -74,6 +81,7 @@ const LoadedCreate = ({ resource, values }) => {
|
|
|
74
81
|
]);
|
|
75
82
|
const change = (name, value) => {
|
|
76
83
|
setErrors((current) => without(current, name));
|
|
84
|
+
setTouched(true);
|
|
77
85
|
setDraft((current) => ({
|
|
78
86
|
...current,
|
|
79
87
|
[name]: value
|
|
@@ -97,11 +105,11 @@ const LoadedCreate = ({ resource, values }) => {
|
|
|
97
105
|
"facets",
|
|
98
106
|
"series"
|
|
99
107
|
]) queryClient.invalidateQueries({ queryKey: [prefix, resource] });
|
|
100
|
-
|
|
108
|
+
setNewRecord({
|
|
101
109
|
kind: "record",
|
|
102
110
|
resource,
|
|
103
111
|
key: recordKey(model, created.record)
|
|
104
|
-
}
|
|
112
|
+
});
|
|
105
113
|
});
|
|
106
114
|
const formSections = sections.map((section) => ({
|
|
107
115
|
title: section.title,
|
package/dist/app/messages.js
CHANGED
|
@@ -47,8 +47,14 @@ const text = defineMessages({
|
|
|
47
47
|
loading: "Loading",
|
|
48
48
|
leaveTitle: "Leave without saving?",
|
|
49
49
|
leaveDescription: "You have unsaved changes on this page. They stay in this browser, and come back when you open the record again.",
|
|
50
|
+
leaveInputLost: "You have unsaved changes on this page. They are lost when you leave.",
|
|
50
51
|
stay: "Stay",
|
|
51
52
|
leave: "Leave",
|
|
53
|
+
reloadTitle: "Reload without saving?",
|
|
54
|
+
reloadDraftKept: "You have unsaved changes on this page. They stay in this browser, and come back after the page reloads.",
|
|
55
|
+
reloadInputLost: "You have unsaved changes on this page. They are lost when the page reloads.",
|
|
56
|
+
keepEditing: "Keep editing",
|
|
57
|
+
reload: "Reload",
|
|
52
58
|
show: (label) => `Show ${label}`,
|
|
53
59
|
hide: (label) => `Hide ${label}`,
|
|
54
60
|
couldNotShow: (label) => `Could not show the ${label}`,
|
|
@@ -110,8 +116,14 @@ const text = defineMessages({
|
|
|
110
116
|
loading: "Laden",
|
|
111
117
|
leaveTitle: "Weggaan zonder te bewaren?",
|
|
112
118
|
leaveDescription: "Je hebt niet-bewaarde wijzigingen op deze pagina. Ze blijven in deze browser, en komen terug als je het record weer opent.",
|
|
119
|
+
leaveInputLost: "Je hebt niet-bewaarde wijzigingen op deze pagina. Ze gaan verloren als je weggaat.",
|
|
113
120
|
stay: "Blijven",
|
|
114
121
|
leave: "Weggaan",
|
|
122
|
+
reloadTitle: "Opnieuw laden zonder op te slaan?",
|
|
123
|
+
reloadDraftKept: "Er zijn niet-opgeslagen wijzigingen op deze pagina. Ze blijven bewaard in deze browser en komen terug zodra de pagina opnieuw is geladen.",
|
|
124
|
+
reloadInputLost: "Er zijn niet-opgeslagen wijzigingen op deze pagina. Ze gaan verloren als de pagina opnieuw wordt geladen.",
|
|
125
|
+
keepEditing: "Verder bewerken",
|
|
126
|
+
reload: "Opnieuw laden",
|
|
115
127
|
show: (label) => `${label} tonen`,
|
|
116
128
|
hide: (label) => `${label} verbergen`,
|
|
117
129
|
couldNotShow: (label) => `Kon ${label} niet tonen`,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { defineMessages } from "../../locale/messages.js";
|
|
2
|
+
//#region src/app/new-version/messages.ts
|
|
3
|
+
const text = defineMessages({
|
|
4
|
+
en: {
|
|
5
|
+
title: "This app has been updated",
|
|
6
|
+
description: "Reload the page to use the new version, when it suits you.",
|
|
7
|
+
reload: "Reload",
|
|
8
|
+
reloadPage: "Reload page",
|
|
9
|
+
reloadPageHint: "Reload page to use the new version",
|
|
10
|
+
bannerTitle: "{} updated",
|
|
11
|
+
bannerLine: "Save your work, then click here to reload. You may see errors until you do."
|
|
12
|
+
},
|
|
13
|
+
nl: {
|
|
14
|
+
title: "Deze app is bijgewerkt",
|
|
15
|
+
description: "Laad de pagina opnieuw voor de nieuwe versie, wanneer het uitkomt.",
|
|
16
|
+
reload: "Opnieuw laden",
|
|
17
|
+
reloadPage: "Opnieuw laden",
|
|
18
|
+
reloadPageHint: "Opnieuw laden om de nieuwe versie te gebruiken",
|
|
19
|
+
bannerTitle: "{} bijgewerkt",
|
|
20
|
+
bannerLine: "Sla je werk op en klik hier om te herladen. Tot dan kunnen er fouten optreden."
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
//#endregion
|
|
24
|
+
export { text };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { useRouter } from "../router.js";
|
|
2
|
+
import { useAdminMeta } from "../meta-gate.js";
|
|
3
|
+
import { slotted } from "../../locale/slotted.js";
|
|
4
|
+
import { text } from "./messages.js";
|
|
5
|
+
import { RotateCw } from "lucide-react";
|
|
6
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
7
|
+
//#region src/app/new-version/new-version-banner.tsx
|
|
8
|
+
/**
|
|
9
|
+
* Across the bottom of a full-width sidebar, from the moment the page keeps its `/meta` for a new version until it
|
|
10
|
+
* reloads: the app's name, that it was updated, and a click that reloads, asking first while a guard holds unsaved
|
|
11
|
+
* changes. It stands in for `NewVersionToast` and `NewVersionButton`, which float over the page.
|
|
12
|
+
*/
|
|
13
|
+
const NewVersionBanner = () => {
|
|
14
|
+
const { versionChange, appName } = useAdminMeta();
|
|
15
|
+
const { reload } = useRouter();
|
|
16
|
+
if (!versionChange) return null;
|
|
17
|
+
return /* @__PURE__ */ jsxs("button", {
|
|
18
|
+
type: "button",
|
|
19
|
+
onClick: reload,
|
|
20
|
+
className: "flex w-full shrink-0 items-start gap-2 border-t border-warning/40 bg-warning-soft px-3.5 py-2 text-left text-warning-text transition-colors hover:bg-warning/15 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary",
|
|
21
|
+
children: [/* @__PURE__ */ jsx(RotateCw, {
|
|
22
|
+
"aria-hidden": true,
|
|
23
|
+
className: "mt-0.5 size-3.5 shrink-0"
|
|
24
|
+
}), /* @__PURE__ */ jsxs("span", {
|
|
25
|
+
className: "flex min-w-0 flex-col gap-0.5",
|
|
26
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
27
|
+
className: "break-words font-semibold",
|
|
28
|
+
children: appName ? slotted(text("bannerTitle"), appName) : text("title")
|
|
29
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
30
|
+
className: "text-xs",
|
|
31
|
+
children: text("bannerLine")
|
|
32
|
+
})]
|
|
33
|
+
})]
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
//#endregion
|
|
37
|
+
export { NewVersionBanner };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Button } from "../../primitives/button.js";
|
|
2
|
+
import { useRouter } from "../router.js";
|
|
3
|
+
import { useAdminMeta } from "../meta-gate.js";
|
|
4
|
+
import { text } from "./messages.js";
|
|
5
|
+
import { RotateCw } from "lucide-react";
|
|
6
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
7
|
+
//#region src/app/new-version/new-version-button.tsx
|
|
8
|
+
/** The top bar's Reload page button, from the moment the page keeps its `/meta` for a new version until it reloads. */
|
|
9
|
+
const NewVersionButton = () => {
|
|
10
|
+
const changed = Boolean(useAdminMeta().versionChange);
|
|
11
|
+
const { reload } = useRouter();
|
|
12
|
+
if (!changed) return null;
|
|
13
|
+
return /* @__PURE__ */ jsxs(Button, {
|
|
14
|
+
size: "sm",
|
|
15
|
+
variant: "ghost",
|
|
16
|
+
"aria-label": text("reloadPageHint"),
|
|
17
|
+
title: text("reloadPageHint"),
|
|
18
|
+
onClick: reload,
|
|
19
|
+
className: "max-md:size-9 max-md:p-0",
|
|
20
|
+
children: [/* @__PURE__ */ jsx(RotateCw, { className: "size-3.5 text-primary" }), /* @__PURE__ */ jsx("span", {
|
|
21
|
+
className: "max-md:sr-only",
|
|
22
|
+
children: text("reloadPage")
|
|
23
|
+
})]
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
//#endregion
|
|
27
|
+
export { NewVersionButton };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { useRouter } from "../router.js";
|
|
2
|
+
import { useAdminMeta } from "../meta-gate.js";
|
|
3
|
+
import { useToast } from "../../toasts/toast-provider.js";
|
|
4
|
+
import { text } from "./messages.js";
|
|
5
|
+
import { useModalOpen } from "./use-modal-open.js";
|
|
6
|
+
import { useEffect, useState } from "react";
|
|
7
|
+
//#region src/app/new-version/new-version-toast.tsx
|
|
8
|
+
/**
|
|
9
|
+
* Once the server runs another Protobase version than the page started with, a toast that says so and reloads the
|
|
10
|
+
* page, asking first while a guard holds unsaved changes. It stays until Reload or ×, and after × it does not come
|
|
11
|
+
* back; the top bar's `NewVersionButton` stays. It steps aside while a modal is open, whose buttons it could cover on a
|
|
12
|
+
* phone. The page keeps the `/meta` it has meanwhile (see `keepOnVersionChange`).
|
|
13
|
+
*/
|
|
14
|
+
const NewVersionToast = () => {
|
|
15
|
+
const changed = Boolean(useAdminMeta().versionChange);
|
|
16
|
+
const modalOpen = useModalOpen();
|
|
17
|
+
const [closed, setClosed] = useState(false);
|
|
18
|
+
const toast = useToast();
|
|
19
|
+
const { reload } = useRouter();
|
|
20
|
+
const shown = changed && !closed && !modalOpen;
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (!shown) return;
|
|
23
|
+
const id = toast.show({
|
|
24
|
+
state: "info",
|
|
25
|
+
sticky: true,
|
|
26
|
+
title: text("title"),
|
|
27
|
+
description: text("description"),
|
|
28
|
+
action: {
|
|
29
|
+
label: text("reload"),
|
|
30
|
+
onClick: reload
|
|
31
|
+
},
|
|
32
|
+
onClose: () => setClosed(true)
|
|
33
|
+
});
|
|
34
|
+
return () => toast.dismiss(id);
|
|
35
|
+
}, [
|
|
36
|
+
shown,
|
|
37
|
+
toast,
|
|
38
|
+
reload
|
|
39
|
+
]);
|
|
40
|
+
return null;
|
|
41
|
+
};
|
|
42
|
+
//#endregion
|
|
43
|
+
export { NewVersionToast };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
//#region src/app/new-version/use-modal-open.ts
|
|
3
|
+
const modalOpen = () => document.querySelector("[aria-modal=\"true\"]") !== null;
|
|
4
|
+
/** Whether a modal, a dialog or a sheet, is open anywhere on the page, following them as they open and close. */
|
|
5
|
+
const useModalOpen = () => {
|
|
6
|
+
const [open, setOpen] = useState(modalOpen);
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
const observer = new MutationObserver(() => setOpen(modalOpen()));
|
|
9
|
+
observer.observe(document.body, {
|
|
10
|
+
childList: true,
|
|
11
|
+
subtree: true
|
|
12
|
+
});
|
|
13
|
+
setOpen(modalOpen());
|
|
14
|
+
return () => observer.disconnect();
|
|
15
|
+
}, []);
|
|
16
|
+
return open;
|
|
17
|
+
};
|
|
18
|
+
//#endregion
|
|
19
|
+
export { useModalOpen };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Button } from "../../../primitives/button.js";
|
|
2
2
|
import { Dialog } from "../../../primitives/dialog.js";
|
|
3
|
+
import { useNavigationGuard } from "../../router.js";
|
|
3
4
|
import { useClient } from "../../../data/api-provider.js";
|
|
4
5
|
import { useAdminMeta, usePermissions } from "../../meta-gate.js";
|
|
5
6
|
import { text } from "../messages.js";
|
|
@@ -45,6 +46,8 @@ const ModalFormDialog = ({ model, record, mode, label, title, fields, values, on
|
|
|
45
46
|
const formRef = useFocusFirstInvalid(rejections);
|
|
46
47
|
const [failure, setFailure] = useState();
|
|
47
48
|
const [saving, setSaving] = useState(false);
|
|
49
|
+
const [touched, setTouched] = useState(false);
|
|
50
|
+
useNavigationGuard(touched || saving, "input-lost");
|
|
48
51
|
const submit = async () => {
|
|
49
52
|
setSaving(true);
|
|
50
53
|
setFailure(void 0);
|
|
@@ -113,6 +116,7 @@ const ModalFormDialog = ({ model, record, mode, label, title, fields, values, on
|
|
|
113
116
|
resource: model.name,
|
|
114
117
|
onChange: (next) => {
|
|
115
118
|
setErrors(({ [name]: _, ...rest }) => rest);
|
|
119
|
+
setTouched(true);
|
|
116
120
|
setDraft((current) => ({
|
|
117
121
|
...current,
|
|
118
122
|
[name]: next
|
|
@@ -4,13 +4,22 @@ import { listColumnNames } from "../../../live/columns-from-model.js";
|
|
|
4
4
|
import { propsOf } from "./block-props.js";
|
|
5
5
|
import { RelatedCards } from "../../related-cards/related-cards.js";
|
|
6
6
|
import { jsx } from "react/jsx-runtime";
|
|
7
|
+
import { cardsLayoutOf } from "@protobase/layout";
|
|
7
8
|
//#region src/app/pages/blocks/record-cards.tsx
|
|
9
|
+
const shares = {
|
|
10
|
+
"1/4": 1 / 4,
|
|
11
|
+
"1/3": 1 / 3,
|
|
12
|
+
"1/2": 1 / 2,
|
|
13
|
+
"2/3": 2 / 3
|
|
14
|
+
};
|
|
8
15
|
/**
|
|
9
|
-
* What a filter finds as the record page's related cards, stacked full width
|
|
10
|
-
* section's, with no card frame around them, so no card sits in another.
|
|
16
|
+
* What a filter finds as the record page's related cards, stacked full width or in a row that scrolls sideways, under
|
|
17
|
+
* a heading like a record page section's, with no card frame around them, so no card sits in another.
|
|
11
18
|
*/
|
|
12
19
|
const RecordCardsBlock = ({ node, slots }) => {
|
|
13
|
-
const
|
|
20
|
+
const props = propsOf(node);
|
|
21
|
+
const { resource, filter = "", sort = "", fields, cardActions = [], limit = 10, display, title, description, empty } = props;
|
|
22
|
+
const { layout, cardWidth } = cardsLayoutOf(props);
|
|
14
23
|
const { resources, views } = useAdminMeta();
|
|
15
24
|
const model = resources[resource];
|
|
16
25
|
if (!model) return null;
|
|
@@ -31,7 +40,8 @@ const RecordCardsBlock = ({ node, slots }) => {
|
|
|
31
40
|
actions: cardActions,
|
|
32
41
|
limit,
|
|
33
42
|
display,
|
|
34
|
-
empty
|
|
43
|
+
empty,
|
|
44
|
+
carousel: layout === "carousel" ? shares[cardWidth] : void 0
|
|
35
45
|
})
|
|
36
46
|
})
|
|
37
47
|
});
|
package/dist/app/record-page.js
CHANGED
|
@@ -153,7 +153,7 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
|
|
|
153
153
|
const restore = useRestore(model);
|
|
154
154
|
const canUpdate = can(permissions, "update") && stored.permissions?.update !== false;
|
|
155
155
|
const { shown, offersEdit, editing } = recordShown(view?.recordMode, canUpdate, address?.kind === "record" && address.edit === true);
|
|
156
|
-
useNavigationGuard(editor.dirty && shown === "form");
|
|
156
|
+
useNavigationGuard(editor.dirty && shown === "form" || editor.saving);
|
|
157
157
|
usePauseFocusRefresh(editor.dirty);
|
|
158
158
|
const openGroups = address?.kind === "record" ? address.open : void 0;
|
|
159
159
|
const atEdit = address?.kind === "record" && address.edit === true;
|
|
@@ -5,6 +5,7 @@ import { defaultOrderBy } from "../list-order.js";
|
|
|
5
5
|
import { AppLink } from "../use-app-address.js";
|
|
6
6
|
import { ErrorBanner } from "../error-banner.js";
|
|
7
7
|
import { scopeOf, useCount, useListPage } from "../pages/use-layout-data.js";
|
|
8
|
+
import { SnapRow } from "../../primitives/snap-row.js";
|
|
8
9
|
import { text } from "../embedded-list/messages.js";
|
|
9
10
|
import { cutOffNote } from "../embedded-list/cut-off-note.js";
|
|
10
11
|
import { embeddedDisplay, viewAllAddress } from "../embedded-list/embedded-display.js";
|
|
@@ -14,10 +15,10 @@ import { ArrowRight } from "lucide-react";
|
|
|
14
15
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
15
16
|
//#region src/app/related-cards/related-cards.tsx
|
|
16
17
|
/**
|
|
17
|
-
* The first `limit` records of a list as cards stacked full width
|
|
18
|
-
* link to the whole list beside a note such as "The first 10 of 37." when there are more.
|
|
18
|
+
* The first `limit` records of a list as cards stacked full width or in a `carousel`, each with its fields and its
|
|
19
|
+
* actions' buttons, and a link to the whole list beside a note such as "The first 10 of 37." when there are more.
|
|
19
20
|
*/
|
|
20
|
-
const RelatedCards = ({ model, view, filter, sort, fields, actions, limit, display, empty }) => {
|
|
21
|
+
const RelatedCards = ({ model, view, filter, sort, fields, actions, limit, display, empty, carousel }) => {
|
|
21
22
|
const xs = useIsXs();
|
|
22
23
|
const orderBy = sort || defaultOrderBy(model, view);
|
|
23
24
|
const page = useListPage(model, {
|
|
@@ -39,20 +40,24 @@ const RelatedCards = ({ model, view, filter, sort, fields, actions, limit, displ
|
|
|
39
40
|
className: "text-muted-foreground",
|
|
40
41
|
children: empty ?? text$1("nothingYet")
|
|
41
42
|
});
|
|
43
|
+
const cards = rows.map((row) => {
|
|
44
|
+
const scope = scopeOf(model, row);
|
|
45
|
+
return /* @__PURE__ */ jsx(RelatedCard, {
|
|
46
|
+
scope,
|
|
47
|
+
view,
|
|
48
|
+
fields,
|
|
49
|
+
actions
|
|
50
|
+
}, scope.key);
|
|
51
|
+
});
|
|
42
52
|
const note = count.data === void 0 ? void 0 : cutOffNote(rows.length, count.data, false);
|
|
43
53
|
return /* @__PURE__ */ jsxs("div", {
|
|
44
54
|
className: "flex flex-col gap-3",
|
|
45
|
-
children: [/* @__PURE__ */ jsx("ul", {
|
|
55
|
+
children: [carousel === void 0 ? /* @__PURE__ */ jsx("ul", {
|
|
46
56
|
className: "flex flex-col gap-3",
|
|
47
|
-
children:
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
view,
|
|
52
|
-
fields,
|
|
53
|
-
actions
|
|
54
|
-
}, scope.key);
|
|
55
|
-
})
|
|
57
|
+
children: cards
|
|
58
|
+
}) : /* @__PURE__ */ jsx(SnapRow, {
|
|
59
|
+
share: carousel,
|
|
60
|
+
children: cards
|
|
56
61
|
}), /* @__PURE__ */ jsxs("div", {
|
|
57
62
|
className: "flex flex-wrap items-center justify-between gap-2",
|
|
58
63
|
children: [/* @__PURE__ */ jsx("p", {
|
package/dist/app/router.js
CHANGED
|
@@ -34,13 +34,15 @@ const Router = ({ basePath = "", initialUrl, children }) => {
|
|
|
34
34
|
url: initialUrl ?? browserUrl(),
|
|
35
35
|
entry: memory ? newEntry() : historyEntry()
|
|
36
36
|
}));
|
|
37
|
-
const guards = useRef(/* @__PURE__ */ new
|
|
37
|
+
const guards = useRef(/* @__PURE__ */ new Map());
|
|
38
|
+
const losesInput = () => [...guards.current.values()].includes("input-lost");
|
|
38
39
|
const current = useRef(page);
|
|
39
40
|
current.current = page;
|
|
40
41
|
const position = useRef(memory ? 0 : historyIndex());
|
|
41
42
|
const undoing = useRef(false);
|
|
42
43
|
const proceeding = useRef(false);
|
|
43
44
|
const leaveAfterUndo = useRef(void 0);
|
|
45
|
+
const reloading = useRef(false);
|
|
44
46
|
const [pending, setPending] = useState();
|
|
45
47
|
useEffect(() => {
|
|
46
48
|
if (memory) return;
|
|
@@ -64,7 +66,8 @@ const Router = ({ basePath = "", initialUrl, children }) => {
|
|
|
64
66
|
window.history.go(-delta);
|
|
65
67
|
setPending({
|
|
66
68
|
kind: "history",
|
|
67
|
-
delta
|
|
69
|
+
delta,
|
|
70
|
+
inputLost: losesInput()
|
|
68
71
|
});
|
|
69
72
|
return;
|
|
70
73
|
}
|
|
@@ -97,19 +100,32 @@ const Router = ({ basePath = "", initialUrl, children }) => {
|
|
|
97
100
|
if (pathOf(full) !== pathOf(current.current.url) && guards.current.size > 0) return setPending({
|
|
98
101
|
kind: "link",
|
|
99
102
|
full,
|
|
100
|
-
replace
|
|
103
|
+
replace,
|
|
104
|
+
inputLost: losesInput()
|
|
101
105
|
});
|
|
102
106
|
go(full, replace);
|
|
103
107
|
}, [basePath, go]);
|
|
104
|
-
const registerGuard = useCallback(() => {
|
|
108
|
+
const registerGuard = useCallback((kind = "draft-kept") => {
|
|
105
109
|
const token = Symbol("guard");
|
|
106
|
-
guards.current.
|
|
110
|
+
guards.current.set(token, kind);
|
|
107
111
|
return () => void guards.current.delete(token);
|
|
108
112
|
}, []);
|
|
113
|
+
const reload = useCallback(() => {
|
|
114
|
+
if (guards.current.size > 0) return setPending({
|
|
115
|
+
kind: "reload",
|
|
116
|
+
inputLost: losesInput()
|
|
117
|
+
});
|
|
118
|
+
window.location.reload();
|
|
119
|
+
}, []);
|
|
120
|
+
const reloadConfirmed = useCallback(() => reloading.current, []);
|
|
109
121
|
const leave = () => {
|
|
110
122
|
if (!pending) return;
|
|
111
123
|
setPending(void 0);
|
|
112
124
|
if (pending.kind === "link") return go(pending.full, pending.replace);
|
|
125
|
+
if (pending.kind === "reload") {
|
|
126
|
+
reloading.current = true;
|
|
127
|
+
return window.location.reload();
|
|
128
|
+
}
|
|
113
129
|
proceeding.current = true;
|
|
114
130
|
if (undoing.current) leaveAfterUndo.current = pending.delta;
|
|
115
131
|
else window.history.go(pending.delta);
|
|
@@ -122,29 +138,49 @@ const Router = ({ basePath = "", initialUrl, children }) => {
|
|
|
122
138
|
basePath,
|
|
123
139
|
entry: page.entry,
|
|
124
140
|
navigate,
|
|
125
|
-
registerGuard
|
|
141
|
+
registerGuard,
|
|
142
|
+
reload,
|
|
143
|
+
reloadConfirmed
|
|
126
144
|
};
|
|
127
145
|
}, [
|
|
128
146
|
page,
|
|
129
147
|
basePath,
|
|
130
148
|
navigate,
|
|
131
|
-
registerGuard
|
|
149
|
+
registerGuard,
|
|
150
|
+
reload,
|
|
151
|
+
reloadConfirmed
|
|
132
152
|
]);
|
|
133
153
|
return /* @__PURE__ */ jsxs(RouterContext.Provider, {
|
|
134
154
|
value,
|
|
135
|
-
children: [
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
children:
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
155
|
+
children: [
|
|
156
|
+
children,
|
|
157
|
+
pending?.kind === "reload" && /* @__PURE__ */ jsx(Dialog, {
|
|
158
|
+
title: text("reloadTitle"),
|
|
159
|
+
description: text(pending.inputLost ? "reloadInputLost" : "reloadDraftKept"),
|
|
160
|
+
onClose: () => setPending(void 0),
|
|
161
|
+
actions: /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Button, {
|
|
162
|
+
onClick: () => setPending(void 0),
|
|
163
|
+
children: text("keepEditing")
|
|
164
|
+
}), /* @__PURE__ */ jsx(Button, {
|
|
165
|
+
variant: "primary",
|
|
166
|
+
onClick: leave,
|
|
167
|
+
children: text("reload")
|
|
168
|
+
})] })
|
|
169
|
+
}),
|
|
170
|
+
pending && pending.kind !== "reload" && /* @__PURE__ */ jsx(Dialog, {
|
|
171
|
+
title: text("leaveTitle"),
|
|
172
|
+
description: text(pending.inputLost ? "leaveInputLost" : "leaveDescription"),
|
|
173
|
+
onClose: () => setPending(void 0),
|
|
174
|
+
actions: /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Button, {
|
|
175
|
+
onClick: () => setPending(void 0),
|
|
176
|
+
children: text("stay")
|
|
177
|
+
}), /* @__PURE__ */ jsx(Button, {
|
|
178
|
+
variant: "primary",
|
|
179
|
+
onClick: leave,
|
|
180
|
+
children: text("leave")
|
|
181
|
+
})] })
|
|
182
|
+
})
|
|
183
|
+
]
|
|
148
184
|
});
|
|
149
185
|
};
|
|
150
186
|
/** A link to a path below the base path, as `appPath` writes it, that the router follows without reloading the page. */
|
|
@@ -158,16 +194,22 @@ const Link = ({ to, replace, ...rest }) => {
|
|
|
158
194
|
...rest
|
|
159
195
|
});
|
|
160
196
|
};
|
|
161
|
-
/** While `active`, leaving the page asks first (in-app links and closing the tab). */
|
|
162
|
-
const useNavigationGuard = (active) => {
|
|
163
|
-
const { registerGuard } = useRouter();
|
|
164
|
-
useEffect(() => active ? registerGuard() : void 0, [
|
|
197
|
+
/** While `active`, leaving the page asks first (in-app links, a reload and closing the tab). */
|
|
198
|
+
const useNavigationGuard = (active, kind = "draft-kept") => {
|
|
199
|
+
const { registerGuard, reloadConfirmed } = useRouter();
|
|
200
|
+
useEffect(() => active ? registerGuard(kind) : void 0, [
|
|
201
|
+
active,
|
|
202
|
+
kind,
|
|
203
|
+
registerGuard
|
|
204
|
+
]);
|
|
165
205
|
useEffect(() => {
|
|
166
206
|
if (!active) return;
|
|
167
|
-
const warn = (event) =>
|
|
207
|
+
const warn = (event) => {
|
|
208
|
+
if (!reloadConfirmed()) event.preventDefault();
|
|
209
|
+
};
|
|
168
210
|
window.addEventListener("beforeunload", warn);
|
|
169
211
|
return () => window.removeEventListener("beforeunload", warn);
|
|
170
|
-
}, [active]);
|
|
212
|
+
}, [active, reloadConfirmed]);
|
|
171
213
|
};
|
|
172
214
|
//#endregion
|
|
173
215
|
export { Link, Router, useNavigationGuard, useRouter };
|
|
@@ -4,14 +4,16 @@ import { BottomBar } from "./bottom-bar.js";
|
|
|
4
4
|
import { Drawer } from "./drawer.js";
|
|
5
5
|
import { PageScrollContext, usePageGutter, usePageInsets } from "./page-scroll.js";
|
|
6
6
|
import { Sidebar } from "./sidebar.js";
|
|
7
|
+
import { useSidebarNoticeShown } from "./sidebar-notice.js";
|
|
7
8
|
import { TopBarSlotContext } from "./top-bar-slot.js";
|
|
8
9
|
import { TopBar } from "./top-bar.js";
|
|
9
10
|
import { useEffect, useRef, useState } from "react";
|
|
10
11
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
11
12
|
//#region src/app-shell/app-shell.tsx
|
|
12
13
|
const drawerId = "app-drawer";
|
|
13
|
-
const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, appName, appIcon, logo, actions, rightPanel, forceExpanded, profileMenuOpen, navGroups, userMenu, bottomBar, onNavigate, onSignOut, signIn, organizations, roles, theme, search, pageScroll: scrollsByDefault = false, className, children }) => {
|
|
14
|
+
const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, appName, appIcon, logo, actions, rightPanel, sidebarNotice, forceExpanded, profileMenuOpen, navGroups, userMenu, bottomBar, onNavigate, onSignOut, signIn, organizations, roles, theme, search, pageScroll: scrollsByDefault = false, className, children }) => {
|
|
14
15
|
const desktop = useIsDesktop();
|
|
16
|
+
const noticeShown = useSidebarNoticeShown(sidebarMode);
|
|
15
17
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
16
18
|
useEffect(() => {
|
|
17
19
|
if (desktop) setDrawerOpen(false);
|
|
@@ -24,9 +26,10 @@ const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, appNam
|
|
|
24
26
|
const bottomBarRef = useRef(null);
|
|
25
27
|
usePageInsets(pageScroll, topBarRef, bottomBarRef, Boolean(tabs));
|
|
26
28
|
usePageGutter(pageScroll);
|
|
27
|
-
const sidebar = (mode, sticky = false) => /* @__PURE__ */ jsx(Sidebar, {
|
|
29
|
+
const sidebar = (mode, sticky = false, notice) => /* @__PURE__ */ jsx(Sidebar, {
|
|
28
30
|
mode,
|
|
29
31
|
sticky,
|
|
32
|
+
notice,
|
|
30
33
|
activeItem,
|
|
31
34
|
user,
|
|
32
35
|
workspace,
|
|
@@ -49,7 +52,7 @@ const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, appNam
|
|
|
49
52
|
children: /* @__PURE__ */ jsxs("div", {
|
|
50
53
|
className: cn(pageScroll ? "flex min-h-full w-full overflow-x-clip bg-background" : "relative flex h-full min-h-0 w-full overflow-hidden bg-background", className),
|
|
51
54
|
children: [
|
|
52
|
-
desktop ? sidebar(sidebarMode, pageScroll) : /* @__PURE__ */ jsx(Drawer, {
|
|
55
|
+
desktop ? sidebar(sidebarMode, pageScroll, noticeShown && sidebarNotice) : /* @__PURE__ */ jsx(Drawer, {
|
|
53
56
|
id: drawerId,
|
|
54
57
|
open: drawerOpen,
|
|
55
58
|
onClose: () => setDrawerOpen(false),
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { useIsDesktop } from "../lib/use-media-query.js";
|
|
2
|
+
import { isTextMode } from "./sidebar-mode.js";
|
|
3
|
+
//#region src/app-shell/sidebar-notice.ts
|
|
4
|
+
/**
|
|
5
|
+
* Whether the shell draws its `sidebarNotice`: where the sidebar shows at full width, from the `md` breakpoint up in a
|
|
6
|
+
* text mode. Collapsed to icons, or a drawer on phones, it has no room, so the app says the same another way.
|
|
7
|
+
*/
|
|
8
|
+
const useSidebarNoticeShown = (mode) => {
|
|
9
|
+
return useIsDesktop() && isTextMode(mode);
|
|
10
|
+
};
|
|
11
|
+
//#endregion
|
|
12
|
+
export { useSidebarNoticeShown };
|