@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,88 @@
|
|
|
1
|
+
import { useClient } from "../../data/api-provider.js";
|
|
2
|
+
import { useToast } from "../../toasts/toast-provider.js";
|
|
3
|
+
import { useRouter } from "../router.js";
|
|
4
|
+
import { useAdminMeta, usePermissions } from "../meta-gate.js";
|
|
5
|
+
import { can } from "../permissions.js";
|
|
6
|
+
import { fillHref, isAppPath } from "./action-href.js";
|
|
7
|
+
import { useProjectUi } from "./project-ui.js";
|
|
8
|
+
import { useRefreshResource } from "./use-layout-data.js";
|
|
9
|
+
import { useState } from "react";
|
|
10
|
+
//#region src/app/pages/use-run-action.ts
|
|
11
|
+
const message = (error) => error instanceof Error ? error.message : "Something went wrong";
|
|
12
|
+
/**
|
|
13
|
+
* The named actions of `resource`'s view, ready to run on `scope` (the record they act on): built-in behaviour first
|
|
14
|
+
* (`update`, `delete`, `link`), else the handler the project registered under the action's name. `blocked` says why one
|
|
15
|
+
* cannot run; `afterRun` hears about every action that succeeded.
|
|
16
|
+
*/
|
|
17
|
+
const useActionRunner = (resource, scope, afterRun) => {
|
|
18
|
+
const { views } = useAdminMeta();
|
|
19
|
+
const { actions: handlers } = useProjectUi();
|
|
20
|
+
const permissions = usePermissions(resource);
|
|
21
|
+
const client = useClient();
|
|
22
|
+
const toast = useToast();
|
|
23
|
+
const refresh = useRefreshResource();
|
|
24
|
+
const { navigate } = useRouter();
|
|
25
|
+
const [running, setRunning] = useState();
|
|
26
|
+
const actions = views[resource]?.actions ?? [];
|
|
27
|
+
const blocked = (action) => {
|
|
28
|
+
const kind = action.run?.kind;
|
|
29
|
+
if ((kind === "update" || kind === "delete") && !scope) return "Needs a record";
|
|
30
|
+
if (kind === "update" && (!can(permissions, "update") || scope?.permissions?.update === false)) return "You cannot change this record";
|
|
31
|
+
if (kind === "delete" && (!can(permissions, "delete") || scope?.permissions?.delete === false)) return "You cannot delete this record";
|
|
32
|
+
if (!action.run && !handlers?.[action.name]) return `The app has no handler for "${action.name}"`;
|
|
33
|
+
};
|
|
34
|
+
const perform = async (action) => {
|
|
35
|
+
const { run } = action;
|
|
36
|
+
if (run?.kind === "link") {
|
|
37
|
+
const href = fillHref(run.href, scope?.record);
|
|
38
|
+
if (isAppPath(href)) navigate(href);
|
|
39
|
+
else window.location.assign(href);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (run?.kind === "update") await client.update(resource, scope.key, run.values, scope.etag ?? "*");
|
|
43
|
+
else if (run?.kind === "delete") await client.remove(resource, scope.key, { etag: scope.etag });
|
|
44
|
+
else await handlers[action.name]({
|
|
45
|
+
resource,
|
|
46
|
+
record: scope?.record,
|
|
47
|
+
recordKey: scope?.key,
|
|
48
|
+
etag: scope?.etag,
|
|
49
|
+
client,
|
|
50
|
+
navigate
|
|
51
|
+
});
|
|
52
|
+
await refresh(resource);
|
|
53
|
+
toast.show({
|
|
54
|
+
state: "success",
|
|
55
|
+
title: `${action.label}: done`
|
|
56
|
+
});
|
|
57
|
+
afterRun?.(action);
|
|
58
|
+
};
|
|
59
|
+
const run = async (action) => {
|
|
60
|
+
if (blocked(action)) return;
|
|
61
|
+
setRunning(action.name);
|
|
62
|
+
await perform(action).catch((error) => toast.show({
|
|
63
|
+
state: "error",
|
|
64
|
+
title: `${action.label} failed`,
|
|
65
|
+
description: message(error)
|
|
66
|
+
}));
|
|
67
|
+
setRunning(void 0);
|
|
68
|
+
};
|
|
69
|
+
return {
|
|
70
|
+
actions,
|
|
71
|
+
blocked,
|
|
72
|
+
running,
|
|
73
|
+
run
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
/** One named action of `resource`'s view, for a button. */
|
|
77
|
+
const useRunAction = (resource, name, scope) => {
|
|
78
|
+
const runner = useActionRunner(resource, scope);
|
|
79
|
+
const action = runner.actions.find((entry) => entry.name === name);
|
|
80
|
+
return {
|
|
81
|
+
action,
|
|
82
|
+
blocked: action ? runner.blocked(action) : `No action "${name}"`,
|
|
83
|
+
running: runner.running === name,
|
|
84
|
+
run: () => action ? runner.run(action) : Promise.resolve()
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
//#endregion
|
|
88
|
+
export { useActionRunner, useRunAction };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
//#region src/app/permissions.ts
|
|
2
|
+
/**
|
|
3
|
+
* Whether to offer an operation: yes when allowed outright, and also when only some records qualify
|
|
4
|
+
* (`conditional`), because then the server decides per record and a 403 is reported when it says no.
|
|
5
|
+
*/
|
|
6
|
+
const can = (permissions, operation) => permissions[operation] || permissions.conditional.includes(operation);
|
|
7
|
+
/** List rows carry `permissions: { update, delete }`; a row without them (the server was not asked) is allowed, and every write is checked again. */
|
|
8
|
+
const rowAllows = (row, operation) => row.permissions?.[operation] ?? true;
|
|
9
|
+
/** One field on one record: `edit`, `read`, or `hidden` (absent from a get response's `permissions.fields`). Without that map the model decides. */
|
|
10
|
+
const fieldAccess = (permissions, name) => {
|
|
11
|
+
if (!permissions?.fields) return "edit";
|
|
12
|
+
return permissions.fields[name] ?? "hidden";
|
|
13
|
+
};
|
|
14
|
+
//#endregion
|
|
15
|
+
export { can, fieldAccess, rowAllows };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { usePermissions } from "./meta-gate.js";
|
|
2
|
+
import { can } from "./permissions.js";
|
|
3
|
+
import { InlineChildTable } from "../inline-child-table/inline-child-table.js";
|
|
4
|
+
import { Section } from "../record-view/section.js";
|
|
5
|
+
import { jsx } from "react/jsx-runtime";
|
|
6
|
+
//#region src/app/record-lines.tsx
|
|
7
|
+
/** A resource that holds numbered lines of this one: it points at the parent and has `position` and `quantity`. */
|
|
8
|
+
const findLinesResource = (resources, parent) => Object.values(resources).find((candidate) => candidate.name !== parent.name && candidate.fields.position && candidate.fields.quantity && Object.values(candidate.fields).some((field) => field.type === "relation" && field.relation?.resource === parent.name));
|
|
9
|
+
const toLine = (record) => ({
|
|
10
|
+
id: String(record.id),
|
|
11
|
+
productId: String(record.productId ?? ""),
|
|
12
|
+
description: String(record.description ?? ""),
|
|
13
|
+
quantity: Number(record.quantity),
|
|
14
|
+
unitPriceCents: Math.round(Number(record.unitPrice) * 100)
|
|
15
|
+
});
|
|
16
|
+
/** The lines of a record. Reordering only changes `draft`; the record's Save writes it, in one batch. */
|
|
17
|
+
const RecordLines = ({ lines, draft, vatRate, saving }) => {
|
|
18
|
+
const canReorder = can(usePermissions(lines.name), "update");
|
|
19
|
+
return /* @__PURE__ */ jsx(Section, {
|
|
20
|
+
title: "Lines",
|
|
21
|
+
help: "What the document bills for. Drag the handle, or use the menu, to change the order; Save applies it.",
|
|
22
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
23
|
+
className: "-mx-4 overflow-x-auto px-4 [-webkit-overflow-scrolling:touch] md:mx-0 md:px-0",
|
|
24
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
25
|
+
className: "min-w-[560px]",
|
|
26
|
+
children: /* @__PURE__ */ jsx(InlineChildTable, {
|
|
27
|
+
lines: draft.shown.map(toLine),
|
|
28
|
+
onChange: () => void 0,
|
|
29
|
+
onReorder: (next) => draft.setOrder(next.map((line) => line.id)),
|
|
30
|
+
vatRate,
|
|
31
|
+
readOnly: true,
|
|
32
|
+
reorderable: canReorder && !saving
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
//#endregion
|
|
39
|
+
export { RecordLines, findLinesResource };
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { Badge } from "../primitives/badge.js";
|
|
2
|
+
import { keys } from "../data/query-keys.js";
|
|
3
|
+
import { useClient } from "../data/api-provider.js";
|
|
4
|
+
import { useAuth } from "../auth/auth-provider.js";
|
|
5
|
+
import { Button } from "../primitives/button.js";
|
|
6
|
+
import { useRecord } from "../data/use-record.js";
|
|
7
|
+
import { recordTitle } from "../live/model-helpers.js";
|
|
8
|
+
import { humanize } from "../live/naming.js";
|
|
9
|
+
import { useNavigationGuard, useRouter } from "./router.js";
|
|
10
|
+
import { formatRelativeTime } from "../format/date.js";
|
|
11
|
+
import { displayValue } from "../live/display-value.js";
|
|
12
|
+
import { ErrorBanner } from "./error-banner.js";
|
|
13
|
+
import { FieldEditor } from "./field-editor.js";
|
|
14
|
+
import { Notice, useAdminMeta, usePermissions } from "./meta-gate.js";
|
|
15
|
+
import { can, fieldAccess } from "./permissions.js";
|
|
16
|
+
import { Field } from "../record-view/field.js";
|
|
17
|
+
import { Section } from "../record-view/section.js";
|
|
18
|
+
import { MetadataCard, RelatedRecords, SidebarCard } from "../record-view/record-sidebar.js";
|
|
19
|
+
import { RecordHeader } from "../record-view/record-header.js";
|
|
20
|
+
import { toneFor } from "../live/columns-from-model.js";
|
|
21
|
+
import { useDeleteFlow } from "./delete/use-delete-flow.js";
|
|
22
|
+
import { useRestore } from "./delete/use-restore.js";
|
|
23
|
+
import { RecordActions } from "./pages/record-actions.js";
|
|
24
|
+
import { RecordLines, findLinesResource } from "./record-lines.js";
|
|
25
|
+
import { draftStorageKey } from "./draft-storage.js";
|
|
26
|
+
import { useLines } from "./use-lines.js";
|
|
27
|
+
import { useLinesDraft } from "./use-lines-draft.js";
|
|
28
|
+
import { useRecordEditor } from "./use-record-editor.js";
|
|
29
|
+
import { useRelatedRecords } from "./use-related-records.js";
|
|
30
|
+
import { RelatedSection } from "./related-section.js";
|
|
31
|
+
import { useMemo } from "react";
|
|
32
|
+
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
33
|
+
import { Check } from "lucide-react";
|
|
34
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
35
|
+
import { isApiError } from "@protobase/client";
|
|
36
|
+
import { enumLabel } from "@protobase/schema";
|
|
37
|
+
//#region src/app/record-page.tsx
|
|
38
|
+
const hidden = (model, name) => name === model.tenant || model.primaryKey.includes(name);
|
|
39
|
+
/**
|
|
40
|
+
* The view's layout, or one section with the editable fields when the view has none. A user who may only read the
|
|
41
|
+
* resource has every field read-only, so they get every field, locked, rather than an empty section.
|
|
42
|
+
*/
|
|
43
|
+
const layoutFor = (model, view) => {
|
|
44
|
+
if (view && view.layout.length > 0) return view.layout;
|
|
45
|
+
const shown = Object.values(model.fields).filter((field) => !hidden(model, field.name));
|
|
46
|
+
const editable = shown.filter((field) => !field.readOnly);
|
|
47
|
+
return [{
|
|
48
|
+
kind: "section",
|
|
49
|
+
title: "Details",
|
|
50
|
+
fields: (editable.length > 0 ? editable : shown).map((field) => field.name)
|
|
51
|
+
}];
|
|
52
|
+
};
|
|
53
|
+
const RecordPage = ({ resource, recordKey }) => {
|
|
54
|
+
const { resources } = useAdminMeta();
|
|
55
|
+
const model = resources[resource];
|
|
56
|
+
const record = useRecord(resource, recordKey);
|
|
57
|
+
if (!model) return /* @__PURE__ */ jsxs(Notice, {
|
|
58
|
+
title: "Not found",
|
|
59
|
+
children: [
|
|
60
|
+
"There is no resource called ",
|
|
61
|
+
resource,
|
|
62
|
+
"."
|
|
63
|
+
]
|
|
64
|
+
});
|
|
65
|
+
if (record.isPending) return /* @__PURE__ */ jsx(Notice, {
|
|
66
|
+
title: "Loading",
|
|
67
|
+
children: "Fetching the record."
|
|
68
|
+
});
|
|
69
|
+
if (record.error) return /* @__PURE__ */ jsx("div", {
|
|
70
|
+
className: "p-8",
|
|
71
|
+
children: isApiError(record.error) && record.error.status === 404 ? /* @__PURE__ */ jsx(Notice, {
|
|
72
|
+
title: "Not found",
|
|
73
|
+
children: "This record does not exist, or you cannot see it."
|
|
74
|
+
}) : /* @__PURE__ */ jsx(ErrorBanner, {
|
|
75
|
+
title: "Could not load the record",
|
|
76
|
+
error: record.error
|
|
77
|
+
})
|
|
78
|
+
});
|
|
79
|
+
return /* @__PURE__ */ jsx(LoadedRecord, {
|
|
80
|
+
model,
|
|
81
|
+
recordKey,
|
|
82
|
+
stored: record.data
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
86
|
+
const meta = useAdminMeta();
|
|
87
|
+
const { basePath, navigate } = useRouter();
|
|
88
|
+
const view = meta.views[model.name];
|
|
89
|
+
const singular = view?.names?.singular ?? humanize(model.name);
|
|
90
|
+
const title = recordTitle(model, view, stored.record, recordKey);
|
|
91
|
+
const { state: auth } = useAuth();
|
|
92
|
+
const linesModel = useMemo(() => findLinesResource(meta.resources, model), [meta.resources, model]);
|
|
93
|
+
const linesData = useLines(linesModel, model, recordKey);
|
|
94
|
+
const linesDraft = useLinesDraft(linesModel, linesData.items);
|
|
95
|
+
const editor = useRecordEditor({
|
|
96
|
+
model,
|
|
97
|
+
stored,
|
|
98
|
+
recordKey,
|
|
99
|
+
mode: view?.saveFeedback ?? "toast",
|
|
100
|
+
subject: title,
|
|
101
|
+
draftKey: auth.kind === "signed-in" ? draftStorageKey(auth.user.id, model.name, recordKey) : void 0,
|
|
102
|
+
lines: linesDraft
|
|
103
|
+
});
|
|
104
|
+
useNavigationGuard(editor.dirty);
|
|
105
|
+
const queryClient = useQueryClient();
|
|
106
|
+
const client = useClient();
|
|
107
|
+
const permissions = usePermissions(model.name);
|
|
108
|
+
const restore = useRestore(model);
|
|
109
|
+
const canUpdate = can(permissions, "update") && stored.permissions?.update !== false;
|
|
110
|
+
const remove = useDeleteFlow({
|
|
111
|
+
model,
|
|
112
|
+
view,
|
|
113
|
+
restore,
|
|
114
|
+
onDeleted: () => navigate(`/${model.name}`, { replace: true }),
|
|
115
|
+
onReview: () => void queryClient.invalidateQueries({ queryKey: keys.record(model.name, recordKey) })
|
|
116
|
+
});
|
|
117
|
+
const related = useRelatedRecords(model, view, meta.resources, meta.views, stored.record, basePath);
|
|
118
|
+
const layout = layoutFor(model, view);
|
|
119
|
+
const sidebarFields = layout.flatMap((item) => item.kind === "sidebar" ? item.fields : []);
|
|
120
|
+
const status = model.fields.status?.type === "enum" ? String(stored.record.status) : void 0;
|
|
121
|
+
const updated = stored.record.updatedAt ? Math.max(0, Math.round((Date.now() - Date.parse(String(stored.record.updatedAt))) / 1e3)) : void 0;
|
|
122
|
+
const hint = (name) => view?.fields[name];
|
|
123
|
+
const visible = (name) => fieldAccess(stored.permissions, name) !== "hidden";
|
|
124
|
+
const fieldView = (name) => {
|
|
125
|
+
const field = model.fields[name];
|
|
126
|
+
const access = fieldAccess(stored.permissions, name);
|
|
127
|
+
if (!field || access === "hidden") return null;
|
|
128
|
+
const conflict = editor.conflicts[name];
|
|
129
|
+
return /* @__PURE__ */ jsx(Field, {
|
|
130
|
+
label: hint(name)?.label ?? humanize(name),
|
|
131
|
+
help: hint(name)?.help,
|
|
132
|
+
error: editor.errors[name],
|
|
133
|
+
conflict: conflict === void 0 ? void 0 : {
|
|
134
|
+
user: {
|
|
135
|
+
name: "Someone else",
|
|
136
|
+
initials: "?",
|
|
137
|
+
hue: 40
|
|
138
|
+
},
|
|
139
|
+
ago: "just now",
|
|
140
|
+
theirValue: conflict || "—",
|
|
141
|
+
onKeepMine: () => editor.keepMine(name),
|
|
142
|
+
onUseTheirs: () => editor.useTheirs(name)
|
|
143
|
+
},
|
|
144
|
+
children: /* @__PURE__ */ jsx(FieldEditor, {
|
|
145
|
+
field,
|
|
146
|
+
hints: hint(name),
|
|
147
|
+
value: editor.value(name),
|
|
148
|
+
stored: stored.record[name],
|
|
149
|
+
relationLabel: related.labels[name],
|
|
150
|
+
relationHref: related.hrefs[name],
|
|
151
|
+
locked: !canUpdate || access === "read",
|
|
152
|
+
invalid: Boolean(editor.errors[name]),
|
|
153
|
+
reveal: field.sensitive ? () => client.reveal(model.name, recordKey, name) : void 0,
|
|
154
|
+
onChange: (next) => editor.change(name, next)
|
|
155
|
+
})
|
|
156
|
+
}, field.sensitive ? `${name}:${stored.etag}` : name);
|
|
157
|
+
};
|
|
158
|
+
const saveControls = /* @__PURE__ */ jsxs(Fragment$1, { children: [
|
|
159
|
+
editor.dirty && /* @__PURE__ */ jsx("span", {
|
|
160
|
+
className: "text-xs text-muted-foreground",
|
|
161
|
+
children: "Unsaved changes"
|
|
162
|
+
}),
|
|
163
|
+
editor.dirty && canUpdate && /* @__PURE__ */ jsx(Button, {
|
|
164
|
+
className: "max-md:min-h-11",
|
|
165
|
+
disabled: editor.saving,
|
|
166
|
+
onClick: editor.discard,
|
|
167
|
+
children: "Discard"
|
|
168
|
+
}),
|
|
169
|
+
can(permissions, "delete") && stored.permissions?.delete !== false && /* @__PURE__ */ jsx(Button, {
|
|
170
|
+
variant: "danger",
|
|
171
|
+
className: "max-md:min-h-11",
|
|
172
|
+
onClick: () => void remove.start({
|
|
173
|
+
key: recordKey,
|
|
174
|
+
title,
|
|
175
|
+
etag: stored.etag,
|
|
176
|
+
snapshot: stored.record
|
|
177
|
+
}),
|
|
178
|
+
children: "Delete"
|
|
179
|
+
}),
|
|
180
|
+
canUpdate && /* @__PURE__ */ jsxs(Button, {
|
|
181
|
+
variant: "primary",
|
|
182
|
+
className: "max-md:min-h-11 max-md:flex-1",
|
|
183
|
+
loading: editor.saving,
|
|
184
|
+
disabled: !editor.dirty && editor.phase !== "saved",
|
|
185
|
+
onClick: () => void editor.save(),
|
|
186
|
+
children: [editor.phase === "saved" && /* @__PURE__ */ jsx(Check, { className: "size-4" }), editor.phase === "saved" ? "Saved" : "Save"]
|
|
187
|
+
})
|
|
188
|
+
] });
|
|
189
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
190
|
+
className: "min-h-0 flex-1 overflow-y-auto",
|
|
191
|
+
children: [
|
|
192
|
+
remove.dialogs,
|
|
193
|
+
/* @__PURE__ */ jsxs("div", {
|
|
194
|
+
className: "mx-auto max-w-[1100px] px-4 pb-28 pt-6 md:px-10 md:py-8",
|
|
195
|
+
children: [
|
|
196
|
+
/* @__PURE__ */ jsx(RecordHeader, {
|
|
197
|
+
collection: view?.names?.plural ?? humanize(model.name),
|
|
198
|
+
title,
|
|
199
|
+
status: {
|
|
200
|
+
label: status ? enumLabel(status, hint("status")?.valueLabels) : singular,
|
|
201
|
+
tone: status ? toneFor(status) : "neutral"
|
|
202
|
+
},
|
|
203
|
+
lastEditedBy: { ago: updated === void 0 ? "recently" : formatRelativeTime(updated) },
|
|
204
|
+
actions: /* @__PURE__ */ jsx("div", {
|
|
205
|
+
className: "hidden items-center gap-4 md:flex",
|
|
206
|
+
children: saveControls
|
|
207
|
+
})
|
|
208
|
+
}),
|
|
209
|
+
editor.message && /* @__PURE__ */ jsx("div", {
|
|
210
|
+
className: "mt-4",
|
|
211
|
+
children: /* @__PURE__ */ jsx(ErrorBanner, {
|
|
212
|
+
title: "Not saved",
|
|
213
|
+
error: new Error(editor.message)
|
|
214
|
+
})
|
|
215
|
+
}),
|
|
216
|
+
editor.linesConflict && /* @__PURE__ */ jsx("div", {
|
|
217
|
+
className: "mt-4",
|
|
218
|
+
children: /* @__PURE__ */ jsx(ErrorBanner, {
|
|
219
|
+
title: "The lines were changed by someone else",
|
|
220
|
+
error: /* @__PURE__ */ new Error("The lines were reloaded. Your new order is kept: Save again to apply it, or Discard to drop it.")
|
|
221
|
+
})
|
|
222
|
+
}),
|
|
223
|
+
editor.restored && /* @__PURE__ */ jsxs("div", {
|
|
224
|
+
className: "mt-4 flex items-center justify-between gap-3 rounded-lg border bg-surface px-3 py-2 text-[13px]",
|
|
225
|
+
role: "status",
|
|
226
|
+
children: [/* @__PURE__ */ jsx("span", { children: "Your unsaved changes were restored." }), /* @__PURE__ */ jsxs("span", {
|
|
227
|
+
className: "flex gap-2",
|
|
228
|
+
children: [/* @__PURE__ */ jsx(Button, {
|
|
229
|
+
size: "sm",
|
|
230
|
+
onClick: editor.dismissRestored,
|
|
231
|
+
children: "Keep"
|
|
232
|
+
}), /* @__PURE__ */ jsx(Button, {
|
|
233
|
+
size: "sm",
|
|
234
|
+
onClick: editor.discard,
|
|
235
|
+
children: "Discard"
|
|
236
|
+
})]
|
|
237
|
+
})]
|
|
238
|
+
}),
|
|
239
|
+
/* @__PURE__ */ jsxs("div", {
|
|
240
|
+
className: "grid grid-cols-1 gap-8 md:grid-cols-[minmax(0,1fr)_300px] md:gap-12",
|
|
241
|
+
children: [/* @__PURE__ */ jsxs("main", {
|
|
242
|
+
className: "divide-y divide-border",
|
|
243
|
+
children: [layout.flatMap((item) => {
|
|
244
|
+
if (item.kind === "related") return [/* @__PURE__ */ jsx(RelatedSection, {
|
|
245
|
+
item,
|
|
246
|
+
recordKey
|
|
247
|
+
}, `related:${item.title}`)];
|
|
248
|
+
if (item.kind !== "section") return [];
|
|
249
|
+
const fields = item.fields.filter((name) => model.fields[name] && visible(name));
|
|
250
|
+
if (fields.length === 0) return [];
|
|
251
|
+
return [/* @__PURE__ */ jsx(Section, {
|
|
252
|
+
title: item.title,
|
|
253
|
+
help: item.help,
|
|
254
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
255
|
+
className: "grid grid-cols-1 gap-x-6 gap-y-5 sm:grid-cols-2",
|
|
256
|
+
children: fields.map(fieldView)
|
|
257
|
+
})
|
|
258
|
+
}, item.title)];
|
|
259
|
+
}), linesModel && /* @__PURE__ */ jsx(RecordLines, {
|
|
260
|
+
lines: linesModel,
|
|
261
|
+
draft: linesDraft,
|
|
262
|
+
vatRate: Number(stored.record.vatRate ?? 21) / 100,
|
|
263
|
+
saving: editor.saving
|
|
264
|
+
})]
|
|
265
|
+
}), /* @__PURE__ */ jsxs("aside", {
|
|
266
|
+
className: "space-y-4 md:pt-8",
|
|
267
|
+
children: [
|
|
268
|
+
/* @__PURE__ */ jsx(RecordActions, {
|
|
269
|
+
model,
|
|
270
|
+
stored,
|
|
271
|
+
recordKey
|
|
272
|
+
}),
|
|
273
|
+
/* @__PURE__ */ jsx(MetadataCard, {
|
|
274
|
+
title: "Summary",
|
|
275
|
+
rows: sidebarFields.flatMap((name) => {
|
|
276
|
+
const field = model.fields[name];
|
|
277
|
+
if (!field || !visible(name)) return [];
|
|
278
|
+
const text = displayValue(field, stored.record[name], hint(name));
|
|
279
|
+
return [{
|
|
280
|
+
label: hint(name)?.label ?? humanize(name),
|
|
281
|
+
value: field.type === "enum" ? /* @__PURE__ */ jsx(Badge, {
|
|
282
|
+
tone: toneFor(String(stored.record[name])),
|
|
283
|
+
children: text
|
|
284
|
+
}) : /* @__PURE__ */ jsx("strong", { children: text })
|
|
285
|
+
}];
|
|
286
|
+
})
|
|
287
|
+
}),
|
|
288
|
+
/* @__PURE__ */ jsx(MetadataCard, {
|
|
289
|
+
title: "Details",
|
|
290
|
+
rows: ["createdAt", "updatedAt"].flatMap((name) => {
|
|
291
|
+
const field = model.fields[name];
|
|
292
|
+
return field && visible(name) ? [{
|
|
293
|
+
label: hint(name)?.label ?? humanize(name),
|
|
294
|
+
value: displayValue(field, stored.record[name], hint(name))
|
|
295
|
+
}] : [];
|
|
296
|
+
})
|
|
297
|
+
}),
|
|
298
|
+
related.records.length > 0 && /* @__PURE__ */ jsx(SidebarCard, {
|
|
299
|
+
title: "Related records",
|
|
300
|
+
children: /* @__PURE__ */ jsx(RelatedRecords, {
|
|
301
|
+
records: related.records,
|
|
302
|
+
onNavigate: (href) => navigate(href)
|
|
303
|
+
})
|
|
304
|
+
})
|
|
305
|
+
]
|
|
306
|
+
})]
|
|
307
|
+
})
|
|
308
|
+
]
|
|
309
|
+
}),
|
|
310
|
+
/* @__PURE__ */ jsx("div", {
|
|
311
|
+
className: "fixed inset-x-0 bottom-0 z-30 flex items-center gap-2 border-t bg-background p-3 md:hidden",
|
|
312
|
+
children: saveControls
|
|
313
|
+
})
|
|
314
|
+
]
|
|
315
|
+
});
|
|
316
|
+
};
|
|
317
|
+
//#endregion
|
|
318
|
+
export { RecordPage };
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { keys } from "../data/query-keys.js";
|
|
2
|
+
import { useClient } from "../data/api-provider.js";
|
|
3
|
+
import { ErrorBanner } from "./error-banner.js";
|
|
4
|
+
import { useAdminMeta } from "./meta-gate.js";
|
|
5
|
+
import { Section } from "../record-view/section.js";
|
|
6
|
+
import { Skeleton } from "../primitives/skeleton.js";
|
|
7
|
+
import { RecordTable } from "./pages/record-table.js";
|
|
8
|
+
import { defaultColumns } from "./pages/blocks/table.js";
|
|
9
|
+
import { useState } from "react";
|
|
10
|
+
import { jsx } from "react/jsx-runtime";
|
|
11
|
+
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
|
12
|
+
import { createWhere, printFilter } from "@protobase/schema";
|
|
13
|
+
//#region src/app/related-section.tsx
|
|
14
|
+
const where = createWhere();
|
|
15
|
+
/** The most join records a section reads to find its records; the list API's largest page. */
|
|
16
|
+
const maxThrough = 500;
|
|
17
|
+
const and = (...parts) => parts.filter((part) => Boolean(part)).map((part) => `(${part})`).join(" AND ");
|
|
18
|
+
/**
|
|
19
|
+
* The filter on the section's resource: its `field` is this record's key, or, through a join resource, one of the keys
|
|
20
|
+
* the join records that point at this record hold. `null` when no join record does, so nothing can match.
|
|
21
|
+
*/
|
|
22
|
+
const useMatch = (item, recordKey) => {
|
|
23
|
+
const client = useClient();
|
|
24
|
+
const { resources } = useAdminMeta();
|
|
25
|
+
const through = item.through;
|
|
26
|
+
const via = through ? resources[through.resource] : void 0;
|
|
27
|
+
const key = through?.key ?? via?.primaryKey[0];
|
|
28
|
+
const viaFilter = through ? and(printFilter(where.eq(through.field, recordKey)), through.filter) : "";
|
|
29
|
+
const collected = useQuery({
|
|
30
|
+
queryKey: keys.list(via?.name ?? "", viaFilter, "", maxThrough, `related:${key}`),
|
|
31
|
+
enabled: Boolean(via && key),
|
|
32
|
+
queryFn: async () => {
|
|
33
|
+
const page = await client.search(via.name, {
|
|
34
|
+
filter: viaFilter,
|
|
35
|
+
fields: [key],
|
|
36
|
+
pageSize: maxThrough
|
|
37
|
+
});
|
|
38
|
+
return [...new Set(page.items.flatMap((row) => row[key] === null || row[key] === void 0 ? [] : [String(row[key])]))];
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
if (!through) return {
|
|
42
|
+
filter: and(printFilter(where.eq(item.field, recordKey)), item.filter),
|
|
43
|
+
isPending: false,
|
|
44
|
+
error: null
|
|
45
|
+
};
|
|
46
|
+
if (!collected.data) return {
|
|
47
|
+
filter: void 0,
|
|
48
|
+
isPending: collected.isPending,
|
|
49
|
+
error: collected.error
|
|
50
|
+
};
|
|
51
|
+
return {
|
|
52
|
+
filter: collected.data.length === 0 ? null : and(printFilter(where.in(item.field, collected.data)), item.filter),
|
|
53
|
+
isPending: false,
|
|
54
|
+
error: null
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
const RelatedRows = ({ item, model, filter }) => {
|
|
58
|
+
const client = useClient();
|
|
59
|
+
const { views } = useAdminMeta();
|
|
60
|
+
const [tokens, setTokens] = useState([""]);
|
|
61
|
+
const pageSize = item.pageSize ?? 10;
|
|
62
|
+
const sort = item.sort ?? "";
|
|
63
|
+
const token = tokens.at(-1) ?? "";
|
|
64
|
+
const page = useQuery({
|
|
65
|
+
queryKey: keys.list(model.name, filter, sort, pageSize, token),
|
|
66
|
+
placeholderData: keepPreviousData,
|
|
67
|
+
queryFn: () => client.search(model.name, {
|
|
68
|
+
filter,
|
|
69
|
+
orderBy: sort || void 0,
|
|
70
|
+
pageSize,
|
|
71
|
+
pageToken: token || void 0
|
|
72
|
+
})
|
|
73
|
+
});
|
|
74
|
+
if (page.isPending) return /* @__PURE__ */ jsx(Loading, {});
|
|
75
|
+
if (page.error) return /* @__PURE__ */ jsx(ErrorBanner, {
|
|
76
|
+
title: "Could not load",
|
|
77
|
+
error: page.error
|
|
78
|
+
});
|
|
79
|
+
const rows = page.data.items;
|
|
80
|
+
if (rows.length === 0 && tokens.length === 1) return /* @__PURE__ */ jsx(Empty, { text: item.empty });
|
|
81
|
+
const columns = (item.columns ?? defaultColumns(model, views[model.name]).filter((name) => name !== item.field)).filter((name) => name.includes(".") || Object.hasOwn(model.fields, name));
|
|
82
|
+
const next = page.data.nextPageToken;
|
|
83
|
+
return /* @__PURE__ */ jsx(RecordTable, {
|
|
84
|
+
model,
|
|
85
|
+
columns,
|
|
86
|
+
rows,
|
|
87
|
+
label: item.title,
|
|
88
|
+
pager: {
|
|
89
|
+
page: tokens.length,
|
|
90
|
+
hasPrevious: tokens.length > 1,
|
|
91
|
+
hasNext: Boolean(next),
|
|
92
|
+
busy: page.isFetching,
|
|
93
|
+
previous: () => setTokens((stack) => stack.slice(0, -1)),
|
|
94
|
+
next: () => next && setTokens((stack) => [...stack, next])
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
};
|
|
98
|
+
const Loading = () => /* @__PURE__ */ jsx("div", {
|
|
99
|
+
className: "flex flex-col gap-2",
|
|
100
|
+
children: Array.from({ length: 3 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-full" }, index))
|
|
101
|
+
});
|
|
102
|
+
const Empty = ({ text }) => /* @__PURE__ */ jsx("p", {
|
|
103
|
+
className: "text-[13px] text-muted-foreground",
|
|
104
|
+
children: text ?? "Nothing here yet."
|
|
105
|
+
});
|
|
106
|
+
/** A record page section listing the records of another resource that point at this record, directly or through a join resource. */
|
|
107
|
+
const RelatedSection = ({ item, recordKey }) => {
|
|
108
|
+
const { resources } = useAdminMeta();
|
|
109
|
+
const model = resources[item.resource];
|
|
110
|
+
const match = useMatch(item, recordKey);
|
|
111
|
+
if (!model) return null;
|
|
112
|
+
return /* @__PURE__ */ jsx(Section, {
|
|
113
|
+
title: item.title,
|
|
114
|
+
help: item.help,
|
|
115
|
+
children: match.isPending ? /* @__PURE__ */ jsx(Loading, {}) : match.error ? /* @__PURE__ */ jsx(ErrorBanner, {
|
|
116
|
+
title: "Could not load",
|
|
117
|
+
error: match.error
|
|
118
|
+
}) : match.filter === null || match.filter === void 0 ? /* @__PURE__ */ jsx(Empty, { text: item.empty }) : /* @__PURE__ */ jsx(RelatedRows, {
|
|
119
|
+
item,
|
|
120
|
+
model,
|
|
121
|
+
filter: match.filter
|
|
122
|
+
}, match.filter)
|
|
123
|
+
});
|
|
124
|
+
};
|
|
125
|
+
//#endregion
|
|
126
|
+
export { RelatedSection };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Input } from "../primitives/input.js";
|
|
2
|
+
import { useClient } from "../data/api-provider.js";
|
|
3
|
+
import { labelFieldOf } from "../live/model-helpers.js";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
6
|
+
import { useQuery } from "@tanstack/react-query";
|
|
7
|
+
import { createWhere, printFilter } from "@protobase/schema";
|
|
8
|
+
//#region src/app/relation-picker.tsx
|
|
9
|
+
const where = createWhere();
|
|
10
|
+
/** Picks a record of another resource: type to search (when the target has search fields), choose from the first matches. */
|
|
11
|
+
const RelationPicker = ({ target, view, value, label, invalid, onChange }) => {
|
|
12
|
+
const client = useClient();
|
|
13
|
+
const [text, setText] = useState("");
|
|
14
|
+
const [open, setOpen] = useState(false);
|
|
15
|
+
const keyField = target.primaryKey[0];
|
|
16
|
+
const labelField = labelFieldOf(target, view);
|
|
17
|
+
const options = useQuery({
|
|
18
|
+
queryKey: [
|
|
19
|
+
"relation-options",
|
|
20
|
+
target.name,
|
|
21
|
+
text
|
|
22
|
+
],
|
|
23
|
+
enabled: open,
|
|
24
|
+
queryFn: () => client.list(target.name, {
|
|
25
|
+
filter: text && target.search?.length ? printFilter(where.search(text)) : void 0,
|
|
26
|
+
fields: [keyField, labelField],
|
|
27
|
+
pageSize: 8
|
|
28
|
+
})
|
|
29
|
+
});
|
|
30
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
31
|
+
className: "relative",
|
|
32
|
+
children: [/* @__PURE__ */ jsx(Input, {
|
|
33
|
+
invalid,
|
|
34
|
+
placeholder: `Search ${target.name}`,
|
|
35
|
+
value: open ? text : label ?? value,
|
|
36
|
+
onFocus: () => setOpen(true),
|
|
37
|
+
onBlur: () => setTimeout(() => setOpen(false), 120),
|
|
38
|
+
onChange: (event) => setText(event.target.value)
|
|
39
|
+
}), open && /* @__PURE__ */ jsxs("ul", {
|
|
40
|
+
role: "listbox",
|
|
41
|
+
className: "absolute z-30 mt-1 max-h-60 w-full overflow-y-auto rounded-md border bg-background py-1 text-[13px] shadow-pop",
|
|
42
|
+
children: [
|
|
43
|
+
options.data?.items.map((item) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx("button", {
|
|
44
|
+
type: "button",
|
|
45
|
+
role: "option",
|
|
46
|
+
"aria-selected": String(item[keyField]) === value,
|
|
47
|
+
className: "block w-full truncate px-3 py-1.5 text-left hover:bg-muted",
|
|
48
|
+
onMouseDown: (event) => event.preventDefault(),
|
|
49
|
+
onClick: () => {
|
|
50
|
+
onChange(String(item[keyField]), String(item[labelField]));
|
|
51
|
+
setText("");
|
|
52
|
+
setOpen(false);
|
|
53
|
+
},
|
|
54
|
+
children: String(item[labelField])
|
|
55
|
+
}) }, String(item[keyField]))),
|
|
56
|
+
options.data && options.data.items.length === 0 && /* @__PURE__ */ jsx("li", {
|
|
57
|
+
className: "px-3 py-1.5 text-muted-foreground",
|
|
58
|
+
children: "No matches"
|
|
59
|
+
}),
|
|
60
|
+
options.isPending && /* @__PURE__ */ jsx("li", {
|
|
61
|
+
className: "px-3 py-1.5 text-muted-foreground",
|
|
62
|
+
children: "Loading"
|
|
63
|
+
})
|
|
64
|
+
]
|
|
65
|
+
})]
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
//#endregion
|
|
69
|
+
export { RelationPicker };
|