@protobase/ui 0.0.0-stage → 0.1.0
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/LICENSE +202 -0
- package/dist/activity-chart/activity-chart.js +125 -0
- package/dist/activity-chart/activity-math.js +11 -0
- package/dist/activity-chart/activity-tooltip.js +24 -0
- package/dist/app/action-icon.js +10 -0
- package/dist/app/app.d.ts +24 -0
- package/dist/app/app.js +118 -0
- package/dist/app/batch-failure.js +21 -0
- package/dist/app/breadcrumb-for.js +27 -0
- package/dist/app/create/create-page.js +118 -0
- package/dist/app/create/create-record-form.js +68 -0
- package/dist/app/create/create-values.js +52 -0
- package/dist/app/delete/dialogs.js +109 -0
- package/dist/app/delete/record-diff.js +17 -0
- package/dist/app/delete/use-delete-flow.js +193 -0
- package/dist/app/delete/use-restore.js +9 -0
- package/dist/app/draft-storage.js +18 -0
- package/dist/app/error-banner.js +28 -0
- package/dist/app/field-editor.js +88 -0
- package/dist/app/icons.js +27 -0
- package/dist/app/index.d.ts +2 -0
- package/dist/app/index.html +12 -0
- package/dist/app/list-chart.js +36 -0
- package/dist/app/list-order.js +15 -0
- package/dist/app/list-page.js +213 -0
- package/dist/app/list-search.js +30 -0
- package/dist/app/main.d.ts +1 -0
- package/dist/app/main.js +14 -0
- package/dist/app/menu-icons.js +21 -0
- package/dist/app/meta-gate.js +46 -0
- package/dist/app/nav-from-meta.js +65 -0
- package/dist/app/nav-recent.js +33 -0
- package/dist/app/pages/action-href.js +7 -0
- package/dist/app/pages/blocks/action.js +38 -0
- package/dist/app/pages/blocks/block-props.js +5 -0
- package/dist/app/pages/blocks/card-frame.js +34 -0
- package/dist/app/pages/blocks/card-row.js +55 -0
- package/dist/app/pages/blocks/card.js +19 -0
- package/dist/app/pages/blocks/field.js +45 -0
- package/dist/app/pages/blocks/grid.js +34 -0
- package/dist/app/pages/blocks/link.js +27 -0
- package/dist/app/pages/blocks/modal-form.js +149 -0
- package/dist/app/pages/blocks/page-block.js +20 -0
- package/dist/app/pages/blocks/progress.js +54 -0
- package/dist/app/pages/blocks/record-card.js +56 -0
- package/dist/app/pages/blocks/show.js +43 -0
- package/dist/app/pages/blocks/stat.js +78 -0
- package/dist/app/pages/blocks/table.js +65 -0
- package/dist/app/pages/composed-page.js +19 -0
- package/dist/app/pages/confirm-action.js +20 -0
- package/dist/app/pages/field-value.js +58 -0
- package/dist/app/pages/project-ui.d.ts +25 -0
- package/dist/app/pages/project-ui.js +13 -0
- package/dist/app/pages/record-actions.js +40 -0
- package/dist/app/pages/record-scope.d.ts +16 -0
- package/dist/app/pages/record-scope.js +12 -0
- package/dist/app/pages/record-table.js +110 -0
- package/dist/app/pages/render-node.js +84 -0
- package/dist/app/pages/use-layout-data.js +73 -0
- package/dist/app/pages/use-path-value.js +65 -0
- package/dist/app/pages/use-relation-labels.js +30 -0
- package/dist/app/pages/use-run-action.js +88 -0
- package/dist/app/permissions.js +15 -0
- package/dist/app/record-lines.js +39 -0
- package/dist/app/record-page.js +318 -0
- package/dist/app/related-section.js +126 -0
- package/dist/app/relation-picker.js +69 -0
- package/dist/app/router.js +155 -0
- package/dist/app/search/search-model.js +70 -0
- package/dist/app/search/use-global-search.js +68 -0
- package/dist/app/sensitive-field.js +65 -0
- package/dist/app/use-lines-draft.js +45 -0
- package/dist/app/use-lines.js +19 -0
- package/dist/app/use-live-save.js +54 -0
- package/dist/app/use-nav-recent.js +41 -0
- package/dist/app/use-record-editor.js +154 -0
- package/dist/app/use-related-records.js +31 -0
- package/dist/app/user-menu-from-meta.js +36 -0
- package/dist/app-shell/app-shell.d.ts +4 -0
- package/dist/app-shell/app-shell.js +57 -0
- package/dist/app-shell/breadcrumb.js +37 -0
- package/dist/app-shell/drawer.js +30 -0
- package/dist/app-shell/global-search.d.ts +1 -0
- package/dist/app-shell/global-search.js +145 -0
- package/dist/app-shell/index.d.ts +9 -0
- package/dist/app-shell/logo.js +46 -0
- package/dist/app-shell/nav-open-storage.js +10 -0
- package/dist/app-shell/nav.d.ts +2 -0
- package/dist/app-shell/nav.js +85 -0
- package/dist/app-shell/page-header.d.ts +1 -0
- package/dist/app-shell/page-header.js +17 -0
- package/dist/app-shell/profile-menu.d.ts +2 -0
- package/dist/app-shell/profile-menu.js +115 -0
- package/dist/app-shell/sidebar-group.js +94 -0
- package/dist/app-shell/sidebar-item.js +46 -0
- package/dist/app-shell/sidebar-mode.d.ts +4 -0
- package/dist/app-shell/sidebar-mode.js +13 -0
- package/dist/app-shell/sidebar.d.ts +2 -0
- package/dist/app-shell/sidebar.js +64 -0
- package/dist/app-shell/status-dot.d.ts +1 -0
- package/dist/app-shell/status-dot.js +20 -0
- package/dist/app-shell/top-bar.js +82 -0
- package/dist/auth/auth-gate.js +70 -0
- package/dist/auth/auth-layout.js +29 -0
- package/dist/auth/auth-provider.js +64 -0
- package/dist/auth/first-run-page.js +51 -0
- package/dist/auth/forgot-password-form.js +75 -0
- package/dist/auth/forgot-password-page.js +35 -0
- package/dist/auth/reset-link.js +29 -0
- package/dist/auth/reset-password-form.js +88 -0
- package/dist/auth/reset-password-page.js +43 -0
- package/dist/auth/sign-in-form.js +74 -0
- package/dist/auth/sign-in-page.js +38 -0
- package/dist/auth/user-to-shell.js +18 -0
- package/dist/data/api-provider.d.ts +54 -0
- package/dist/data/api-provider.js +53 -0
- package/dist/data/meta-data.d.ts +14 -0
- package/dist/data/meta-data.js +9 -0
- package/dist/data/query-keys.js +95 -0
- package/dist/data/use-aggregates.d.ts +8 -0
- package/dist/data/use-aggregates.js +43 -0
- package/dist/data/use-list.d.ts +231 -0
- package/dist/data/use-list.js +31 -0
- package/dist/data/use-meta.d.ts +8 -0
- package/dist/data/use-meta.js +35 -0
- package/dist/data/use-record.d.ts +6 -0
- package/dist/data/use-record.js +16 -0
- package/dist/data/use-update-record.d.ts +15 -0
- package/dist/data/use-update-record.js +26 -0
- package/dist/data-grid/cells.js +85 -0
- package/dist/data-grid/column-spec.js +4 -0
- package/dist/data-grid/columns.js +16 -0
- package/dist/data-grid/data-grid.js +238 -0
- package/dist/data-grid/geometry.js +32 -0
- package/dist/data-grid/grid-footer.js +33 -0
- package/dist/data-grid/grid-header.js +69 -0
- package/dist/data-grid/grid-row.js +83 -0
- package/dist/data-grid/grid-template.js +12 -0
- package/dist/data-grid/header-menu.js +52 -0
- package/dist/data-grid/page-cache.js +27 -0
- package/dist/data-grid/pinned-storage.js +13 -0
- package/dist/data-grid/row-menu.js +88 -0
- package/dist/data-grid/use-grid-scroll.js +80 -0
- package/dist/data-grid/use-horizontal-scroll.js +38 -0
- package/dist/data-grid/use-paged-rows.js +50 -0
- package/dist/data-grid/use-pinned-columns.js +16 -0
- package/dist/filter-panel/active-filter-chips.js +34 -0
- package/dist/filter-panel/date-presets.js +20 -0
- package/dist/filter-panel/facet-group.js +48 -0
- package/dist/filter-panel/filter-config.js +18 -0
- package/dist/filter-panel/filter-menu.js +41 -0
- package/dist/filter-panel/filter-panel.js +112 -0
- package/dist/filter-panel/filter-section.js +11 -0
- package/dist/filter-panel/filter-state.js +63 -0
- package/dist/filter-panel/range-filter.js +54 -0
- package/dist/filter-panel/range-math.js +11 -0
- package/dist/filter-panel/range-slider.css +32 -0
- package/dist/filter-panel/toggle-row.js +13 -0
- package/dist/filter-panel/use-filter-state.js +15 -0
- package/dist/format/date.js +37 -0
- package/dist/format/money.js +15 -0
- package/dist/format/number.js +12 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +10 -0
- package/dist/inline-child-table/columns.js +4 -0
- package/dist/inline-child-table/inline-child-table.js +120 -0
- package/dist/inline-child-table/line-menu.js +54 -0
- package/dist/inline-child-table/line-row.js +91 -0
- package/dist/inline-child-table/number-cell.js +25 -0
- package/dist/inline-child-table/reorder.js +10 -0
- package/dist/inline-child-table/totals-footer.js +42 -0
- package/dist/lib/cn.js +4 -0
- package/dist/lib/guarded.js +12 -0
- package/dist/lib/use-debounced.js +13 -0
- package/dist/lib/use-focus-trap.js +46 -0
- package/dist/lib/use-media-query.js +12 -0
- package/dist/live/columns-from-model.js +93 -0
- package/dist/live/display-value.js +24 -0
- package/dist/live/field-values.js +32 -0
- package/dist/live/filter-string.js +135 -0
- package/dist/live/grid-loader.js +63 -0
- package/dist/live/model-helpers.js +30 -0
- package/dist/live/naming.js +9 -0
- package/dist/live/relation-labels.js +53 -0
- package/dist/live/use-filter-config.js +88 -0
- package/dist/live/use-live-rows.js +45 -0
- package/dist/live/view-fields.js +35 -0
- package/dist/mocks/activity.js +24 -0
- package/dist/mocks/invoices.js +13 -0
- package/dist/mocks/products.js +167 -0
- package/dist/mocks/rng.js +20 -0
- package/dist/mocks/users.js +63 -0
- package/dist/primitives/avatar.js +22 -0
- package/dist/primitives/badge.d.ts +1 -0
- package/dist/primitives/badge.js +25 -0
- package/dist/primitives/button.js +23 -0
- package/dist/primitives/checkbox.js +21 -0
- package/dist/primitives/dialog.js +41 -0
- package/dist/primitives/input.js +16 -0
- package/dist/primitives/kbd.js +8 -0
- package/dist/primitives/segmented.js +14 -0
- package/dist/primitives/sheet.js +44 -0
- package/dist/primitives/skeleton.js +6 -0
- package/dist/primitives/spinner.js +10 -0
- package/dist/primitives/switch.js +14 -0
- package/dist/primitives/tooltip.js +13 -0
- package/dist/record-view/action-rail.js +28 -0
- package/dist/record-view/field.js +62 -0
- package/dist/record-view/record-header.js +50 -0
- package/dist/record-view/record-sidebar.js +58 -0
- package/dist/record-view/section.js +21 -0
- package/dist/styles.css +132 -0
- package/dist/toasts/toast-provider.js +69 -0
- package/dist/toasts/toast.js +45 -0
- package/package.json +45 -3
- package/README.md +0 -3
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { ErrorBanner } from "../../error-banner.js";
|
|
2
|
+
import { useAdminMeta } from "../../meta-gate.js";
|
|
3
|
+
import { Skeleton } from "../../../primitives/skeleton.js";
|
|
4
|
+
import { useListPage } from "../use-layout-data.js";
|
|
5
|
+
import { propsOf } from "./block-props.js";
|
|
6
|
+
import { CardFrame, Quiet } from "./card-frame.js";
|
|
7
|
+
import { RecordTable } from "../record-table.js";
|
|
8
|
+
import { useState } from "react";
|
|
9
|
+
import { jsx } from "react/jsx-runtime";
|
|
10
|
+
//#region src/app/pages/blocks/table.tsx
|
|
11
|
+
/** The view's list columns, else the first five fields that are not keys or the tenant. */
|
|
12
|
+
const defaultColumns = (model, view) => view?.list?.columns ?? Object.keys(model.fields).filter((name) => !model.primaryKey.includes(name) && name !== model.tenant).slice(0, 5);
|
|
13
|
+
const LoadedTable = ({ model, view, filter, sort, columns, pageSize = 10, title, description, empty, actions }) => {
|
|
14
|
+
const shown = (columns ?? defaultColumns(model, view)).filter((name) => Object.hasOwn(model.fields, name));
|
|
15
|
+
const [tokens, setTokens] = useState([""]);
|
|
16
|
+
const page = useListPage(model, {
|
|
17
|
+
filter,
|
|
18
|
+
sort,
|
|
19
|
+
pageSize,
|
|
20
|
+
pageToken: tokens.at(-1)
|
|
21
|
+
});
|
|
22
|
+
const rows = page.data?.items ?? [];
|
|
23
|
+
const next = page.data?.nextPageToken;
|
|
24
|
+
return /* @__PURE__ */ jsx(CardFrame, {
|
|
25
|
+
title,
|
|
26
|
+
description,
|
|
27
|
+
actions,
|
|
28
|
+
className: "h-full",
|
|
29
|
+
children: page.isPending ? /* @__PURE__ */ jsx("div", {
|
|
30
|
+
className: "flex flex-col gap-2",
|
|
31
|
+
children: Array.from({ length: 3 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-full" }, index))
|
|
32
|
+
}) : page.error ? /* @__PURE__ */ jsx(ErrorBanner, {
|
|
33
|
+
title: "Could not load",
|
|
34
|
+
error: page.error
|
|
35
|
+
}) : rows.length === 0 && tokens.length === 1 ? /* @__PURE__ */ jsx(Quiet, { children: empty ?? "Nothing here yet." }) : /* @__PURE__ */ jsx(RecordTable, {
|
|
36
|
+
model,
|
|
37
|
+
columns: shown,
|
|
38
|
+
rows,
|
|
39
|
+
label: title ?? model.name,
|
|
40
|
+
pager: {
|
|
41
|
+
page: tokens.length,
|
|
42
|
+
hasPrevious: tokens.length > 1,
|
|
43
|
+
hasNext: Boolean(next),
|
|
44
|
+
busy: page.isFetching,
|
|
45
|
+
previous: () => setTokens((stack) => stack.slice(0, -1)),
|
|
46
|
+
next: () => next && setTokens((stack) => [...stack, next])
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
});
|
|
50
|
+
};
|
|
51
|
+
/** A page of records at a time, in `sort` order; the first column opens the record. */
|
|
52
|
+
const TableBlock = ({ node, slots }) => {
|
|
53
|
+
const props = propsOf(node);
|
|
54
|
+
const { resources, views } = useAdminMeta();
|
|
55
|
+
const model = resources[props.resource];
|
|
56
|
+
if (!model) return null;
|
|
57
|
+
return /* @__PURE__ */ jsx(LoadedTable, {
|
|
58
|
+
...props,
|
|
59
|
+
model,
|
|
60
|
+
view: views[model.name],
|
|
61
|
+
actions: slots.actions
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
//#endregion
|
|
65
|
+
export { TableBlock, defaultColumns };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Notice, useAdminMeta } from "../meta-gate.js";
|
|
2
|
+
import { RenderNode } from "./render-node.js";
|
|
3
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
4
|
+
//#region src/app/pages/composed-page.tsx
|
|
5
|
+
/** `/<page>`: the page's tree as `/meta` gave it to this user. */
|
|
6
|
+
const ComposedPage = ({ name }) => {
|
|
7
|
+
const page = useAdminMeta().pages[name];
|
|
8
|
+
if (!page) return /* @__PURE__ */ jsxs(Notice, {
|
|
9
|
+
title: "Not found",
|
|
10
|
+
children: [
|
|
11
|
+
"There is no page called ",
|
|
12
|
+
name,
|
|
13
|
+
"."
|
|
14
|
+
]
|
|
15
|
+
});
|
|
16
|
+
return /* @__PURE__ */ jsx(RenderNode, { node: page.tree });
|
|
17
|
+
};
|
|
18
|
+
//#endregion
|
|
19
|
+
export { ComposedPage };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Button } from "../../primitives/button.js";
|
|
2
|
+
import { Dialog } from "../../primitives/dialog.js";
|
|
3
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
4
|
+
//#region src/app/pages/confirm-action.tsx
|
|
5
|
+
/** The question an action with `confirm` asks before it runs. */
|
|
6
|
+
const ConfirmAction = ({ action, label, danger, onCancel, onConfirm }) => /* @__PURE__ */ jsx(Dialog, {
|
|
7
|
+
title: `${label}?`,
|
|
8
|
+
description: action.confirm,
|
|
9
|
+
onClose: onCancel,
|
|
10
|
+
actions: /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Button, {
|
|
11
|
+
onClick: onCancel,
|
|
12
|
+
children: "Cancel"
|
|
13
|
+
}), /* @__PURE__ */ jsx(Button, {
|
|
14
|
+
variant: danger ? "danger" : "primary",
|
|
15
|
+
onClick: onConfirm,
|
|
16
|
+
children: label
|
|
17
|
+
})] })
|
|
18
|
+
});
|
|
19
|
+
//#endregion
|
|
20
|
+
export { ConfirmAction };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { cn } from "../../lib/cn.js";
|
|
2
|
+
import { Badge } from "../../primitives/badge.js";
|
|
3
|
+
import { keys } from "../../data/query-keys.js";
|
|
4
|
+
import { useClient } from "../../data/api-provider.js";
|
|
5
|
+
import { Link } from "../router.js";
|
|
6
|
+
import { displayValue } from "../../live/display-value.js";
|
|
7
|
+
import { useAdminMeta } from "../meta-gate.js";
|
|
8
|
+
import { toneFor } from "../../live/columns-from-model.js";
|
|
9
|
+
import { fetchLabels } from "../../live/relation-labels.js";
|
|
10
|
+
import { jsx } from "react/jsx-runtime";
|
|
11
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
12
|
+
//#region src/app/pages/field-value.tsx
|
|
13
|
+
const RelationValue = ({ field, value, known }) => {
|
|
14
|
+
const { resources, views } = useAdminMeta();
|
|
15
|
+
const client = useClient();
|
|
16
|
+
const queryClient = useQueryClient();
|
|
17
|
+
const target = field.relation ? resources[field.relation.resource] : void 0;
|
|
18
|
+
const id = String(value);
|
|
19
|
+
const label = useQuery({
|
|
20
|
+
queryKey: keys.relatedLabel(target?.name ?? "", id),
|
|
21
|
+
enabled: Boolean(target) && known === void 0,
|
|
22
|
+
queryFn: async () => (await fetchLabels(queryClient, client, target, [id], views[target.name])).get(id) ?? id
|
|
23
|
+
});
|
|
24
|
+
if (!target) return /* @__PURE__ */ jsx("span", { children: id });
|
|
25
|
+
return /* @__PURE__ */ jsx(Link, {
|
|
26
|
+
to: `/${target.name}/${encodeURIComponent(id)}`,
|
|
27
|
+
className: "font-medium text-primary-text hover:underline",
|
|
28
|
+
children: known ?? label.data ?? id
|
|
29
|
+
});
|
|
30
|
+
};
|
|
31
|
+
/** A stored value as the record page shows it: the view's hints, badges for statuses, names for relations (`relationLabel` when already known). */
|
|
32
|
+
const FieldValue = ({ model, field, value, format, relationLabel }) => {
|
|
33
|
+
const { views } = useAdminMeta();
|
|
34
|
+
const hints = {
|
|
35
|
+
...views[model.name]?.fields[field.name],
|
|
36
|
+
...format && { format }
|
|
37
|
+
};
|
|
38
|
+
if (value === null || value === void 0 || value === "") return /* @__PURE__ */ jsx("span", {
|
|
39
|
+
className: "text-muted-foreground",
|
|
40
|
+
children: "—"
|
|
41
|
+
});
|
|
42
|
+
if (field.type === "relation") return /* @__PURE__ */ jsx(RelationValue, {
|
|
43
|
+
field,
|
|
44
|
+
value,
|
|
45
|
+
known: relationLabel
|
|
46
|
+
});
|
|
47
|
+
const text = displayValue(field, value, hints);
|
|
48
|
+
if (field.type === "enum" || hints.format === "badge") return /* @__PURE__ */ jsx(Badge, {
|
|
49
|
+
tone: toneFor(String(value)),
|
|
50
|
+
children: text
|
|
51
|
+
});
|
|
52
|
+
return /* @__PURE__ */ jsx("span", {
|
|
53
|
+
className: cn(hints.format === "code" && "font-mono text-xs", (field.type === "decimal" || field.type === "integer") && "tabular-nums"),
|
|
54
|
+
children: text
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
//#endregion
|
|
58
|
+
export { FieldValue };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { ComponentType, ReactNode } from "react";
|
|
2
|
+
import { Client } from "@protobase/client";
|
|
3
|
+
//#region src/app/pages/project-ui.d.ts
|
|
4
|
+
/** What a named action's handler gets: the record it runs on (inside a RecordCard or CardRow), the API and the router. */
|
|
5
|
+
export type ActionContext = {
|
|
6
|
+
resource: string;
|
|
7
|
+
record?: Record<string, unknown>;
|
|
8
|
+
/** The record's key as the URL carries it. */
|
|
9
|
+
recordKey?: string;
|
|
10
|
+
etag?: string;
|
|
11
|
+
client: Client;
|
|
12
|
+
navigate: (to: string) => void;
|
|
13
|
+
};
|
|
14
|
+
export type ActionHandler = (context: ActionContext) => void | Promise<void>;
|
|
15
|
+
/**
|
|
16
|
+
* The project's own React code for composed pages: custom components by the name their layout declares
|
|
17
|
+
* (`component('UsageChart')`), and handlers for named actions that have no built-in behaviour.
|
|
18
|
+
*/
|
|
19
|
+
export type ProjectUi = {
|
|
20
|
+
components?: Record<string, ComponentType<any>>;
|
|
21
|
+
actions?: Record<string, ActionHandler>;
|
|
22
|
+
};
|
|
23
|
+
/** Typed identity, for `export default defineUi({ components, actions })` in `protobase.ui.tsx`. */
|
|
24
|
+
export declare const defineUi: (ui: ProjectUi) => ProjectUi;
|
|
25
|
+
//#endregion
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
|
+
//#region src/app/pages/project-ui.tsx
|
|
4
|
+
/** Typed identity, for `export default defineUi({ components, actions })` in `protobase.ui.tsx`. */
|
|
5
|
+
const defineUi = (ui) => ui;
|
|
6
|
+
const ProjectUiContext = createContext({});
|
|
7
|
+
const ProjectUiProvider = ({ ui, children }) => /* @__PURE__ */ jsx(ProjectUiContext.Provider, {
|
|
8
|
+
value: ui ?? {},
|
|
9
|
+
children
|
|
10
|
+
});
|
|
11
|
+
const useProjectUi = () => useContext(ProjectUiContext);
|
|
12
|
+
//#endregion
|
|
13
|
+
export { ProjectUiProvider, defineUi, useProjectUi };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useRouter } from "../router.js";
|
|
2
|
+
import { ActionRail } from "../../record-view/action-rail.js";
|
|
3
|
+
import { ConfirmAction } from "./confirm-action.js";
|
|
4
|
+
import { useActionRunner } from "./use-run-action.js";
|
|
5
|
+
import { actionIcon } from "../action-icon.js";
|
|
6
|
+
import { useState } from "react";
|
|
7
|
+
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
8
|
+
//#region src/app/pages/record-actions.tsx
|
|
9
|
+
/** The record page's named actions, run like the Action block runs them; deleting the record returns to the list. */
|
|
10
|
+
const RecordActions = ({ model, stored, recordKey }) => {
|
|
11
|
+
const { navigate } = useRouter();
|
|
12
|
+
const scope = {
|
|
13
|
+
model,
|
|
14
|
+
record: stored.record,
|
|
15
|
+
key: recordKey,
|
|
16
|
+
etag: stored.etag,
|
|
17
|
+
permissions: stored.permissions
|
|
18
|
+
};
|
|
19
|
+
const runner = useActionRunner(model.name, scope, (action) => action.run?.kind === "delete" && navigate(`/${model.name}`, { replace: true }));
|
|
20
|
+
const [asking, setAsking] = useState();
|
|
21
|
+
if (runner.actions.length === 0) return null;
|
|
22
|
+
return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(ActionRail, { actions: runner.actions.map((action) => ({
|
|
23
|
+
name: action.label,
|
|
24
|
+
icon: actionIcon(action.icon),
|
|
25
|
+
blocked: runner.blocked(action),
|
|
26
|
+
running: runner.running === action.name,
|
|
27
|
+
onRun: () => action.confirm ? setAsking(action) : void runner.run(action)
|
|
28
|
+
})) }), asking && /* @__PURE__ */ jsx(ConfirmAction, {
|
|
29
|
+
action: asking,
|
|
30
|
+
label: asking.label,
|
|
31
|
+
danger: asking.run?.kind === "delete",
|
|
32
|
+
onCancel: () => setAsking(void 0),
|
|
33
|
+
onConfirm: () => {
|
|
34
|
+
setAsking(void 0);
|
|
35
|
+
runner.run(asking);
|
|
36
|
+
}
|
|
37
|
+
})] });
|
|
38
|
+
};
|
|
39
|
+
//#endregion
|
|
40
|
+
export { RecordActions };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { ReactNode } from "react";
|
|
2
|
+
import { RecordPermissions } from "@protobase/client";
|
|
3
|
+
import { ResourceModel } from "@protobase/schema";
|
|
4
|
+
//#region src/app/pages/record-scope.d.ts
|
|
5
|
+
/** The record a RecordCard, or one card of a CardRow, shows; Fields, Actions and forms inside it work on it. */
|
|
6
|
+
export type LayoutRecord = {
|
|
7
|
+
model: ResourceModel;
|
|
8
|
+
record: Record<string, unknown>;
|
|
9
|
+
/** The key as the URL carries it. */
|
|
10
|
+
key: string;
|
|
11
|
+
etag?: string;
|
|
12
|
+
permissions?: RecordPermissions;
|
|
13
|
+
};
|
|
14
|
+
/** The record around a custom component in a composed page, or `undefined` outside a RecordCard or CardRow. */
|
|
15
|
+
export declare const useLayoutRecord: () => LayoutRecord | undefined;
|
|
16
|
+
//#endregion
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
|
+
//#region src/app/pages/record-scope.tsx
|
|
4
|
+
const RecordScopeContext = createContext(void 0);
|
|
5
|
+
const RecordScope = ({ value, children }) => /* @__PURE__ */ jsx(RecordScopeContext.Provider, {
|
|
6
|
+
value,
|
|
7
|
+
children
|
|
8
|
+
});
|
|
9
|
+
/** The record around a custom component in a composed page, or `undefined` outside a RecordCard or CardRow. */
|
|
10
|
+
const useLayoutRecord = () => useContext(RecordScopeContext);
|
|
11
|
+
//#endregion
|
|
12
|
+
export { RecordScope, useLayoutRecord };
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { Button } from "../../primitives/button.js";
|
|
2
|
+
import { humanize } from "../../live/naming.js";
|
|
3
|
+
import { Link } from "../router.js";
|
|
4
|
+
import { useAdminMeta } from "../meta-gate.js";
|
|
5
|
+
import { scopeOf } from "./use-layout-data.js";
|
|
6
|
+
import { FieldValue } from "./field-value.js";
|
|
7
|
+
import { usePathValue } from "./use-path-value.js";
|
|
8
|
+
import { useRelationLabels } from "./use-relation-labels.js";
|
|
9
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
10
|
+
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
11
|
+
import { resolveFieldPath } from "@protobase/layout";
|
|
12
|
+
//#region src/app/pages/record-table.tsx
|
|
13
|
+
const isPath = (column) => column.includes(".");
|
|
14
|
+
/** The header of a column: the label of the field it ends at, from that field's view. */
|
|
15
|
+
const useHeader = (model) => {
|
|
16
|
+
const { resources, views } = useAdminMeta();
|
|
17
|
+
return (column) => {
|
|
18
|
+
const resolved = resolveFieldPath(resources, model.name, column);
|
|
19
|
+
const last = resolved.ok ? resolved.steps.at(-1) : void 0;
|
|
20
|
+
const name = last?.field.name ?? column;
|
|
21
|
+
return views[last?.model.name ?? model.name]?.fields[name]?.label ?? humanize(name);
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
/** A value through relations (`unitId.propertyId`), fetched like the record page fetches it and cached. */
|
|
25
|
+
const PathCell = ({ model, row, column }) => {
|
|
26
|
+
const value = usePathValue(scopeOf(model, row), column);
|
|
27
|
+
if (value.state === "loading") return /* @__PURE__ */ jsx("span", {
|
|
28
|
+
className: "text-muted-foreground",
|
|
29
|
+
children: "…"
|
|
30
|
+
});
|
|
31
|
+
if (value.state === "missing") return /* @__PURE__ */ jsx("span", {
|
|
32
|
+
className: "text-muted-foreground",
|
|
33
|
+
children: "—"
|
|
34
|
+
});
|
|
35
|
+
return /* @__PURE__ */ jsx(FieldValue, {
|
|
36
|
+
model: value.model,
|
|
37
|
+
field: value.field,
|
|
38
|
+
value: value.value
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Records as table rows; the first column opens the record, unless it is a relation, which links to the record it
|
|
43
|
+
* names instead (a join record's first column is usually what it joins). Columns may follow relations (`unitId.propertyId`).
|
|
44
|
+
*/
|
|
45
|
+
const RecordTable = ({ model, columns, rows, label, pager }) => {
|
|
46
|
+
const { resources } = useAdminMeta();
|
|
47
|
+
const header = useHeader(model);
|
|
48
|
+
const first = columns[0] === void 0 ? void 0 : resolveFieldPath(resources, model.name, columns[0]);
|
|
49
|
+
const opensRecord = !first?.ok || first.steps.at(-1).field.type !== "relation";
|
|
50
|
+
const labels = useRelationLabels(model, columns.filter((column) => !isPath(column)), rows);
|
|
51
|
+
const cell = (row, column) => isPath(column) ? /* @__PURE__ */ jsx(PathCell, {
|
|
52
|
+
model,
|
|
53
|
+
row,
|
|
54
|
+
column
|
|
55
|
+
}) : /* @__PURE__ */ jsx(FieldValue, {
|
|
56
|
+
model,
|
|
57
|
+
field: model.fields[column],
|
|
58
|
+
value: row[column],
|
|
59
|
+
relationLabel: labels.get(column)?.get(String(row[column]))
|
|
60
|
+
});
|
|
61
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("div", {
|
|
62
|
+
className: "-mx-4 overflow-x-auto",
|
|
63
|
+
children: /* @__PURE__ */ jsxs("table", {
|
|
64
|
+
className: "w-full min-w-max text-left text-[13px]",
|
|
65
|
+
children: [/* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsx("tr", {
|
|
66
|
+
className: "border-b text-xs text-muted-foreground",
|
|
67
|
+
children: columns.map((column) => /* @__PURE__ */ jsx("th", {
|
|
68
|
+
scope: "col",
|
|
69
|
+
className: "px-4 py-2 font-medium",
|
|
70
|
+
children: header(column)
|
|
71
|
+
}, column))
|
|
72
|
+
}) }), /* @__PURE__ */ jsx("tbody", { children: rows.map((row) => {
|
|
73
|
+
const scope = scopeOf(model, row);
|
|
74
|
+
return /* @__PURE__ */ jsx("tr", {
|
|
75
|
+
className: "border-b last:border-0 hover:bg-surface",
|
|
76
|
+
children: columns.map((column, index) => /* @__PURE__ */ jsx("td", {
|
|
77
|
+
className: "px-4 py-2",
|
|
78
|
+
children: index === 0 && opensRecord ? /* @__PURE__ */ jsx(Link, {
|
|
79
|
+
to: `/${model.name}/${encodeURIComponent(scope.key)}`,
|
|
80
|
+
className: "font-medium text-primary-text hover:underline",
|
|
81
|
+
children: cell(row, column)
|
|
82
|
+
}) : cell(row, column)
|
|
83
|
+
}, column))
|
|
84
|
+
}, scope.key);
|
|
85
|
+
}) })]
|
|
86
|
+
})
|
|
87
|
+
}), pager && (pager.hasPrevious || pager.hasNext) && /* @__PURE__ */ jsxs("nav", {
|
|
88
|
+
"aria-label": `${label} pages`,
|
|
89
|
+
className: "mt-3 flex items-center justify-end gap-2 text-xs text-muted-foreground",
|
|
90
|
+
children: [
|
|
91
|
+
/* @__PURE__ */ jsxs("span", { children: ["Page ", pager.page] }),
|
|
92
|
+
/* @__PURE__ */ jsx(Button, {
|
|
93
|
+
size: "sm",
|
|
94
|
+
"aria-label": "Previous page",
|
|
95
|
+
disabled: !pager.hasPrevious || pager.busy,
|
|
96
|
+
onClick: pager.previous,
|
|
97
|
+
children: /* @__PURE__ */ jsx(ChevronLeft, { className: "size-3.5" })
|
|
98
|
+
}),
|
|
99
|
+
/* @__PURE__ */ jsx(Button, {
|
|
100
|
+
size: "sm",
|
|
101
|
+
"aria-label": "Next page",
|
|
102
|
+
disabled: !pager.hasNext || pager.busy,
|
|
103
|
+
onClick: pager.next,
|
|
104
|
+
children: /* @__PURE__ */ jsx(ChevronRight, { className: "size-3.5" })
|
|
105
|
+
})
|
|
106
|
+
]
|
|
107
|
+
})] });
|
|
108
|
+
};
|
|
109
|
+
//#endregion
|
|
110
|
+
export { RecordTable };
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { useProjectUi } from "./project-ui.js";
|
|
2
|
+
import { ActionBlock } from "./blocks/action.js";
|
|
3
|
+
import { CardBlock } from "./blocks/card.js";
|
|
4
|
+
import { CardRowBlock } from "./blocks/card-row.js";
|
|
5
|
+
import { FieldBlock } from "./blocks/field.js";
|
|
6
|
+
import { GridBlock } from "./blocks/grid.js";
|
|
7
|
+
import { LinkBlock } from "./blocks/link.js";
|
|
8
|
+
import { ModalFormBlock } from "./blocks/modal-form.js";
|
|
9
|
+
import { PageBlock } from "./blocks/page-block.js";
|
|
10
|
+
import { ProgressBlock } from "./blocks/progress.js";
|
|
11
|
+
import { RecordCardBlock } from "./blocks/record-card.js";
|
|
12
|
+
import { ShowBlock } from "./blocks/show.js";
|
|
13
|
+
import { StatBlock } from "./blocks/stat.js";
|
|
14
|
+
import { TableBlock } from "./blocks/table.js";
|
|
15
|
+
import { Fragment } from "react";
|
|
16
|
+
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
17
|
+
import { isLayoutNode } from "@protobase/layout";
|
|
18
|
+
//#region src/app/pages/render-node.tsx
|
|
19
|
+
const blocks = {
|
|
20
|
+
Page: PageBlock,
|
|
21
|
+
Grid: GridBlock,
|
|
22
|
+
Card: CardBlock,
|
|
23
|
+
Stat: StatBlock,
|
|
24
|
+
RecordCard: RecordCardBlock,
|
|
25
|
+
Field: FieldBlock,
|
|
26
|
+
Table: TableBlock,
|
|
27
|
+
CardRow: CardRowBlock,
|
|
28
|
+
Progress: ProgressBlock,
|
|
29
|
+
ModalForm: ModalFormBlock,
|
|
30
|
+
Action: ActionBlock,
|
|
31
|
+
Show: ShowBlock,
|
|
32
|
+
Link: LinkBlock
|
|
33
|
+
};
|
|
34
|
+
const Problem = ({ children }) => /* @__PURE__ */ jsx("div", {
|
|
35
|
+
role: "alert",
|
|
36
|
+
className: "rounded-lg border border-dashed border-warning bg-warning-soft p-3 text-[13px] text-warning-text",
|
|
37
|
+
children
|
|
38
|
+
});
|
|
39
|
+
const isInline = (child) => typeof child === "string" || !child.custom && child.type === "Link";
|
|
40
|
+
/** Text and links run together as one paragraph, like a sentence with a link in it; other elements stand alone. */
|
|
41
|
+
const renderChildren = (children) => {
|
|
42
|
+
const runs = [];
|
|
43
|
+
for (const child of children) {
|
|
44
|
+
const last = runs.at(-1);
|
|
45
|
+
if (isInline(child) && last && isInline(last[0])) last.push(child);
|
|
46
|
+
else runs.push([child]);
|
|
47
|
+
}
|
|
48
|
+
return runs.map((run, index) => {
|
|
49
|
+
const [first] = run;
|
|
50
|
+
if (!isInline(first)) return /* @__PURE__ */ jsx(RenderNode, { node: first }, index);
|
|
51
|
+
return /* @__PURE__ */ jsx("p", {
|
|
52
|
+
className: "text-[13px] leading-relaxed",
|
|
53
|
+
children: run.map((child, position) => typeof child === "string" ? /* @__PURE__ */ jsx(Fragment, { children: child }, position) : /* @__PURE__ */ jsx(RenderNode, { node: child }, position))
|
|
54
|
+
}, index);
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
const isElements = (value) => isLayoutNode(value) || Array.isArray(value) && value.length > 0 && value.every(isLayoutNode);
|
|
58
|
+
/** Element props (`actions={...}`) rendered like children; the block decides where they go and in which record. */
|
|
59
|
+
const renderSlots = (node) => Object.fromEntries(Object.entries(node.props).flatMap(([name, value]) => isElements(value) ? [[name, /* @__PURE__ */ jsx(Fragment$1, { children: renderChildren(Array.isArray(value) ? value : [value]) })]] : []));
|
|
60
|
+
/** One element of a composed page: a built-in block, or the project's component registered under its name. */
|
|
61
|
+
const RenderNode = ({ node }) => {
|
|
62
|
+
const { components } = useProjectUi();
|
|
63
|
+
if (node.custom) {
|
|
64
|
+
const Custom = components?.[node.type];
|
|
65
|
+
if (!Custom) return /* @__PURE__ */ jsxs(Problem, { children: [
|
|
66
|
+
"No component is registered as ",
|
|
67
|
+
node.type,
|
|
68
|
+
". Add it to the components in protobase.ui.tsx."
|
|
69
|
+
] });
|
|
70
|
+
return /* @__PURE__ */ jsx(Custom, {
|
|
71
|
+
...node.props,
|
|
72
|
+
children: node.children.length > 0 ? renderChildren(node.children) : void 0
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
const Block = blocks[node.type];
|
|
76
|
+
if (!Block) return /* @__PURE__ */ jsxs(Problem, { children: [node.type, " is not a block this version of the app knows."] });
|
|
77
|
+
return /* @__PURE__ */ jsx(Block, {
|
|
78
|
+
node,
|
|
79
|
+
slots: renderSlots(node),
|
|
80
|
+
children: renderChildren(node.children)
|
|
81
|
+
});
|
|
82
|
+
};
|
|
83
|
+
//#endregion
|
|
84
|
+
export { RenderNode };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { keys } from "../../data/query-keys.js";
|
|
2
|
+
import { useClient } from "../../data/api-provider.js";
|
|
3
|
+
import { recordKey } from "../../live/model-helpers.js";
|
|
4
|
+
import { useCallback } from "react";
|
|
5
|
+
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
6
|
+
import { encodeKey } from "@protobase/schema";
|
|
7
|
+
//#region src/app/pages/use-layout-data.ts
|
|
8
|
+
/** A list item as a record scope: the key as the URL carries it, and the etag and permissions the item carries. */
|
|
9
|
+
const scopeOf = (model, row) => ({
|
|
10
|
+
model,
|
|
11
|
+
record: row,
|
|
12
|
+
key: encodeKey(recordKey(model, row)),
|
|
13
|
+
etag: typeof row.etag === "string" ? row.etag : void 0,
|
|
14
|
+
permissions: row.permissions
|
|
15
|
+
});
|
|
16
|
+
/** How many records of `model` match `filter`, counted by the server inside the caller's access rules. */
|
|
17
|
+
const countQuery = (client, model, filter = "") => ({
|
|
18
|
+
queryKey: keys.count(model.name, filter),
|
|
19
|
+
queryFn: async () => {
|
|
20
|
+
const page = await client.list(model.name, {
|
|
21
|
+
filter: filter || void 0,
|
|
22
|
+
pageSize: 1,
|
|
23
|
+
count: "exact",
|
|
24
|
+
fields: model.primaryKey
|
|
25
|
+
});
|
|
26
|
+
return page.totalSize ?? page.totalSizeEstimate;
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
const useCount = (model, filter = "") => useQuery(countQuery(useClient(), model, filter));
|
|
30
|
+
/** The record a RecordCard shows: the one `recordKey` names, or the first that matches `filter` in `sort` order. */
|
|
31
|
+
const useFirstRecord = (model, { filter = "", sort = "", key }) => {
|
|
32
|
+
const client = useClient();
|
|
33
|
+
return useQuery({
|
|
34
|
+
queryKey: key === void 0 ? keys.firstRecord(model.name, filter, sort) : keys.record(model.name, key),
|
|
35
|
+
queryFn: async () => {
|
|
36
|
+
if (key !== void 0) {
|
|
37
|
+
const stored = await client.get(model.name, key);
|
|
38
|
+
return {
|
|
39
|
+
...scopeOf(model, stored.record),
|
|
40
|
+
etag: stored.etag,
|
|
41
|
+
permissions: stored.permissions
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const page = await client.list(model.name, {
|
|
45
|
+
filter: filter || void 0,
|
|
46
|
+
orderBy: sort || void 0,
|
|
47
|
+
pageSize: 1
|
|
48
|
+
});
|
|
49
|
+
return page.items[0] ? scopeOf(model, page.items[0]) : null;
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
/** One page of a list, from `pageToken`; the previous page stays on screen while the next loads. */
|
|
54
|
+
const useListPage = (model, { filter = "", sort = "", pageSize, pageToken = "" }) => {
|
|
55
|
+
const client = useClient();
|
|
56
|
+
return useQuery({
|
|
57
|
+
queryKey: keys.list(model.name, filter, sort, pageSize, pageToken),
|
|
58
|
+
placeholderData: keepPreviousData,
|
|
59
|
+
queryFn: () => client.list(model.name, {
|
|
60
|
+
filter: filter || void 0,
|
|
61
|
+
orderBy: sort || void 0,
|
|
62
|
+
pageSize,
|
|
63
|
+
pageToken: pageToken || void 0
|
|
64
|
+
})
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
/** Refetches everything shown of a resource after a write: lists, counts, records and labels. */
|
|
68
|
+
const useRefreshResource = () => {
|
|
69
|
+
const queryClient = useQueryClient();
|
|
70
|
+
return useCallback((resource) => queryClient.invalidateQueries({ predicate: (query) => query.queryKey[1] === resource }), [queryClient]);
|
|
71
|
+
};
|
|
72
|
+
//#endregion
|
|
73
|
+
export { countQuery, scopeOf, useCount, useFirstRecord, useListPage, useRefreshResource };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { keys } from "../../data/query-keys.js";
|
|
2
|
+
import { useClient } from "../../data/api-provider.js";
|
|
3
|
+
import { useAdminMeta } from "../meta-gate.js";
|
|
4
|
+
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
5
|
+
import { resolveFieldPath } from "@protobase/layout";
|
|
6
|
+
//#region src/app/pages/use-path-value.ts
|
|
7
|
+
/** The steps of `path` from the record around it, or none when it names nothing the caller can read. */
|
|
8
|
+
const stepsOf = (resources, scope, path) => {
|
|
9
|
+
if (!scope) return [];
|
|
10
|
+
const resolved = resolveFieldPath(resources, scope.model.name, path);
|
|
11
|
+
return resolved.ok ? resolved.steps : [];
|
|
12
|
+
};
|
|
13
|
+
/** The query that follows the relations of `steps` from `scope`, fetching each record it passes like a record page. */
|
|
14
|
+
const pathQuery = (client, queryClient, scope, path, steps) => ({
|
|
15
|
+
queryKey: keys.pathValue(scope?.model.name ?? "", scope?.key ?? "", path),
|
|
16
|
+
enabled: steps.length > 1,
|
|
17
|
+
queryFn: async () => {
|
|
18
|
+
let record = scope.record;
|
|
19
|
+
for (const step of steps.slice(0, -1)) {
|
|
20
|
+
const id = record[step.field.name];
|
|
21
|
+
if (id === null || id === void 0) return null;
|
|
22
|
+
const target = step.field.relation.resource;
|
|
23
|
+
record = (await queryClient.fetchQuery({
|
|
24
|
+
queryKey: keys.record(target, String(id)),
|
|
25
|
+
queryFn: () => client.get(target, String(id))
|
|
26
|
+
})).record;
|
|
27
|
+
}
|
|
28
|
+
return record[steps.at(-1).field.name] ?? null;
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
const valueOf = (scope, steps, query) => {
|
|
32
|
+
const last = steps.at(-1);
|
|
33
|
+
if (!scope || !last) return { state: "missing" };
|
|
34
|
+
if (steps.length === 1) return {
|
|
35
|
+
state: "ready",
|
|
36
|
+
value: scope.record[last.field.name],
|
|
37
|
+
field: last.field,
|
|
38
|
+
model: last.model
|
|
39
|
+
};
|
|
40
|
+
if (query.isPending) return { state: "loading" };
|
|
41
|
+
return {
|
|
42
|
+
state: "ready",
|
|
43
|
+
value: query.data ?? null,
|
|
44
|
+
field: last.field,
|
|
45
|
+
model: last.model
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
/** The value at `path` from the record around it: `status` directly, `plan.name` through the plan it points at. */
|
|
49
|
+
const usePathValue = (scope, path) => {
|
|
50
|
+
const { resources } = useAdminMeta();
|
|
51
|
+
const steps = stepsOf(resources, scope, path);
|
|
52
|
+
const query = useQuery(pathQuery(useClient(), useQueryClient(), scope, path, steps));
|
|
53
|
+
return valueOf(scope, steps, query);
|
|
54
|
+
};
|
|
55
|
+
/** Several paths at once, for conditions. */
|
|
56
|
+
const usePathValues = (scope, paths) => {
|
|
57
|
+
const { resources } = useAdminMeta();
|
|
58
|
+
const client = useClient();
|
|
59
|
+
const queryClient = useQueryClient();
|
|
60
|
+
const steps = paths.map((path) => stepsOf(resources, scope, path));
|
|
61
|
+
const queries = useQueries({ queries: paths.map((path, index) => pathQuery(client, queryClient, scope, path, steps[index])) });
|
|
62
|
+
return paths.map((_, index) => valueOf(scope, steps[index], queries[index]));
|
|
63
|
+
};
|
|
64
|
+
//#endregion
|
|
65
|
+
export { usePathValue, usePathValues };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { keys } from "../../data/query-keys.js";
|
|
2
|
+
import { useClient } from "../../data/api-provider.js";
|
|
3
|
+
import { useAdminMeta } from "../meta-gate.js";
|
|
4
|
+
import { fetchLabels } from "../../live/relation-labels.js";
|
|
5
|
+
import { useQueries, useQueryClient } from "@tanstack/react-query";
|
|
6
|
+
//#region src/app/pages/use-relation-labels.ts
|
|
7
|
+
/** Names for the relation columns of the rows on screen, one list call per column, by column then key. */
|
|
8
|
+
const useRelationLabels = (model, columns, rows) => {
|
|
9
|
+
const { resources, views } = useAdminMeta();
|
|
10
|
+
const client = useClient();
|
|
11
|
+
const queryClient = useQueryClient();
|
|
12
|
+
const relations = columns.flatMap((name) => {
|
|
13
|
+
const field = model.fields[name];
|
|
14
|
+
const target = field?.type === "relation" && field.relation ? resources[field.relation.resource] : void 0;
|
|
15
|
+
if (!target || target.primaryKey.length !== 1) return [];
|
|
16
|
+
const ids = [...new Set(rows.flatMap((row) => row[name] === null || row[name] === void 0 ? [] : [String(row[name])]))].sort();
|
|
17
|
+
return ids.length > 0 ? [{
|
|
18
|
+
name,
|
|
19
|
+
target,
|
|
20
|
+
ids
|
|
21
|
+
}] : [];
|
|
22
|
+
});
|
|
23
|
+
const results = useQueries({ queries: relations.map(({ target, ids }) => ({
|
|
24
|
+
queryKey: keys.labelMap(target.name, ids.join(",")),
|
|
25
|
+
queryFn: () => fetchLabels(queryClient, client, target, ids, views[target.name])
|
|
26
|
+
})) });
|
|
27
|
+
return new Map(relations.flatMap(({ name }, index) => results[index]?.data ? [[name, results[index].data]] : []));
|
|
28
|
+
};
|
|
29
|
+
//#endregion
|
|
30
|
+
export { useRelationLabels };
|