@protobase/ui 0.21.7 → 0.21.9
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 +1 -0
- package/dist/app/charts/chart-view.js +3 -1
- package/dist/app/comments/comment-body.js +13 -0
- package/dist/app/comments/comment-composer.js +149 -0
- package/dist/app/comments/comments-section.js +78 -0
- package/dist/app/comments/mention-text.js +45 -0
- package/dist/app/comments/use-mentionable.js +18 -0
- package/dist/app/comments/use-post-comment.js +15 -0
- package/dist/app/comments/use-record-comments.js +24 -0
- package/dist/app/current-address.d.ts +4 -3
- package/dist/app/current-address.js +1 -0
- package/dist/app/displays/calendar-controls.js +70 -0
- package/dist/app/displays/calendar-day.js +33 -0
- package/dist/app/displays/calendar-display.js +175 -0
- package/dist/app/displays/calendar-dnd.js +27 -0
- package/dist/app/displays/calendar-event.js +73 -0
- package/dist/app/displays/calendar-range.js +129 -0
- package/dist/app/displays/list-displays.js +7 -1
- package/dist/app/displays/use-calendar-move.js +68 -0
- package/dist/app/displays/use-calendar-rows.js +35 -0
- package/dist/app/embedded-list/embedded-list.js +25 -21
- package/dist/app/embedded-list/embedded-row-targets.js +65 -0
- 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/list-page.js +16 -5
- package/dist/app/pages/field-value.js +9 -1
- package/dist/app/pages/record-actions.js +34 -16
- package/dist/app/pages/use-run-action.js +8 -6
- package/dist/app/record-page.js +50 -28
- package/dist/app/related-opens.js +22 -0
- package/dist/app/related-section.js +4 -1
- 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-app-address.d.ts +3 -1
- package/dist/app/use-app-address.js +8 -9
- 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/user-to-shell.js +3 -1
- package/dist/data/api-provider.d.ts +20 -0
- package/dist/data/query-keys.js +15 -0
- 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 +10 -40
- package/dist/format/date.js +52 -6
- 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/primitives/segmented.js +8 -3
- package/dist/primitives/textarea.d.ts +4 -3
- package/dist/primitives/textarea.js +9 -4
- 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-B_L-OYQJ.js +69 -0
- package/dist/shell/assets/export-worker-BoAr3vuF.js +2 -0
- package/dist/shell/assets/{index-CQc-60lQ.js → index-CRNWPM01.js} +1 -1
- package/dist/shell/assets/index-CzqIlZPP.css +2 -0
- package/dist/shell/assets/{maplibre-BvtgTvfx.js → maplibre-DZlOrjmh.js} +1 -1
- package/dist/shell/assets/{markdown-content-CJ8g5iKO.js → markdown-content-DT_18T89.js} +1 -1
- package/dist/shell/assets/{record-map-DSXYVoqB.js → record-map-Bmns3gJf.js} +1 -1
- package/dist/shell/assets/{records-map-cU_FAK7w.js → records-map-Ci3qFCDs.js} +1 -1
- package/dist/shell/assets/{shared-protobase-ui-Bc0CoTXD.js → shared-protobase-ui-DPgVYrS4.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-BbVdHzEY.js +0 -69
- package/dist/shell/assets/export-worker-CVd5saLq.js +0 -2
- package/dist/shell/assets/index-C1fzTv_X.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";
|
|
@@ -49,8 +51,10 @@ import { useLines } from "./use-lines.js";
|
|
|
49
51
|
import { useLinesDraft } from "./use-lines-draft.js";
|
|
50
52
|
import { GallerySection } from "./gallery/gallery-section.js";
|
|
51
53
|
import { HistorySection } from "./history/history-section.js";
|
|
54
|
+
import { CommentsSection } from "./comments/comments-section.js";
|
|
52
55
|
import { useRecordEditor } from "./use-record-editor.js";
|
|
53
56
|
import { useRelatedRecords } from "./use-related-records.js";
|
|
57
|
+
import { IncomingCard } from "./incoming-card.js";
|
|
54
58
|
import { RelatedSection } from "./related-section.js";
|
|
55
59
|
import { Fragment, useMemo } from "react";
|
|
56
60
|
import { Check } from "lucide-react";
|
|
@@ -62,21 +66,28 @@ import { enumLabel } from "@protobase/schema";
|
|
|
62
66
|
const hidden = (model, name) => name === model.tenant || model.primaryKey.includes(name);
|
|
63
67
|
/**
|
|
64
68
|
* The view's layout, or one section with the editable fields when the view has none, then History when the resource
|
|
65
|
-
* keeps it. The read view, which is all a user who may only read the resource gets,
|
|
66
|
-
* than what can be changed.
|
|
69
|
+
* keeps it and Comments when it takes them. The read view, which is all a user who may only read the resource gets,
|
|
70
|
+
* shows every field: the record rather than what can be changed.
|
|
67
71
|
*/
|
|
68
72
|
const layoutFor = (model, view, read) => {
|
|
69
73
|
if (view && view.layout.length > 0) return view.layout;
|
|
70
74
|
const shown = Object.values(model.fields).filter((field) => !hidden(model, field.name));
|
|
71
75
|
const editable = shown.filter((field) => !field.readOnly);
|
|
72
|
-
return [
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
76
|
+
return [
|
|
77
|
+
{
|
|
78
|
+
kind: "section",
|
|
79
|
+
title: "Details",
|
|
80
|
+
fields: (editable.length > 0 && !read ? editable : shown).map((field) => field.name)
|
|
81
|
+
},
|
|
82
|
+
...model.history ? [{
|
|
83
|
+
kind: "history",
|
|
84
|
+
title: "History"
|
|
85
|
+
}] : [],
|
|
86
|
+
...model.comments ? [{
|
|
87
|
+
kind: "comments",
|
|
88
|
+
title: "Comments"
|
|
89
|
+
}] : []
|
|
90
|
+
];
|
|
80
91
|
};
|
|
81
92
|
const RecordPage = ({ resource, recordKey }) => {
|
|
82
93
|
const { resources } = useAdminMeta();
|
|
@@ -113,8 +124,7 @@ const RecordPage = ({ resource, recordKey }) => {
|
|
|
113
124
|
};
|
|
114
125
|
const LoadedRecord = ({ model, recordKey, stored, preview }) => {
|
|
115
126
|
const meta = useAdminMeta();
|
|
116
|
-
const {
|
|
117
|
-
const { address, go, open } = useAppAddress();
|
|
127
|
+
const { address, go } = useAppAddress();
|
|
118
128
|
const view = meta.views[model.name];
|
|
119
129
|
const title = recordTitle(model, view, stored.record, recordKey);
|
|
120
130
|
const { state: auth } = useAuth();
|
|
@@ -160,7 +170,7 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
|
|
|
160
170
|
}, { replace: true }),
|
|
161
171
|
onReview: () => void queryClient.invalidateQueries({ queryKey: keys.record(model.name, recordKey) })
|
|
162
172
|
});
|
|
163
|
-
const related = useRelatedRecords(model,
|
|
173
|
+
const related = useRelatedRecords(model, meta.resources, meta.views, stored.record);
|
|
164
174
|
const pickTarget = (name) => {
|
|
165
175
|
const field = model.fields[name];
|
|
166
176
|
const target = field?.relation ? meta.resources[field.relation.resource] : void 0;
|
|
@@ -172,7 +182,7 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
|
|
|
172
182
|
...stored.record,
|
|
173
183
|
...Object.fromEntries(picked.map((field) => [field.name, fromDraft(field, editor.value(field.name))]))
|
|
174
184
|
};
|
|
175
|
-
const chosen = useRelatedRecords(model,
|
|
185
|
+
const chosen = useRelatedRecords(model, meta.resources, meta.views, drafted);
|
|
176
186
|
const pick = (name, next) => {
|
|
177
187
|
const target = pickTarget(name);
|
|
178
188
|
if (!target || !isChosen(next)) return editor.change(name, next);
|
|
@@ -271,11 +281,16 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
|
|
|
271
281
|
const text = displayValue(field, stored.record[name], hint(name));
|
|
272
282
|
const value = stored.record[name];
|
|
273
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;
|
|
274
285
|
const builtIn = field.type === "enum" ? /* @__PURE__ */ jsx(ValueBadge, {
|
|
275
286
|
value,
|
|
276
287
|
hints: hint(name),
|
|
277
288
|
children: text
|
|
278
|
-
}) : 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),
|
|
279
294
|
className: hint(name)?.multiline ? "whitespace-pre-line break-words" : void 0,
|
|
280
295
|
children: text
|
|
281
296
|
});
|
|
@@ -341,7 +356,6 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
|
|
|
341
356
|
children: [
|
|
342
357
|
/* @__PURE__ */ jsx(RecordHeader, {
|
|
343
358
|
title,
|
|
344
|
-
titleHidden: true,
|
|
345
359
|
status: status ? {
|
|
346
360
|
label: enumLabel(status, hint("status")?.valueLabels),
|
|
347
361
|
tone: toneFor(status)
|
|
@@ -396,7 +410,7 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
|
|
|
396
410
|
/* @__PURE__ */ jsxs("div", {
|
|
397
411
|
className: "grid grid-cols-1 gap-8 md:grid-cols-[minmax(0,1fr)_300px] md:gap-12",
|
|
398
412
|
children: [/* @__PURE__ */ jsxs("main", {
|
|
399
|
-
className:
|
|
413
|
+
className: "divide-y divide-border",
|
|
400
414
|
children: [layout.flatMap((item, index) => {
|
|
401
415
|
if (item.kind === "related") return [/* @__PURE__ */ jsx(RelatedSection, {
|
|
402
416
|
item,
|
|
@@ -424,6 +438,12 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
|
|
|
424
438
|
recordKey,
|
|
425
439
|
etag: stored.etag
|
|
426
440
|
}, `history:${index}`)];
|
|
441
|
+
if (item.kind === "comments") return [/* @__PURE__ */ jsx(CommentsSection, {
|
|
442
|
+
item,
|
|
443
|
+
resource: model.name,
|
|
444
|
+
recordKey,
|
|
445
|
+
canWrite: auth.kind !== "visitor"
|
|
446
|
+
}, `comments:${index}`)];
|
|
427
447
|
if (item.kind !== "section") return [];
|
|
428
448
|
const visibleFields = item.fields.filter((name) => model.fields[name] && visible(name));
|
|
429
449
|
const facts = shown === "read" && item.presentation === "facts";
|
|
@@ -449,7 +469,7 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
|
|
|
449
469
|
locked: shown !== "form"
|
|
450
470
|
})]
|
|
451
471
|
}), /* @__PURE__ */ jsxs("aside", {
|
|
452
|
-
className:
|
|
472
|
+
className: "space-y-4 md:pt-8",
|
|
453
473
|
children: [
|
|
454
474
|
formControls && /* @__PURE__ */ jsx("div", {
|
|
455
475
|
className: "hidden flex-col items-center gap-2 md:flex",
|
|
@@ -481,16 +501,18 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
|
|
|
481
501
|
if (!name || !field || !visible(name)) return [];
|
|
482
502
|
return [{
|
|
483
503
|
label: hint(name)?.label ?? humanize(name),
|
|
484
|
-
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" })
|
|
485
508
|
}];
|
|
486
509
|
})
|
|
487
510
|
}),
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
})
|
|
511
|
+
!preview && /* @__PURE__ */ jsx(IncomingCard, {
|
|
512
|
+
model,
|
|
513
|
+
recordKey,
|
|
514
|
+
layout,
|
|
515
|
+
lines: linesModel
|
|
494
516
|
})
|
|
495
517
|
]
|
|
496
518
|
})]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
//#region src/app/related-opens.ts
|
|
2
|
+
const opensTo = (model, name, resources) => {
|
|
3
|
+
const field = Object.hasOwn(model.fields, name) ? model.fields[name] : void 0;
|
|
4
|
+
return field?.type === "relation" && field.relation !== void 0 && resources[field.relation.resource]?.primaryKey.length === 1;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* The relation field whose record a related section's row opens: `item.open`, else the one relation in the listed
|
|
8
|
+
* resource's primary key besides `item.field`, when there is exactly one; none when the rows are records in their own right.
|
|
9
|
+
*/
|
|
10
|
+
const relatedOpens = (item, model, resources) => {
|
|
11
|
+
if (item.open !== void 0) return item.open !== item.field && opensTo(model, item.open, resources) ? item.open : void 0;
|
|
12
|
+
const candidates = model.primaryKey.filter((name) => name !== item.field && name !== model.tenant && opensTo(model, name, resources));
|
|
13
|
+
return candidates.length === 1 ? candidates[0] : void 0;
|
|
14
|
+
};
|
|
15
|
+
/** The key of the record a row's relation `name` points at; a list row holds a shown relation as `{ id, name }`. Undefined when empty. */
|
|
16
|
+
const relationTarget = (row, name) => {
|
|
17
|
+
const value = row[name];
|
|
18
|
+
const id = typeof value === "object" && value !== null && "id" in value ? value.id : value;
|
|
19
|
+
return typeof id === "string" || typeof id === "number" ? id : void 0;
|
|
20
|
+
};
|
|
21
|
+
//#endregion
|
|
22
|
+
export { relatedOpens, relationTarget };
|
|
@@ -6,6 +6,7 @@ import { ErrorBanner } from "./error-banner.js";
|
|
|
6
6
|
import { listColumnNames } from "../live/columns-from-model.js";
|
|
7
7
|
import { useAdminMeta, usePermissions } from "./meta-gate.js";
|
|
8
8
|
import { searchAll } from "../live/search-all.js";
|
|
9
|
+
import { relatedOpens } from "./related-opens.js";
|
|
9
10
|
import { EmbeddedDisplaySwitch, EmbeddedList } from "./embedded-list/embedded-list.js";
|
|
10
11
|
import { useEmbeddedDisplay } from "./embedded-list/use-embedded-display.js";
|
|
11
12
|
import { relatedHandOrder } from "./hand-order/hand-order.js";
|
|
@@ -82,6 +83,7 @@ const RelatedSection = ({ item, recordKey, owner }) => {
|
|
|
82
83
|
const permissions = usePermissions(item.resource);
|
|
83
84
|
const [sorting, setSorting] = useState(false);
|
|
84
85
|
if (!model) return null;
|
|
86
|
+
const opens = relatedOpens(item, model, resources);
|
|
85
87
|
const columns = item.columns ?? listColumnNames(model, view).filter((name) => name !== item.field);
|
|
86
88
|
const handOrder = relatedHandOrder(item, model, permissions);
|
|
87
89
|
const displaySwitch = /* @__PURE__ */ jsx(EmbeddedDisplaySwitch, {
|
|
@@ -124,7 +126,8 @@ const RelatedSection = ({ item, recordKey, owner }) => {
|
|
|
124
126
|
columns,
|
|
125
127
|
limit: item.limit ?? 10,
|
|
126
128
|
display,
|
|
127
|
-
empty: item.empty
|
|
129
|
+
empty: item.empty,
|
|
130
|
+
opens
|
|
128
131
|
}, match.filter)
|
|
129
132
|
});
|
|
130
133
|
};
|
|
@@ -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,
|
|
@@ -3,13 +3,15 @@ import { AnchorHTMLAttributes } from "react";
|
|
|
3
3
|
import { AppAddress } from "@protobase/schema";
|
|
4
4
|
//#region src/app/use-app-address.d.ts
|
|
5
5
|
/**
|
|
6
|
-
* The app's address as logical values, and the ways to change it: `set` changes the list in place, `
|
|
6
|
+
* The app's address as logical values, and the ways to change it: `set` changes the list in place, `listTo` is the
|
|
7
|
+
* list after a change for a link to it, `go` moves to
|
|
7
8
|
* another address, `href` is an address for an `<a href>` and `open` follows such an href. Every component reads and
|
|
8
9
|
* writes the address through this hook, never the query string or a path of its own.
|
|
9
10
|
*/
|
|
10
11
|
export declare const useAppAddress: () => {
|
|
11
12
|
address: CurrentAddress | undefined;
|
|
12
13
|
set: (change: ListChange) => void;
|
|
14
|
+
listTo: (change: ListChange) => AppAddress;
|
|
13
15
|
go: (to: AppAddress, options?: {
|
|
14
16
|
replace?: boolean;
|
|
15
17
|
}) => void;
|
|
@@ -7,7 +7,8 @@ import { jsx } from "react/jsx-runtime";
|
|
|
7
7
|
import { appPath, readAppPath } from "@protobase/schema";
|
|
8
8
|
//#region src/app/use-app-address.tsx
|
|
9
9
|
/**
|
|
10
|
-
* The app's address as logical values, and the ways to change it: `set` changes the list in place, `
|
|
10
|
+
* The app's address as logical values, and the ways to change it: `set` changes the list in place, `listTo` is the
|
|
11
|
+
* list after a change for a link to it, `go` moves to
|
|
11
12
|
* another address, `href` is an address for an `<a href>` and `open` follows such an href. Every component reads and
|
|
12
13
|
* writes the address through this hook, never the query string or a path of its own.
|
|
13
14
|
*/
|
|
@@ -32,16 +33,14 @@ const useAppAddress = () => {
|
|
|
32
33
|
]);
|
|
33
34
|
const address = useMemo(() => currentAddress(read, defaults), [read, defaults]);
|
|
34
35
|
const go = useCallback((to, options) => navigate(appPath(to), options), [navigate]);
|
|
36
|
+
const listTo = useCallback((change) => {
|
|
37
|
+
if (read?.kind !== "list") throw new Error("listTo and set change the list the app shows; use go to move to another address");
|
|
38
|
+
return changedList(read, change, defaults(read.resource));
|
|
39
|
+
}, [read, defaults]);
|
|
35
40
|
return {
|
|
36
41
|
address,
|
|
37
|
-
set: useCallback((change) => {
|
|
38
|
-
|
|
39
|
-
go(changedList(read, change, defaults(read.resource)), { replace: true });
|
|
40
|
-
}, [
|
|
41
|
-
read,
|
|
42
|
-
defaults,
|
|
43
|
-
go
|
|
44
|
-
]),
|
|
42
|
+
set: useCallback((change) => go(listTo(change), { replace: true }), [listTo, go]),
|
|
43
|
+
listTo,
|
|
45
44
|
go,
|
|
46
45
|
href: useCallback((to) => appPath(to, basePath), [basePath]),
|
|
47
46
|
open: useCallback((target) => navigate(target.startsWith(`${basePath}/`) ? target.slice(basePath.length) : target), [basePath, navigate])
|
|
@@ -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 };
|