@protobase/ui 0.21.8 → 0.21.10
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/account/account-security-page.js +5 -4
- package/dist/app/app.js +1 -0
- package/dist/app/charts/chart-view.js +3 -1
- package/dist/app/displays/calendar-controls.js +2 -1
- package/dist/app/displays/calendar-display.js +2 -1
- package/dist/app/displays/calendar-event.js +2 -1
- package/dist/app/displays/calendar-range.js +18 -14
- package/dist/app/export/cells.js +6 -4
- package/dist/app/field-editor.js +13 -1
- package/dist/app/import/convert.js +2 -12
- package/dist/app/import/locale-formats.js +2 -1
- package/dist/app/incoming-card.js +35 -0
- package/dist/app/incoming-relations.js +36 -0
- package/dist/app/list-chart.js +3 -1
- package/dist/app/pages/field-value.js +9 -1
- package/dist/app/pages/record-actions.js +34 -16
- package/dist/app/record-page.js +26 -18
- package/dist/app/runtime/runtime-status.js +5 -4
- package/dist/app/search/navigation-hits.js +45 -0
- package/dist/app/search/use-global-search.js +13 -4
- package/dist/app/use-incoming-counts.js +38 -0
- package/dist/app/use-related-records.js +3 -10
- package/dist/app-shell/app-shell.js +9 -2
- package/dist/app-shell/index.d.ts +1 -0
- package/dist/app-shell/logo.d.ts +5 -1
- package/dist/app-shell/logo.js +10 -3
- package/dist/app-shell/sidebar.js +3 -2
- package/dist/app-shell/top-bar-slot.js +11 -0
- package/dist/app-shell/top-bar.d.ts +3 -0
- package/dist/app-shell/top-bar.js +27 -19
- package/dist/auth/auth-messages.js +2 -1
- package/dist/auth/auth-provider.js +8 -3
- package/dist/data-grid/cells.js +8 -1
- package/dist/data-grid/grid-header.js +17 -16
- package/dist/data-grid/header-menu.js +16 -6
- package/dist/data-grid/row-menu.js +2 -39
- package/dist/format/date.js +29 -9
- package/dist/format/number-text.js +16 -0
- package/dist/format/zone.js +62 -0
- package/dist/live/columns-from-model.js +2 -0
- package/dist/live/contact-link.js +25 -0
- package/dist/live/contact-links.js +42 -0
- package/dist/live/display-value.js +14 -4
- package/dist/live/field-values.js +20 -9
- package/dist/live/filter-string.js +30 -30
- package/dist/package.js +1 -1
- package/dist/primitives/popover-menu.js +56 -0
- package/dist/record-view/action-menu.js +59 -0
- package/dist/record-view/record-header.js +2 -6
- package/dist/record-view/record-sidebar.js +2 -30
- package/dist/shell/assets/app-9imuOKuW.js +69 -0
- package/dist/shell/assets/export-worker-BoAr3vuF.js +2 -0
- package/dist/shell/assets/{index-xN6_oZU2.js → index-C_o0i6I2.js} +1 -1
- package/dist/shell/assets/index-CzqIlZPP.css +2 -0
- package/dist/shell/assets/{maplibre-Ba9RapjY.js → maplibre-DLYsHwYU.js} +1 -1
- package/dist/shell/assets/{markdown-content-BUxrbovf.js → markdown-content-B410ow-t.js} +1 -1
- package/dist/shell/assets/{record-map-14zotZ2s.js → record-map-DvgWFyLn.js} +1 -1
- package/dist/shell/assets/{records-map-wi-0pXqF.js → records-map-CLj0uHGq.js} +1 -1
- package/dist/shell/assets/{shared-protobase-ui-BVwhKZk9.js → shared-protobase-ui-Bf1h2ktk.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-C01lazBK.js +0 -69
- package/dist/shell/assets/export-worker-BnOpS_4Q.js +0 -2
- package/dist/shell/assets/index-CBGigFxb.css +0 -2
package/dist/app/record-page.js
CHANGED
|
@@ -6,12 +6,12 @@ import { Button, buttonClass } from "../primitives/button.js";
|
|
|
6
6
|
import { usePauseFocusRefresh } from "../data/focus-refresh.js";
|
|
7
7
|
import { keys } from "../data/query-keys.js";
|
|
8
8
|
import { useClient } from "../data/api-provider.js";
|
|
9
|
-
import { useNavigationGuard
|
|
9
|
+
import { useNavigationGuard } from "./router.js";
|
|
10
10
|
import { useOpenedRecord } from "../data/use-record.js";
|
|
11
11
|
import { humanize } from "../live/naming.js";
|
|
12
12
|
import { Field } from "../record-view/field.js";
|
|
13
13
|
import { Section } from "../record-view/section.js";
|
|
14
|
-
import { MetadataCard
|
|
14
|
+
import { MetadataCard } from "../record-view/record-sidebar.js";
|
|
15
15
|
import { RecordHeader } from "../record-view/record-header.js";
|
|
16
16
|
import { SpecTable } from "../record-view/spec-table.js";
|
|
17
17
|
import { KeyFacts } from "../record-view/key-facts.js";
|
|
@@ -21,8 +21,9 @@ import { isChosen, keyValue } from "./create/create-values.js";
|
|
|
21
21
|
import { ErrorBanner } from "./error-banner.js";
|
|
22
22
|
import { isMarkdown } from "../markdown/markdown-hint.js";
|
|
23
23
|
import { recordTitle } from "../live/model-helpers.js";
|
|
24
|
+
import { contactFormat } from "../live/contact-links.js";
|
|
24
25
|
import { toneFor } from "../live/columns-from-model.js";
|
|
25
|
-
import { displayValue } from "../live/display-value.js";
|
|
26
|
+
import { displayValue, valueTitle } from "../live/display-value.js";
|
|
26
27
|
import { filledFields } from "./filled-fields.js";
|
|
27
28
|
import { Notice, useAdminMeta, usePermissions } from "./meta-gate.js";
|
|
28
29
|
import { AppLink, useAppAddress } from "./use-app-address.js";
|
|
@@ -30,6 +31,7 @@ import { useProjectUi } from "./pages/project-ui.js";
|
|
|
30
31
|
import { shownWidget } from "./field-types/shown-widget.js";
|
|
31
32
|
import { WidgetPart } from "./field-types/widget-part.js";
|
|
32
33
|
import { Markdown } from "../markdown/markdown.js";
|
|
34
|
+
import { ContactLink } from "../live/contact-link.js";
|
|
33
35
|
import { FieldEditor } from "./field-editor.js";
|
|
34
36
|
import { fieldMessage } from "./field-errors.js";
|
|
35
37
|
import { useFocusFirstInvalid } from "./use-focus-first-invalid.js";
|
|
@@ -52,6 +54,7 @@ import { HistorySection } from "./history/history-section.js";
|
|
|
52
54
|
import { CommentsSection } from "./comments/comments-section.js";
|
|
53
55
|
import { useRecordEditor } from "./use-record-editor.js";
|
|
54
56
|
import { useRelatedRecords } from "./use-related-records.js";
|
|
57
|
+
import { IncomingCard } from "./incoming-card.js";
|
|
55
58
|
import { RelatedSection } from "./related-section.js";
|
|
56
59
|
import { Fragment, useMemo } from "react";
|
|
57
60
|
import { Check } from "lucide-react";
|
|
@@ -121,8 +124,7 @@ const RecordPage = ({ resource, recordKey }) => {
|
|
|
121
124
|
};
|
|
122
125
|
const LoadedRecord = ({ model, recordKey, stored, preview }) => {
|
|
123
126
|
const meta = useAdminMeta();
|
|
124
|
-
const {
|
|
125
|
-
const { address, go, open } = useAppAddress();
|
|
127
|
+
const { address, go } = useAppAddress();
|
|
126
128
|
const view = meta.views[model.name];
|
|
127
129
|
const title = recordTitle(model, view, stored.record, recordKey);
|
|
128
130
|
const { state: auth } = useAuth();
|
|
@@ -168,7 +170,7 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
|
|
|
168
170
|
}, { replace: true }),
|
|
169
171
|
onReview: () => void queryClient.invalidateQueries({ queryKey: keys.record(model.name, recordKey) })
|
|
170
172
|
});
|
|
171
|
-
const related = useRelatedRecords(model,
|
|
173
|
+
const related = useRelatedRecords(model, meta.resources, meta.views, stored.record);
|
|
172
174
|
const pickTarget = (name) => {
|
|
173
175
|
const field = model.fields[name];
|
|
174
176
|
const target = field?.relation ? meta.resources[field.relation.resource] : void 0;
|
|
@@ -180,7 +182,7 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
|
|
|
180
182
|
...stored.record,
|
|
181
183
|
...Object.fromEntries(picked.map((field) => [field.name, fromDraft(field, editor.value(field.name))]))
|
|
182
184
|
};
|
|
183
|
-
const chosen = useRelatedRecords(model,
|
|
185
|
+
const chosen = useRelatedRecords(model, meta.resources, meta.views, drafted);
|
|
184
186
|
const pick = (name, next) => {
|
|
185
187
|
const target = pickTarget(name);
|
|
186
188
|
if (!target || !isChosen(next)) return editor.change(name, next);
|
|
@@ -279,11 +281,16 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
|
|
|
279
281
|
const text = displayValue(field, stored.record[name], hint(name));
|
|
280
282
|
const value = stored.record[name];
|
|
281
283
|
const markdown = isMarkdown(field, hint(name)) && typeof value === "string" && value !== "";
|
|
284
|
+
const contact = field.type === "text" && typeof value === "string" && value !== "" ? contactFormat(hint(name)) : void 0;
|
|
282
285
|
const builtIn = field.type === "enum" ? /* @__PURE__ */ jsx(ValueBadge, {
|
|
283
286
|
value,
|
|
284
287
|
hints: hint(name),
|
|
285
288
|
children: text
|
|
286
|
-
}) : markdown ? /* @__PURE__ */ jsx(Markdown, { source: value }) : /* @__PURE__ */ jsx(
|
|
289
|
+
}) : markdown ? /* @__PURE__ */ jsx(Markdown, { source: value }) : contact ? /* @__PURE__ */ jsx(ContactLink, {
|
|
290
|
+
format: contact,
|
|
291
|
+
value: text
|
|
292
|
+
}) : /* @__PURE__ */ jsx("strong", {
|
|
293
|
+
title: valueTitle(field, value),
|
|
287
294
|
className: hint(name)?.multiline ? "whitespace-pre-line break-words" : void 0,
|
|
288
295
|
children: text
|
|
289
296
|
});
|
|
@@ -349,7 +356,6 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
|
|
|
349
356
|
children: [
|
|
350
357
|
/* @__PURE__ */ jsx(RecordHeader, {
|
|
351
358
|
title,
|
|
352
|
-
titleHidden: true,
|
|
353
359
|
status: status ? {
|
|
354
360
|
label: enumLabel(status, hint("status")?.valueLabels),
|
|
355
361
|
tone: toneFor(status)
|
|
@@ -404,7 +410,7 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
|
|
|
404
410
|
/* @__PURE__ */ jsxs("div", {
|
|
405
411
|
className: "grid grid-cols-1 gap-8 md:grid-cols-[minmax(0,1fr)_300px] md:gap-12",
|
|
406
412
|
children: [/* @__PURE__ */ jsxs("main", {
|
|
407
|
-
className:
|
|
413
|
+
className: "divide-y divide-border",
|
|
408
414
|
children: [layout.flatMap((item, index) => {
|
|
409
415
|
if (item.kind === "related") return [/* @__PURE__ */ jsx(RelatedSection, {
|
|
410
416
|
item,
|
|
@@ -463,7 +469,7 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
|
|
|
463
469
|
locked: shown !== "form"
|
|
464
470
|
})]
|
|
465
471
|
}), /* @__PURE__ */ jsxs("aside", {
|
|
466
|
-
className:
|
|
472
|
+
className: "space-y-4 md:pt-8",
|
|
467
473
|
children: [
|
|
468
474
|
formControls && /* @__PURE__ */ jsx("div", {
|
|
469
475
|
className: "hidden flex-col items-center gap-2 md:flex",
|
|
@@ -495,16 +501,18 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
|
|
|
495
501
|
if (!name || !field || !visible(name)) return [];
|
|
496
502
|
return [{
|
|
497
503
|
label: hint(name)?.label ?? humanize(name),
|
|
498
|
-
value: known(name) ?
|
|
504
|
+
value: known(name) ? /* @__PURE__ */ jsx("span", {
|
|
505
|
+
title: valueTitle(field, stored.record[name]),
|
|
506
|
+
children: displayValue(field, stored.record[name], hint(name))
|
|
507
|
+
}) : /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-24" })
|
|
499
508
|
}];
|
|
500
509
|
})
|
|
501
510
|
}),
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
})
|
|
511
|
+
!preview && /* @__PURE__ */ jsx(IncomingCard, {
|
|
512
|
+
model,
|
|
513
|
+
recordKey,
|
|
514
|
+
layout,
|
|
515
|
+
lines: linesModel
|
|
508
516
|
})
|
|
509
517
|
]
|
|
510
518
|
})]
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { viewerZone } from "../../format/zone.js";
|
|
1
2
|
import { formatAgo, formatDateTime } from "../../format/date.js";
|
|
2
3
|
import { isApiError } from "@protobase/client";
|
|
3
4
|
//#region src/app/runtime/runtime-status.ts
|
|
@@ -23,14 +24,14 @@ const runningText = (status, now) => {
|
|
|
23
24
|
return `${label}, started ${formatAgo(seconds)}`;
|
|
24
25
|
};
|
|
25
26
|
/** How far the running update is, or when the latest version will be applied on its own, in words; `now` in ms. */
|
|
26
|
-
const nextUpdateText = (status, now = Date.now()) => {
|
|
27
|
+
const nextUpdateText = (status, now = Date.now(), zone = viewerZone()) => {
|
|
27
28
|
if (status.updating) return runningText(status, now);
|
|
28
|
-
if (status.nextUpdateAt) return `${policyLabels[status.policy]}: next on ${formatDateTime(Date.parse(status.nextUpdateAt))}
|
|
29
|
+
if (status.nextUpdateAt) return `${policyLabels[status.policy]}: next on ${formatDateTime(Date.parse(status.nextUpdateAt), zone)}`;
|
|
29
30
|
return policyLabels[status.policy];
|
|
30
31
|
};
|
|
31
32
|
/** How the last update ended, in words, with the endpoint's message for the owner when it has one. */
|
|
32
|
-
const lastUpdateText = (last) => {
|
|
33
|
-
const when =
|
|
33
|
+
const lastUpdateText = (last, zone = viewerZone()) => {
|
|
34
|
+
const when = formatDateTime(Date.parse(last.finishedAt), zone);
|
|
34
35
|
const headline = {
|
|
35
36
|
succeeded: `Updated to ${last.toVersion} on ${when}`,
|
|
36
37
|
unchanged: `The update to ${last.toVersion} on ${when} changed nothing`,
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { humanize } from "../../live/naming.js";
|
|
2
|
+
import { allowEverything } from "../../data/meta-data.js";
|
|
3
|
+
import { createOffered } from "../create/create-offered.js";
|
|
4
|
+
import { navFromMeta } from "../nav-from-meta.js";
|
|
5
|
+
import { appPath } from "@protobase/schema";
|
|
6
|
+
const matches = (label, text) => label.toLowerCase().includes(text.trim().toLowerCase());
|
|
7
|
+
/**
|
|
8
|
+
* The search's own results, before the records: the sidebar's pages and lists whose names hold the text ("stock" finds
|
|
9
|
+
* Stock levels), and "New ..." for each resource the user may create whose name does ("new order" or "order" finds New
|
|
10
|
+
* order). Each is a link, as the sidebar's entries are.
|
|
11
|
+
*/
|
|
12
|
+
const navigationHits = (meta, text, basePath = "") => {
|
|
13
|
+
const pages = navFromMeta(meta, basePath).flatMap((group) => group.items.filter((item) => item.href !== void 0 && !item.problem)).filter((item) => matches(item.label, text)).slice(0, 5);
|
|
14
|
+
const asked = text.trim().replace(/^new\s+/i, "");
|
|
15
|
+
const creates = Object.values(meta.resources).flatMap((model) => {
|
|
16
|
+
const view = meta.views[model.name];
|
|
17
|
+
if (!view || view.nav?.hidden || !createOffered(view, meta.pages, meta.permissions[model.name] ?? allowEverything)) return [];
|
|
18
|
+
const singular = view.names?.singular ?? humanize(model.name);
|
|
19
|
+
const label = `New ${singular.toLowerCase()}`;
|
|
20
|
+
if (!matches(label, text) && !matches(singular, asked) && !matches(view.names?.plural ?? humanize(model.name), asked)) return [];
|
|
21
|
+
return [{
|
|
22
|
+
id: `new:${model.name}`,
|
|
23
|
+
title: label,
|
|
24
|
+
href: appPath({
|
|
25
|
+
kind: "create",
|
|
26
|
+
resource: model.name
|
|
27
|
+
}, basePath)
|
|
28
|
+
}];
|
|
29
|
+
});
|
|
30
|
+
return [...pages.length > 0 ? [{
|
|
31
|
+
id: "go-to",
|
|
32
|
+
label: "Go to",
|
|
33
|
+
hits: pages.map((item) => ({
|
|
34
|
+
id: `go-to:${item.id}`,
|
|
35
|
+
title: item.label,
|
|
36
|
+
href: item.href
|
|
37
|
+
}))
|
|
38
|
+
}] : [], ...creates.length > 0 ? [{
|
|
39
|
+
id: "create",
|
|
40
|
+
label: "Create",
|
|
41
|
+
hits: creates.slice(0, 5)
|
|
42
|
+
}] : []];
|
|
43
|
+
};
|
|
44
|
+
//#endregion
|
|
45
|
+
export { navigationHits };
|
|
@@ -2,12 +2,14 @@ import { useClient } from "../../data/api-provider.js";
|
|
|
2
2
|
import { useRouter } from "../router.js";
|
|
3
3
|
import { useAdminMeta } from "../meta-gate.js";
|
|
4
4
|
import { useDebounced } from "../../lib/use-debounced.js";
|
|
5
|
+
import { navigationHits } from "./navigation-hits.js";
|
|
5
6
|
import { searchFields, searchFilter, searchPlaceholder, searchTargets, toHit } from "./search-model.js";
|
|
6
7
|
import { useMemo, useState } from "react";
|
|
7
8
|
import { useQueries } from "@tanstack/react-query";
|
|
8
9
|
//#region src/app/search/use-global-search.ts
|
|
9
10
|
/**
|
|
10
|
-
* The top bar's search: the typed text,
|
|
11
|
+
* The top bar's search: the typed text, matched against the sidebar's pages and lists and the records the user may
|
|
12
|
+
* create ("New order"), then searched in every resource the user can read that has search fields, through
|
|
11
13
|
* the list API with the user's token, so row filters and hidden fields apply exactly as on the lists. The results stay
|
|
12
14
|
* while the text does, so opening the box again asks nothing; other texts' results are dropped, so changed text asks again.
|
|
13
15
|
*/
|
|
@@ -36,7 +38,13 @@ const useGlobalSearch = () => {
|
|
|
36
38
|
fields: searchFields(target)
|
|
37
39
|
})
|
|
38
40
|
})) });
|
|
39
|
-
const
|
|
41
|
+
const navigation = useMemo(() => searching ? navigationHits(meta, query, basePath) : [], [
|
|
42
|
+
searching,
|
|
43
|
+
meta,
|
|
44
|
+
query,
|
|
45
|
+
basePath
|
|
46
|
+
]);
|
|
47
|
+
const records = targets.flatMap((target, index) => {
|
|
40
48
|
const result = results[index];
|
|
41
49
|
if (!searching || !result) return [];
|
|
42
50
|
if (result.error) return [{
|
|
@@ -52,9 +60,10 @@ const useGlobalSearch = () => {
|
|
|
52
60
|
hits
|
|
53
61
|
}] : [];
|
|
54
62
|
});
|
|
63
|
+
const groups = [...navigation, ...records];
|
|
55
64
|
return {
|
|
56
|
-
/** False when no
|
|
57
|
-
available:
|
|
65
|
+
/** False when there is nothing to find, no page, list or resource; the top bar then shows no search box. */
|
|
66
|
+
available: Object.keys(meta.pages).length > 0 || Object.keys(meta.resources).length > 0,
|
|
58
67
|
placeholder: searchPlaceholder(targets),
|
|
59
68
|
text,
|
|
60
69
|
setText,
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { keys } from "../data/query-keys.js";
|
|
2
|
+
import { useClient } from "../data/api-provider.js";
|
|
3
|
+
import { useQueries } from "@tanstack/react-query";
|
|
4
|
+
import { createWhere, printFilter } from "@protobase/schema";
|
|
5
|
+
//#region src/app/use-incoming-counts.ts
|
|
6
|
+
const where$1 = createWhere();
|
|
7
|
+
/** How many records point at `key` through each relation: `undefined` while loading, left out when the list is refused. */
|
|
8
|
+
const useIncomingCounts = (relations, resources, key) => {
|
|
9
|
+
const client = useClient();
|
|
10
|
+
const counts = useQueries({ queries: relations.map((relation) => {
|
|
11
|
+
const filter = printFilter(where$1.eq(relation.field, key));
|
|
12
|
+
return {
|
|
13
|
+
queryKey: keys.list(relation.resource, filter, "", 1, "incoming"),
|
|
14
|
+
queryFn: async () => {
|
|
15
|
+
const page = await client.list(relation.resource, {
|
|
16
|
+
filter,
|
|
17
|
+
pageSize: 1,
|
|
18
|
+
count: "exact",
|
|
19
|
+
fields: resources[relation.resource].primaryKey
|
|
20
|
+
});
|
|
21
|
+
return {
|
|
22
|
+
count: page.totalSize ?? page.totalSizeEstimate,
|
|
23
|
+
exact: page.totalSize !== void 0
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
}) });
|
|
28
|
+
return relations.flatMap((relation, index) => {
|
|
29
|
+
const result = counts[index];
|
|
30
|
+
if (result.error) return [];
|
|
31
|
+
return [{
|
|
32
|
+
relation,
|
|
33
|
+
count: result.data
|
|
34
|
+
}];
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
//#endregion
|
|
38
|
+
export { useIncomingCounts };
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import { keys } from "../data/query-keys.js";
|
|
2
2
|
import { useClient } from "../data/api-provider.js";
|
|
3
|
-
import { humanize } from "../live/naming.js";
|
|
4
3
|
import { fetchLabels } from "../live/relation-labels.js";
|
|
5
4
|
import { useQueries, useQueryClient } from "@tanstack/react-query";
|
|
6
|
-
import { appPath } from "@protobase/schema";
|
|
7
5
|
//#region src/app/use-related-records.ts
|
|
8
|
-
/**
|
|
9
|
-
const useRelatedRecords = (model,
|
|
6
|
+
/** Per relation the record holds, the address and name of the record it points at. The tenant is left out. */
|
|
7
|
+
const useRelatedRecords = (model, resources, views, record) => {
|
|
10
8
|
const client = useClient();
|
|
11
9
|
const queryClient = useQueryClient();
|
|
12
10
|
const relations = Object.values(model.fields).filter((field) => field.type === "relation" && field.relation && field.name !== model.tenant && record[field.name] != null && resources[field.relation.resource]?.primaryKey.length === 1);
|
|
@@ -25,12 +23,7 @@ const useRelatedRecords = (model, view, resources, views, record, basePath) => {
|
|
|
25
23
|
}));
|
|
26
24
|
return {
|
|
27
25
|
addresses: Object.fromEntries(relations.map((field, index) => [field.name, addresses[index]])),
|
|
28
|
-
labels: Object.fromEntries(relations.map((field, index) => [field.name, labels[index]?.data]))
|
|
29
|
-
records: relations.map((field, index) => ({
|
|
30
|
-
kind: view?.fields[field.name]?.label ?? humanize(field.name),
|
|
31
|
-
label: labels[index]?.data ?? String(record[field.name]),
|
|
32
|
-
href: appPath(addresses[index], basePath)
|
|
33
|
-
}))
|
|
26
|
+
labels: Object.fromEntries(relations.map((field, index) => [field.name, labels[index]?.data]))
|
|
34
27
|
};
|
|
35
28
|
};
|
|
36
29
|
//#endregion
|
|
@@ -4,12 +4,13 @@ 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 { TopBarSlotContext } from "./top-bar-slot.js";
|
|
7
8
|
import { TopBar } from "./top-bar.js";
|
|
8
9
|
import { useEffect, useRef, useState } from "react";
|
|
9
10
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
10
11
|
//#region src/app-shell/app-shell.tsx
|
|
11
12
|
const drawerId = "app-drawer";
|
|
12
|
-
const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, logo, actions, rightPanel, forceExpanded, profileMenuOpen, navGroups, userMenu, bottomBar, onNavigate, onSignOut, signIn, organizations, roles, theme, search, pageScroll: scrollsByDefault = false, className, children }) => {
|
|
13
|
+
const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, appName, logo, actions, rightPanel, forceExpanded, profileMenuOpen, navGroups, userMenu, bottomBar, onNavigate, onSignOut, signIn, organizations, roles, theme, search, pageScroll: scrollsByDefault = false, className, children }) => {
|
|
13
14
|
const desktop = useIsDesktop();
|
|
14
15
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
15
16
|
useEffect(() => {
|
|
@@ -19,6 +20,7 @@ const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, logo,
|
|
|
19
20
|
const [chosen, setPageScroll] = useState();
|
|
20
21
|
const pageScroll = chosen ?? scrollsByDefault;
|
|
21
22
|
const topBarRef = useRef(null);
|
|
23
|
+
const [topBarSlot, setTopBarSlot] = useState(null);
|
|
22
24
|
const bottomBarRef = useRef(null);
|
|
23
25
|
usePageInsets(pageScroll, topBarRef, bottomBarRef, Boolean(tabs));
|
|
24
26
|
usePageGutter(pageScroll);
|
|
@@ -28,6 +30,7 @@ const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, logo,
|
|
|
28
30
|
activeItem,
|
|
29
31
|
user,
|
|
30
32
|
workspace,
|
|
33
|
+
appName,
|
|
31
34
|
forceExpanded,
|
|
32
35
|
profileMenuOpen,
|
|
33
36
|
groups: navGroups,
|
|
@@ -56,6 +59,7 @@ const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, logo,
|
|
|
56
59
|
children: [
|
|
57
60
|
/* @__PURE__ */ jsx(TopBar, {
|
|
58
61
|
ref: topBarRef,
|
|
62
|
+
slotRef: setTopBarSlot,
|
|
59
63
|
sticky: pageScroll,
|
|
60
64
|
breadcrumb,
|
|
61
65
|
onNavigate,
|
|
@@ -67,7 +71,10 @@ const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, logo,
|
|
|
67
71
|
}),
|
|
68
72
|
/* @__PURE__ */ jsx("main", {
|
|
69
73
|
className: pageScroll ? "flex flex-1 flex-col" : "flex min-h-0 flex-1 flex-col",
|
|
70
|
-
children
|
|
74
|
+
children: /* @__PURE__ */ jsx(TopBarSlotContext.Provider, {
|
|
75
|
+
value: topBarSlot,
|
|
76
|
+
children
|
|
77
|
+
})
|
|
71
78
|
}),
|
|
72
79
|
tabs && /* @__PURE__ */ jsx(BottomBar, {
|
|
73
80
|
ref: bottomBarRef,
|
package/dist/app-shell/logo.d.ts
CHANGED
|
@@ -3,9 +3,13 @@
|
|
|
3
3
|
export declare const LogoMark: ({ className }: {
|
|
4
4
|
className?: string;
|
|
5
5
|
}) => import("react").JSX.Element;
|
|
6
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* What the logo at the top of the sidebar gets: `compact` when the sidebar shows only icons, the workspace's name, and
|
|
8
|
+
* the app's name, such as `Property Finder` (`PROTOBASE_APP_NAME` or `createAuth`'s `appName`).
|
|
9
|
+
*/
|
|
7
10
|
export type ShellLogoProps = {
|
|
8
11
|
compact: boolean;
|
|
9
12
|
workspace?: string;
|
|
13
|
+
appName?: string;
|
|
10
14
|
};
|
|
11
15
|
//#endregion
|
package/dist/app-shell/logo.js
CHANGED
|
@@ -29,12 +29,19 @@ const Wordmark = ({ className }) => /* @__PURE__ */ jsx("svg", {
|
|
|
29
29
|
d: wordmarkPath
|
|
30
30
|
})
|
|
31
31
|
});
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
const brandName = "Protobase";
|
|
33
|
+
/**
|
|
34
|
+
* The logo at the top of the sidebar: Protobase's mark, then the app's name, as the brand's wordmark when the app goes by
|
|
35
|
+
* Protobase's name, with the workspace's name under it; the mark alone when compact.
|
|
36
|
+
*/
|
|
37
|
+
const Logo = ({ compact, workspace, appName }) => /* @__PURE__ */ jsxs("div", {
|
|
34
38
|
className: "flex items-center gap-2.5 overflow-hidden",
|
|
35
39
|
children: [/* @__PURE__ */ jsx(LogoMark, {}), !compact && /* @__PURE__ */ jsxs("div", {
|
|
36
40
|
className: "flex min-w-0 flex-col gap-0.5 leading-tight",
|
|
37
|
-
children: [
|
|
41
|
+
children: [appName && appName !== brandName ? /* @__PURE__ */ jsx("div", {
|
|
42
|
+
className: "truncate text-sm font-semibold text-foreground",
|
|
43
|
+
children: appName
|
|
44
|
+
}) : /* @__PURE__ */ jsx(Wordmark, { className: "self-start" }), workspace && /* @__PURE__ */ jsx("div", {
|
|
38
45
|
className: "truncate text-[11px] text-muted-foreground",
|
|
39
46
|
children: workspace
|
|
40
47
|
})]
|
|
@@ -10,7 +10,7 @@ import { SidebarGroup } from "./sidebar-group.js";
|
|
|
10
10
|
import { LogIn } from "lucide-react";
|
|
11
11
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
12
12
|
//#region src/app-shell/sidebar.tsx
|
|
13
|
-
const Sidebar = ({ mode, activeItem, user, workspace, forceExpanded, profileMenuOpen, groups = navGroups, userMenu, onNavigate, onSignOut, signIn, organizations, roles, theme, sticky, logo: SidebarLogo = Logo }) => {
|
|
13
|
+
const Sidebar = ({ mode, activeItem, user, workspace, appName, forceExpanded, profileMenuOpen, groups = navGroups, userMenu, onNavigate, onSignOut, signIn, organizations, roles, theme, sticky, logo: SidebarLogo = Logo }) => {
|
|
14
14
|
const expanding = mode === "icon-expand";
|
|
15
15
|
const expanded = expanding && forceExpanded;
|
|
16
16
|
const inline = isTextMode(mode) || expanding;
|
|
@@ -24,7 +24,8 @@ const Sidebar = ({ mode, activeItem, user, workspace, forceExpanded, profileMenu
|
|
|
24
24
|
className: cn("flex h-12 shrink-0 items-center px-3.5", mode === "icon-label" && "justify-center px-0"),
|
|
25
25
|
children: /* @__PURE__ */ jsx(SidebarLogo, {
|
|
26
26
|
compact: !inline,
|
|
27
|
-
workspace
|
|
27
|
+
workspace,
|
|
28
|
+
appName
|
|
28
29
|
})
|
|
29
30
|
}),
|
|
30
31
|
/* @__PURE__ */ jsx("div", {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
//#region src/app-shell/top-bar-slot.ts
|
|
3
|
+
/**
|
|
4
|
+
* The place in the top bar, below `md`, where a page puts what a mobile screen needs at hand, such as a record's actions
|
|
5
|
+
* menu, portalled into it; null until the top bar is drawn, and in a shell without one.
|
|
6
|
+
*/
|
|
7
|
+
const TopBarSlotContext = createContext(null);
|
|
8
|
+
/** The top bar's place for the calling page's own controls on a mobile screen, or null when there is none. */
|
|
9
|
+
const useTopBarSlot = () => useContext(TopBarSlotContext);
|
|
10
|
+
//#endregion
|
|
11
|
+
export { TopBarSlotContext, useTopBarSlot };
|
|
@@ -6,8 +6,9 @@ import { useEffect, useRef, useState } from "react";
|
|
|
6
6
|
import { ChevronLeft, Menu, Search, X } from "lucide-react";
|
|
7
7
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
8
8
|
//#region src/app-shell/top-bar.tsx
|
|
9
|
-
|
|
10
|
-
const
|
|
9
|
+
/** An icon button of the top bar on a mobile screen, as the menu, back and search buttons are. */
|
|
10
|
+
const topBarButton = "inline-flex size-11 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted md:hidden";
|
|
11
|
+
const TopBar = ({ breadcrumb, onNavigate, actions, onMenu, menuOpen, menuControls, search, sticky, slotRef, ref }) => {
|
|
11
12
|
const [searchOpen, setSearchOpen] = useState(false);
|
|
12
13
|
const back = backTarget(breadcrumb);
|
|
13
14
|
const desktopSearch = useRef(null);
|
|
@@ -33,7 +34,7 @@ const TopBar = ({ breadcrumb, onNavigate, actions, onMenu, menuOpen, menuControl
|
|
|
33
34
|
"aria-expanded": Boolean(menuOpen),
|
|
34
35
|
"aria-controls": menuControls,
|
|
35
36
|
onClick: onMenu,
|
|
36
|
-
className: cn(
|
|
37
|
+
className: cn("inline-flex size-11 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted md:hidden", "-ml-3"),
|
|
37
38
|
children: /* @__PURE__ */ jsx(Menu, { className: "size-5" })
|
|
38
39
|
}),
|
|
39
40
|
back && /* @__PURE__ */ jsx("a", {
|
|
@@ -44,7 +45,7 @@ const TopBar = ({ breadcrumb, onNavigate, actions, onMenu, menuOpen, menuControl
|
|
|
44
45
|
event.preventDefault();
|
|
45
46
|
onNavigate(back.href);
|
|
46
47
|
},
|
|
47
|
-
className: cn(
|
|
48
|
+
className: cn("inline-flex size-11 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted md:hidden", !onMenu && "-ml-3"),
|
|
48
49
|
children: /* @__PURE__ */ jsx(ChevronLeft, { className: "size-5" })
|
|
49
50
|
}),
|
|
50
51
|
/* @__PURE__ */ jsx(Breadcrumb, {
|
|
@@ -53,20 +54,27 @@ const TopBar = ({ breadcrumb, onNavigate, actions, onMenu, menuOpen, menuControl
|
|
|
53
54
|
}),
|
|
54
55
|
/* @__PURE__ */ jsxs("div", {
|
|
55
56
|
className: "ml-auto flex items-center gap-1 md:gap-3",
|
|
56
|
-
children: [
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
children: [
|
|
58
|
+
search && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("div", {
|
|
59
|
+
className: "hidden md:block",
|
|
60
|
+
children: /* @__PURE__ */ jsx(GlobalSearch, {
|
|
61
|
+
ref: desktopSearch,
|
|
62
|
+
search
|
|
63
|
+
})
|
|
64
|
+
}), /* @__PURE__ */ jsx("button", {
|
|
65
|
+
type: "button",
|
|
66
|
+
"aria-label": "Search",
|
|
67
|
+
"aria-expanded": searchOpen,
|
|
68
|
+
onClick: () => setSearchOpen(true),
|
|
69
|
+
className: "inline-flex size-11 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted md:hidden",
|
|
70
|
+
children: /* @__PURE__ */ jsx(Search, { className: "size-5" })
|
|
71
|
+
})] }),
|
|
72
|
+
actions,
|
|
73
|
+
/* @__PURE__ */ jsx("div", {
|
|
74
|
+
ref: slotRef,
|
|
75
|
+
className: "contents md:hidden"
|
|
61
76
|
})
|
|
62
|
-
|
|
63
|
-
type: "button",
|
|
64
|
-
"aria-label": "Search",
|
|
65
|
-
"aria-expanded": searchOpen,
|
|
66
|
-
onClick: () => setSearchOpen(true),
|
|
67
|
-
className: iconButton,
|
|
68
|
-
children: /* @__PURE__ */ jsx(Search, { className: "size-5" })
|
|
69
|
-
})] }), actions]
|
|
77
|
+
]
|
|
70
78
|
}),
|
|
71
79
|
search && searchOpen && /* @__PURE__ */ jsxs("div", {
|
|
72
80
|
className: "absolute inset-0 z-20 flex items-center gap-1 bg-background px-3 md:hidden",
|
|
@@ -85,7 +93,7 @@ const TopBar = ({ breadcrumb, onNavigate, actions, onMenu, menuOpen, menuControl
|
|
|
85
93
|
type: "button",
|
|
86
94
|
"aria-label": "Close search",
|
|
87
95
|
onClick: () => setSearchOpen(false),
|
|
88
|
-
className:
|
|
96
|
+
className: "inline-flex size-11 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted md:hidden",
|
|
89
97
|
children: /* @__PURE__ */ jsx(X, { className: "size-5" })
|
|
90
98
|
})]
|
|
91
99
|
})
|
|
@@ -93,4 +101,4 @@ const TopBar = ({ breadcrumb, onNavigate, actions, onMenu, menuOpen, menuControl
|
|
|
93
101
|
});
|
|
94
102
|
};
|
|
95
103
|
//#endregion
|
|
96
|
-
export { TopBar };
|
|
104
|
+
export { TopBar, topBarButton };
|
|
@@ -49,7 +49,8 @@ const providerMessages = {
|
|
|
49
49
|
account_not_linked: (name) => `Your ${name} account is not linked to your account here. Sign in another way, then connect ${name} on the Sign-in & security page.`,
|
|
50
50
|
account_already_linked_to_different_user: (name) => `This ${name} account is linked to someone else here already.`,
|
|
51
51
|
unable_to_link_account: (name) => `Your ${name} account could not be linked. Try again.`,
|
|
52
|
-
access_denied: (name) => `${name} did not let you in, or you cancelled
|
|
52
|
+
access_denied: (name) => `${name} did not let you in, or you cancelled.`,
|
|
53
|
+
EMAIL_DOMAIN_NOT_ALLOWED: (name) => `${name} signed you in with an address this app does not accept from it. Sign in another way.`
|
|
53
54
|
};
|
|
54
55
|
/** Why signing in or linking with the provider `name` did not go through, from the code it came back with. */
|
|
55
56
|
const providerMessage = (code, name) => providerMessages[code]?.(name) ?? `Signing in with ${name} did not go through. Try again.`;
|
|
@@ -18,6 +18,7 @@ const AuthProvider = ({ session: given, children }) => {
|
|
|
18
18
|
const [passkeyDomains, setPasskeyDomains] = useState([]);
|
|
19
19
|
const [socialProviders, setSocialProviders] = useState([]);
|
|
20
20
|
const [platformSignIn, setPlatformSignIn] = useState();
|
|
21
|
+
const [samlProviders, setSamlProviders] = useState([]);
|
|
21
22
|
const [staffSignIn, setStaffSignIn] = useState();
|
|
22
23
|
const [organizations, setOrganizations] = useState();
|
|
23
24
|
const [anonymous, setAnonymous] = useState(false);
|
|
@@ -52,6 +53,7 @@ const AuthProvider = ({ session: given, children }) => {
|
|
|
52
53
|
setPasskeyDomains(status.passkeyDomains ?? []);
|
|
53
54
|
setSocialProviders(status.socialProviders);
|
|
54
55
|
setPlatformSignIn(status.platformSignIn);
|
|
56
|
+
setSamlProviders(status.samlProviders);
|
|
55
57
|
setStaffSignIn(status.staffSignIn);
|
|
56
58
|
setOrganizations(status.organizations);
|
|
57
59
|
setAnonymous(Boolean(status.anonymous));
|
|
@@ -73,10 +75,10 @@ const AuthProvider = ({ session: given, children }) => {
|
|
|
73
75
|
canSignUp,
|
|
74
76
|
passkeyDomains,
|
|
75
77
|
socialProviders,
|
|
76
|
-
signInProviders: socialProviders.map((id) => ({
|
|
78
|
+
signInProviders: [...socialProviders.map((id) => ({
|
|
77
79
|
id,
|
|
78
80
|
name: providerName(id, platformSignIn)
|
|
79
|
-
})),
|
|
81
|
+
})), ...samlProviders],
|
|
80
82
|
staffSignIn,
|
|
81
83
|
...organizations && { organizations },
|
|
82
84
|
markSignedOut,
|
|
@@ -100,7 +102,9 @@ const AuthProvider = ({ session: given, children }) => {
|
|
|
100
102
|
signInWithPasskey: async (domain) => enter(await session.signInWithPasskey(domain)),
|
|
101
103
|
verifyTwoFactor: async (input) => enter(await session.verifyTwoFactor(input)),
|
|
102
104
|
signInSocial: async (provider) => {
|
|
103
|
-
|
|
105
|
+
const callbackURL = new URL(withoutSignInLink(window.location.href), window.location.href).href;
|
|
106
|
+
const saml = samlProviders.some((candidate) => candidate.id === provider);
|
|
107
|
+
window.location.assign(await (saml ? session.signInSaml(provider, callbackURL) : session.signInSocial(provider, callbackURL)));
|
|
104
108
|
},
|
|
105
109
|
linkProvider: async (provider) => {
|
|
106
110
|
window.location.assign(await session.account.linkProvider(provider, new URL(withoutSignInLink(window.location.href), window.location.href).href));
|
|
@@ -122,6 +126,7 @@ const AuthProvider = ({ session: given, children }) => {
|
|
|
122
126
|
passkeyDomains,
|
|
123
127
|
socialProviders,
|
|
124
128
|
platformSignIn,
|
|
129
|
+
samlProviders,
|
|
125
130
|
staffSignIn,
|
|
126
131
|
organizations,
|
|
127
132
|
anonymous,
|
package/dist/data-grid/cells.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { cn } from "../lib/cn.js";
|
|
2
2
|
import { Avatar } from "../primitives/avatar.js";
|
|
3
3
|
import { Badge } from "../primitives/badge.js";
|
|
4
|
+
import { viewerZone, zoneLabel } from "../format/zone.js";
|
|
4
5
|
import { userById } from "../mocks/users.js";
|
|
5
6
|
import { fileShown, shownImage } from "../live/file-values.js";
|
|
6
7
|
import { cellText } from "./cell-text.js";
|
|
7
8
|
import { FileImage } from "../live/file-image.js";
|
|
9
|
+
import { ContactLink } from "../live/contact-link.js";
|
|
8
10
|
import { valueColor } from "../live/value-color.js";
|
|
9
11
|
import { TruncatedText } from "./truncated-text.js";
|
|
10
12
|
import { Check, Paperclip } from "lucide-react";
|
|
@@ -18,7 +20,11 @@ const renderCell = (spec, value) => {
|
|
|
18
20
|
if (value === null || value === void 0) return muted("—");
|
|
19
21
|
const text = cellText(spec, value) ?? "";
|
|
20
22
|
switch (spec.kind) {
|
|
21
|
-
case "text": return /* @__PURE__ */ jsx(
|
|
23
|
+
case "text": return spec.link ? /* @__PURE__ */ jsx(ContactLink, {
|
|
24
|
+
format: spec.link,
|
|
25
|
+
value: text,
|
|
26
|
+
className: "block min-w-0 truncate"
|
|
27
|
+
}) : /* @__PURE__ */ jsx(TruncatedText, { text });
|
|
22
28
|
case "id": return /* @__PURE__ */ jsx(TruncatedText, {
|
|
23
29
|
text,
|
|
24
30
|
className: "font-mono text-xs text-muted-foreground"
|
|
@@ -33,6 +39,7 @@ const renderCell = (spec, value) => {
|
|
|
33
39
|
});
|
|
34
40
|
case "datetime": return /* @__PURE__ */ jsx("span", {
|
|
35
41
|
className: "tabular-nums text-muted-foreground",
|
|
42
|
+
title: `Shown in ${zoneLabel(Number(value), viewerZone())}`,
|
|
36
43
|
children: text
|
|
37
44
|
});
|
|
38
45
|
case "number": return /* @__PURE__ */ jsx("span", {
|