@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,118 @@
|
|
|
1
|
+
import { useClient } from "../../data/api-provider.js";
|
|
2
|
+
import { recordKey } from "../../live/model-helpers.js";
|
|
3
|
+
import { humanize } from "../../live/naming.js";
|
|
4
|
+
import { useRouter } from "../router.js";
|
|
5
|
+
import { FieldEditor } from "../field-editor.js";
|
|
6
|
+
import { Notice, useAdminMeta, usePermissions } from "../meta-gate.js";
|
|
7
|
+
import { can } from "../permissions.js";
|
|
8
|
+
import { useLiveSave } from "../use-live-save.js";
|
|
9
|
+
import { CreateRecordForm } from "./create-record-form.js";
|
|
10
|
+
import { createBody, createSections, hasDatabaseDefault, initialDraft, isChosen, isRequired } from "./create-values.js";
|
|
11
|
+
import { useState } from "react";
|
|
12
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
13
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
14
|
+
import { isApiError } from "@protobase/client";
|
|
15
|
+
import { encodeKey } from "@protobase/schema";
|
|
16
|
+
//#region src/app/create/create-page.tsx
|
|
17
|
+
const without = (source, name) => Object.fromEntries(Object.entries(source).filter(([key]) => key !== name));
|
|
18
|
+
/** `/:resource/new`: a form from the view's layout; on success it opens the new record. */
|
|
19
|
+
const CreatePage = ({ resource }) => {
|
|
20
|
+
const meta = useAdminMeta();
|
|
21
|
+
const model = meta.resources[resource];
|
|
22
|
+
const permissions = usePermissions(resource);
|
|
23
|
+
if (!model) return /* @__PURE__ */ jsxs(Notice, {
|
|
24
|
+
title: "Not found",
|
|
25
|
+
children: [
|
|
26
|
+
"There is no resource called ",
|
|
27
|
+
resource,
|
|
28
|
+
"."
|
|
29
|
+
]
|
|
30
|
+
});
|
|
31
|
+
if (!can(permissions, "create")) return /* @__PURE__ */ jsxs(Notice, {
|
|
32
|
+
title: "Not allowed",
|
|
33
|
+
children: [
|
|
34
|
+
"You cannot create ",
|
|
35
|
+
meta.views[resource]?.names?.plural.toLowerCase() ?? resource,
|
|
36
|
+
"."
|
|
37
|
+
]
|
|
38
|
+
});
|
|
39
|
+
return /* @__PURE__ */ jsx(LoadedCreate, { resource });
|
|
40
|
+
};
|
|
41
|
+
const LoadedCreate = ({ resource }) => {
|
|
42
|
+
const meta = useAdminMeta();
|
|
43
|
+
const model = meta.resources[resource];
|
|
44
|
+
const view = meta.views[resource];
|
|
45
|
+
const client = useClient();
|
|
46
|
+
const queryClient = useQueryClient();
|
|
47
|
+
const { navigate } = useRouter();
|
|
48
|
+
const noun = view?.names?.singular ?? humanize(resource);
|
|
49
|
+
const feedback = useLiveSave(view?.saveFeedback ?? "toast", `New ${noun.toLowerCase()}`);
|
|
50
|
+
const sections = createSections(model, view);
|
|
51
|
+
const [draft, setDraft] = useState(() => initialDraft(model, sections));
|
|
52
|
+
const [errors, setErrors] = useState({});
|
|
53
|
+
const change = (name, value) => {
|
|
54
|
+
setErrors((current) => without(current, name));
|
|
55
|
+
setDraft((current) => ({
|
|
56
|
+
...current,
|
|
57
|
+
[name]: value
|
|
58
|
+
}));
|
|
59
|
+
};
|
|
60
|
+
const submit = () => feedback.run(async () => {
|
|
61
|
+
let created;
|
|
62
|
+
try {
|
|
63
|
+
created = await client.create(resource, createBody(model, meta.resources, draft));
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (isApiError(error)) setErrors(Object.fromEntries(error.errors.flatMap((entry) => entry.field ? [[entry.field, entry.message]] : [])));
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
for (const prefix of [
|
|
69
|
+
"page",
|
|
70
|
+
"list",
|
|
71
|
+
"first-page",
|
|
72
|
+
"facets",
|
|
73
|
+
"series"
|
|
74
|
+
]) queryClient.invalidateQueries({ queryKey: [prefix, resource] });
|
|
75
|
+
navigate(`/${resource}/${encodeKey(recordKey(model, created.record))}`, { replace: true });
|
|
76
|
+
});
|
|
77
|
+
const formSections = sections.map((section) => ({
|
|
78
|
+
title: section.title,
|
|
79
|
+
help: section.help,
|
|
80
|
+
fields: section.fields.map((name) => {
|
|
81
|
+
const field = model.fields[name];
|
|
82
|
+
const value = draft[name];
|
|
83
|
+
const target = field.relation ? meta.resources[field.relation.resource] : void 0;
|
|
84
|
+
return {
|
|
85
|
+
name,
|
|
86
|
+
label: view?.fields[name]?.label ?? humanize(name),
|
|
87
|
+
help: view?.fields[name]?.help,
|
|
88
|
+
required: isRequired(field),
|
|
89
|
+
error: errors[name],
|
|
90
|
+
editor: /* @__PURE__ */ jsx(FieldEditor, {
|
|
91
|
+
field,
|
|
92
|
+
hints: view?.fields[name],
|
|
93
|
+
value: isChosen(value) ? value.key : value ?? (field.type === "boolean" ? false : ""),
|
|
94
|
+
stored: void 0,
|
|
95
|
+
relationLabel: isChosen(value) ? value.label : void 0,
|
|
96
|
+
placeholder: hasDatabaseDefault(field) ? "Set by the database" : void 0,
|
|
97
|
+
relationPicker: target ? {
|
|
98
|
+
target,
|
|
99
|
+
view: meta.views[target.name]
|
|
100
|
+
} : void 0,
|
|
101
|
+
invalid: Boolean(errors[name]),
|
|
102
|
+
onChange: (next) => change(name, next)
|
|
103
|
+
})
|
|
104
|
+
};
|
|
105
|
+
})
|
|
106
|
+
}));
|
|
107
|
+
return /* @__PURE__ */ jsx(CreateRecordForm, {
|
|
108
|
+
noun,
|
|
109
|
+
collection: view?.names?.plural ?? humanize(resource),
|
|
110
|
+
sections: formSections,
|
|
111
|
+
saving: feedback.phase === "saving",
|
|
112
|
+
message: feedback.message,
|
|
113
|
+
onSubmit: () => void submit(),
|
|
114
|
+
onCancel: () => navigate(`/${resource}`)
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
//#endregion
|
|
118
|
+
export { CreatePage };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Button } from "../../primitives/button.js";
|
|
2
|
+
import { ErrorBanner } from "../error-banner.js";
|
|
3
|
+
import { Field } from "../../record-view/field.js";
|
|
4
|
+
import { Section } from "../../record-view/section.js";
|
|
5
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
6
|
+
//#region src/app/create/create-record-form.tsx
|
|
7
|
+
/** The presentation of "New <thing>": the view's sections, each field with its editor, and Create or Cancel. */
|
|
8
|
+
const CreateRecordForm = ({ noun, collection, sections, saving, message, onSubmit, onCancel }) => /* @__PURE__ */ jsx("form", {
|
|
9
|
+
className: "min-h-0 flex-1 overflow-y-auto",
|
|
10
|
+
onSubmit: (event) => {
|
|
11
|
+
event.preventDefault();
|
|
12
|
+
onSubmit();
|
|
13
|
+
},
|
|
14
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
15
|
+
className: "mx-auto max-w-[760px] px-4 py-6 md:px-10 md:py-8",
|
|
16
|
+
children: [
|
|
17
|
+
/* @__PURE__ */ jsxs("header", {
|
|
18
|
+
className: "flex flex-col gap-4 border-b pb-6 sm:flex-row sm:items-end sm:justify-between sm:gap-6",
|
|
19
|
+
children: [/* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("p", {
|
|
20
|
+
className: "text-xs text-muted-foreground",
|
|
21
|
+
children: collection
|
|
22
|
+
}), /* @__PURE__ */ jsxs("h1", {
|
|
23
|
+
className: "mt-1 text-2xl font-semibold tracking-tight",
|
|
24
|
+
children: ["New ", noun.toLowerCase()]
|
|
25
|
+
})] }), /* @__PURE__ */ jsxs("div", {
|
|
26
|
+
className: "flex items-center gap-2",
|
|
27
|
+
children: [/* @__PURE__ */ jsx(Button, {
|
|
28
|
+
className: "max-sm:min-h-11",
|
|
29
|
+
onClick: onCancel,
|
|
30
|
+
children: "Cancel"
|
|
31
|
+
}), /* @__PURE__ */ jsxs(Button, {
|
|
32
|
+
variant: "primary",
|
|
33
|
+
type: "submit",
|
|
34
|
+
className: "max-sm:min-h-11 max-sm:flex-1",
|
|
35
|
+
loading: saving,
|
|
36
|
+
children: ["Create ", noun.toLowerCase()]
|
|
37
|
+
})]
|
|
38
|
+
})]
|
|
39
|
+
}),
|
|
40
|
+
message && /* @__PURE__ */ jsx("div", {
|
|
41
|
+
className: "mt-4",
|
|
42
|
+
children: /* @__PURE__ */ jsx(ErrorBanner, {
|
|
43
|
+
title: "Not created",
|
|
44
|
+
error: new Error(message)
|
|
45
|
+
})
|
|
46
|
+
}),
|
|
47
|
+
/* @__PURE__ */ jsx("div", {
|
|
48
|
+
className: "divide-y divide-border",
|
|
49
|
+
children: sections.map((section) => /* @__PURE__ */ jsx(Section, {
|
|
50
|
+
title: section.title,
|
|
51
|
+
help: section.help,
|
|
52
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
53
|
+
className: "grid grid-cols-1 gap-x-6 gap-y-5 sm:grid-cols-2",
|
|
54
|
+
children: section.fields.map((field) => /* @__PURE__ */ jsx(Field, {
|
|
55
|
+
label: field.label,
|
|
56
|
+
required: field.required,
|
|
57
|
+
help: field.help,
|
|
58
|
+
error: field.error,
|
|
59
|
+
children: field.editor
|
|
60
|
+
}, field.name))
|
|
61
|
+
})
|
|
62
|
+
}, section.title))
|
|
63
|
+
})
|
|
64
|
+
]
|
|
65
|
+
})
|
|
66
|
+
});
|
|
67
|
+
//#endregion
|
|
68
|
+
export { CreateRecordForm };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { isEditable, toPatchValue } from "../../live/field-values.js";
|
|
2
|
+
//#region src/app/create/create-values.ts
|
|
3
|
+
const isChosen = (value) => typeof value === "object" && value !== null && "key" in value;
|
|
4
|
+
/** Fields the user can set on a new record: not read-only, not the tenant, and not structured data. */
|
|
5
|
+
const isCreatable = (model, field) => field.name !== model.tenant && !field.readOnly && (field.type === "relation" || isEditable(field));
|
|
6
|
+
/** The view's sections limited to creatable fields, plus a section for any creatable field the layout leaves out. */
|
|
7
|
+
const createSections = (model, view) => {
|
|
8
|
+
const creatable = Object.values(model.fields).filter((field) => isCreatable(model, field)).map((field) => field.name);
|
|
9
|
+
const placed = /* @__PURE__ */ new Set();
|
|
10
|
+
const sections = (view?.layout ?? []).flatMap((item) => {
|
|
11
|
+
if (item.kind !== "section") return [];
|
|
12
|
+
const fields = item.fields.filter((name) => creatable.includes(name) && !placed.has(name));
|
|
13
|
+
fields.forEach((name) => placed.add(name));
|
|
14
|
+
return fields.length > 0 ? [{
|
|
15
|
+
title: item.title,
|
|
16
|
+
help: item.help,
|
|
17
|
+
fields
|
|
18
|
+
}] : [];
|
|
19
|
+
});
|
|
20
|
+
const rest = creatable.filter((name) => !placed.has(name));
|
|
21
|
+
if (rest.length === 0) return sections;
|
|
22
|
+
return [...sections, {
|
|
23
|
+
title: sections.length === 0 ? "Details" : "Other fields",
|
|
24
|
+
fields: rest
|
|
25
|
+
}];
|
|
26
|
+
};
|
|
27
|
+
const keyValue = (target, key) => {
|
|
28
|
+
return (target?.primaryKey.length === 1 ? target.fields[target.primaryKey[0]]?.type : void 0) === "integer" ? Number(key) : key;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* The JSON body for `POST`: only what the user filled in, so the database applies defaults to the rest
|
|
32
|
+
* and the server reports what is still required.
|
|
33
|
+
*/
|
|
34
|
+
const createBody = (model, resources, draft) => Object.fromEntries(Object.entries(draft).flatMap(([name, value]) => {
|
|
35
|
+
const field = model.fields[name];
|
|
36
|
+
if (!field) return [];
|
|
37
|
+
if (isChosen(value)) return [[name, keyValue(resources[field.relation?.resource ?? ""], value.key)]];
|
|
38
|
+
if (field.type === "boolean") return [[name, Boolean(value)]];
|
|
39
|
+
if (String(value).trim() === "") return [];
|
|
40
|
+
return [[name, toPatchValue(field, value)]];
|
|
41
|
+
}));
|
|
42
|
+
/** A field is required when it can be neither empty nor filled in by a default. */
|
|
43
|
+
const isRequired = (field) => !field.nullable && !field.default && field.type !== "boolean";
|
|
44
|
+
/** The form's starting values: literal defaults from `/meta`. Database defaults stay empty so the database fills them. */
|
|
45
|
+
const initialDraft = (model, sections) => Object.fromEntries(sections.flatMap((section) => section.fields.flatMap((name) => {
|
|
46
|
+
const fallback = model.fields[name]?.default;
|
|
47
|
+
if (!fallback || !("value" in fallback) || fallback.value === null || fallback.value === void 0) return [];
|
|
48
|
+
return [[name, typeof fallback.value === "boolean" ? fallback.value : String(fallback.value)]];
|
|
49
|
+
})));
|
|
50
|
+
const hasDatabaseDefault = (field) => Boolean(field.default && "db" in field.default);
|
|
51
|
+
//#endregion
|
|
52
|
+
export { createBody, createSections, hasDatabaseDefault, initialDraft, isChosen, isCreatable, isRequired };
|
|
@@ -0,0 +1,109 @@
|
|
|
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/delete/dialogs.tsx
|
|
5
|
+
const ConfirmDeleteDialog = ({ title, noun, busy, onConfirm, onCancel }) => /* @__PURE__ */ jsx(Dialog, {
|
|
6
|
+
title: `Delete ${title}?`,
|
|
7
|
+
description: `This ${noun.toLowerCase()} will be removed. This cannot be undone.`,
|
|
8
|
+
onClose: onCancel,
|
|
9
|
+
actions: /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Button, {
|
|
10
|
+
onClick: onCancel,
|
|
11
|
+
children: "Cancel"
|
|
12
|
+
}), /* @__PURE__ */ jsx(Button, {
|
|
13
|
+
variant: "primary",
|
|
14
|
+
className: "bg-danger hover:bg-danger/90",
|
|
15
|
+
loading: busy,
|
|
16
|
+
onClick: onConfirm,
|
|
17
|
+
children: "Delete"
|
|
18
|
+
})] })
|
|
19
|
+
});
|
|
20
|
+
const ConflictDialog = ({ title, changes, busy, onReview, onDeleteAnyway, onCancel }) => /* @__PURE__ */ jsx(Dialog, {
|
|
21
|
+
title: `${title} was changed since you opened it`,
|
|
22
|
+
description: "Someone saved this record in the meantime. Review what changed before you delete it.",
|
|
23
|
+
onClose: onCancel,
|
|
24
|
+
actions: /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Button, {
|
|
25
|
+
onClick: onReview,
|
|
26
|
+
children: "Review changes"
|
|
27
|
+
}), /* @__PURE__ */ jsx(Button, {
|
|
28
|
+
variant: "primary",
|
|
29
|
+
className: "bg-danger hover:bg-danger/90",
|
|
30
|
+
loading: busy,
|
|
31
|
+
onClick: onDeleteAnyway,
|
|
32
|
+
children: "Delete anyway"
|
|
33
|
+
})] }),
|
|
34
|
+
children: changes.length === 0 ? /* @__PURE__ */ jsx("p", {
|
|
35
|
+
className: "text-[13px] text-muted-foreground",
|
|
36
|
+
children: "No visible fields differ; the record was saved again with the same values."
|
|
37
|
+
}) : /* @__PURE__ */ jsx("ul", {
|
|
38
|
+
className: "divide-y rounded-md border text-[13px]",
|
|
39
|
+
children: changes.map((change) => /* @__PURE__ */ jsxs("li", {
|
|
40
|
+
className: "grid grid-cols-[96px_1fr] gap-3 px-3 py-2",
|
|
41
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
42
|
+
className: "text-muted-foreground",
|
|
43
|
+
children: change.label
|
|
44
|
+
}), /* @__PURE__ */ jsxs("span", { children: [
|
|
45
|
+
/* @__PURE__ */ jsx("span", {
|
|
46
|
+
className: "text-muted-foreground line-through",
|
|
47
|
+
children: change.before
|
|
48
|
+
}),
|
|
49
|
+
" ",
|
|
50
|
+
/* @__PURE__ */ jsx("span", {
|
|
51
|
+
className: "font-medium",
|
|
52
|
+
children: change.after
|
|
53
|
+
})
|
|
54
|
+
] })]
|
|
55
|
+
}, change.field))
|
|
56
|
+
})
|
|
57
|
+
});
|
|
58
|
+
const BulkDeleteDialog = (props) => {
|
|
59
|
+
if (props.stage === "confirm") return /* @__PURE__ */ jsx(Dialog, {
|
|
60
|
+
title: `Delete ${props.count} ${props.count === 1 ? props.plural.replace(/s$/, "") : props.plural.toLowerCase()}?`,
|
|
61
|
+
description: "This cannot be undone. Each record is deleted only if it has not changed since the list loaded; anything that changed is reported and kept.",
|
|
62
|
+
onClose: props.onCancel,
|
|
63
|
+
actions: /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Button, {
|
|
64
|
+
onClick: props.onCancel,
|
|
65
|
+
children: "Cancel"
|
|
66
|
+
}), /* @__PURE__ */ jsxs(Button, {
|
|
67
|
+
variant: "primary",
|
|
68
|
+
className: "bg-danger hover:bg-danger/90",
|
|
69
|
+
onClick: props.onConfirm,
|
|
70
|
+
children: ["Delete ", props.count]
|
|
71
|
+
})] })
|
|
72
|
+
});
|
|
73
|
+
if (props.stage === "running") return /* @__PURE__ */ jsx(Dialog, {
|
|
74
|
+
title: "Deleting",
|
|
75
|
+
description: `${props.done} of ${props.total} done`,
|
|
76
|
+
onClose: () => void 0,
|
|
77
|
+
actions: /* @__PURE__ */ jsx(Button, {
|
|
78
|
+
loading: true,
|
|
79
|
+
disabled: true,
|
|
80
|
+
children: "Working"
|
|
81
|
+
})
|
|
82
|
+
});
|
|
83
|
+
const deleted = props.total - props.failures.length;
|
|
84
|
+
return /* @__PURE__ */ jsx(Dialog, {
|
|
85
|
+
title: props.failures.length === 0 ? `Deleted ${deleted}` : `Deleted ${deleted} of ${props.total}`,
|
|
86
|
+
description: props.failures.length === 0 ? void 0 : `${props.failures.length} could not be deleted and were kept.`,
|
|
87
|
+
onClose: props.onClose,
|
|
88
|
+
actions: /* @__PURE__ */ jsx(Button, {
|
|
89
|
+
variant: "primary",
|
|
90
|
+
onClick: props.onClose,
|
|
91
|
+
children: "Done"
|
|
92
|
+
}),
|
|
93
|
+
children: props.failures.length > 0 && /* @__PURE__ */ jsx("ul", {
|
|
94
|
+
className: "max-h-48 divide-y overflow-y-auto rounded-md border text-[13px]",
|
|
95
|
+
children: props.failures.map((failure) => /* @__PURE__ */ jsxs("li", {
|
|
96
|
+
className: "px-3 py-2",
|
|
97
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
98
|
+
className: "font-medium",
|
|
99
|
+
children: failure.title
|
|
100
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
101
|
+
className: "block text-xs text-danger-text",
|
|
102
|
+
children: failure.message
|
|
103
|
+
})]
|
|
104
|
+
}, failure.title))
|
|
105
|
+
})
|
|
106
|
+
});
|
|
107
|
+
};
|
|
108
|
+
//#endregion
|
|
109
|
+
export { BulkDeleteDialog, ConfirmDeleteDialog, ConflictDialog };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { humanize } from "../../live/naming.js";
|
|
2
|
+
import { displayValue } from "../../live/display-value.js";
|
|
3
|
+
//#region src/app/delete/record-diff.ts
|
|
4
|
+
const volatile = /* @__PURE__ */ new Set(["updatedAt"]);
|
|
5
|
+
/** The fields whose stored value differs between two reads of a record, for the "changed since you opened it" dialog. */
|
|
6
|
+
const diffRecords = (model, view, before, after) => Object.values(model.fields).flatMap((field) => {
|
|
7
|
+
if (volatile.has(field.name) || JSON.stringify(before[field.name] ?? null) === JSON.stringify(after[field.name] ?? null)) return [];
|
|
8
|
+
const hints = view?.fields[field.name];
|
|
9
|
+
return [{
|
|
10
|
+
field: field.name,
|
|
11
|
+
label: hints?.label ?? humanize(field.name),
|
|
12
|
+
before: displayValue(field, before[field.name], hints),
|
|
13
|
+
after: displayValue(field, after[field.name], hints)
|
|
14
|
+
}];
|
|
15
|
+
});
|
|
16
|
+
//#endregion
|
|
17
|
+
export { diffRecords };
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { useClient } from "../../data/api-provider.js";
|
|
2
|
+
import { humanize } from "../../live/naming.js";
|
|
3
|
+
import { useToast } from "../../toasts/toast-provider.js";
|
|
4
|
+
import { BulkDeleteDialog, ConfirmDeleteDialog, ConflictDialog } from "./dialogs.js";
|
|
5
|
+
import { diffRecords } from "./record-diff.js";
|
|
6
|
+
import { useState } from "react";
|
|
7
|
+
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
8
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
9
|
+
import { PreconditionFailedError, isApiError } from "@protobase/client";
|
|
10
|
+
//#region src/app/delete/use-delete-flow.tsx
|
|
11
|
+
const concurrency = 4;
|
|
12
|
+
/**
|
|
13
|
+
* Delete with `If-Match`: hard deletes ask first, soft deletes go straight through (with Undo when `restore` exists),
|
|
14
|
+
* a 412 opens a dialog that says what changed, and bulk deletes confirm once and report each failure.
|
|
15
|
+
*/
|
|
16
|
+
const useDeleteFlow = ({ model, view, onDeleted, onReview, restore }) => {
|
|
17
|
+
const client = useClient();
|
|
18
|
+
const queryClient = useQueryClient();
|
|
19
|
+
const toast = useToast();
|
|
20
|
+
const [flow, setFlow] = useState();
|
|
21
|
+
const [busy, setBusy] = useState(false);
|
|
22
|
+
const noun = view?.names?.singular ?? humanize(model.name);
|
|
23
|
+
const plural = view?.names?.plural ?? humanize(model.name);
|
|
24
|
+
const refresh = () => {
|
|
25
|
+
for (const prefix of [
|
|
26
|
+
"page",
|
|
27
|
+
"list",
|
|
28
|
+
"first-page"
|
|
29
|
+
]) queryClient.invalidateQueries({ queryKey: [prefix, model.name] });
|
|
30
|
+
for (const prefix of [
|
|
31
|
+
"facets",
|
|
32
|
+
"series",
|
|
33
|
+
"histogram"
|
|
34
|
+
]) queryClient.invalidateQueries({ queryKey: [prefix, model.name] });
|
|
35
|
+
};
|
|
36
|
+
/** Deletes with the given ETag (`*` for any version). Returns false after opening the conflict dialog on a 412. */
|
|
37
|
+
const remove = async (target, etag) => {
|
|
38
|
+
const version = etag ?? (await client.get(model.name, target.key)).etag;
|
|
39
|
+
try {
|
|
40
|
+
await client.remove(model.name, target.key, { etag: version });
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (!(error instanceof PreconditionFailedError)) throw error;
|
|
43
|
+
return error;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
const succeeded = (target) => {
|
|
47
|
+
refresh();
|
|
48
|
+
setFlow(void 0);
|
|
49
|
+
onDeleted?.(target);
|
|
50
|
+
toast.show({
|
|
51
|
+
state: "success",
|
|
52
|
+
title: `${target.title} deleted`,
|
|
53
|
+
...model.softDelete && restore && { action: {
|
|
54
|
+
label: "Undo",
|
|
55
|
+
onClick: () => {
|
|
56
|
+
restore(target).then(() => {
|
|
57
|
+
refresh();
|
|
58
|
+
toast.show({
|
|
59
|
+
state: "success",
|
|
60
|
+
title: `${target.title} restored`
|
|
61
|
+
});
|
|
62
|
+
}, (error) => {
|
|
63
|
+
if (!isApiError(error)) throw error;
|
|
64
|
+
refresh();
|
|
65
|
+
toast.show({
|
|
66
|
+
state: error.status === 409 ? "success" : "error",
|
|
67
|
+
title: error.status === 409 ? `${target.title} is already back` : `Could not restore ${target.title}`,
|
|
68
|
+
description: error.status === 409 ? void 0 : error.message
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
} }
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
const attempt = async (target, etag) => {
|
|
76
|
+
setBusy(true);
|
|
77
|
+
try {
|
|
78
|
+
if (!await remove(target, etag)) return succeeded(target);
|
|
79
|
+
const fresh = (await client.get(model.name, target.key)).record;
|
|
80
|
+
setFlow({
|
|
81
|
+
kind: "conflict",
|
|
82
|
+
target,
|
|
83
|
+
fresh,
|
|
84
|
+
changes: diffRecords(model, view, target.snapshot, fresh)
|
|
85
|
+
});
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (!isApiError(error)) throw error;
|
|
88
|
+
setFlow(void 0);
|
|
89
|
+
toast.show({
|
|
90
|
+
state: "error",
|
|
91
|
+
title: `Could not delete ${target.title}`,
|
|
92
|
+
description: error.message
|
|
93
|
+
});
|
|
94
|
+
} finally {
|
|
95
|
+
setBusy(false);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
const deleteQuietly = async (target) => {
|
|
99
|
+
try {
|
|
100
|
+
return await remove(target, target.etag) ? {
|
|
101
|
+
title: target.title,
|
|
102
|
+
message: "Changed since the list loaded, so it was kept."
|
|
103
|
+
} : void 0;
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (!isApiError(error)) throw error;
|
|
106
|
+
return {
|
|
107
|
+
title: target.title,
|
|
108
|
+
message: error.message
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
const runBulk = async (targets) => {
|
|
113
|
+
const failures = [];
|
|
114
|
+
let done = 0;
|
|
115
|
+
setFlow({
|
|
116
|
+
kind: "bulk-running",
|
|
117
|
+
done,
|
|
118
|
+
total: targets.length
|
|
119
|
+
});
|
|
120
|
+
const queue = [...targets];
|
|
121
|
+
const worker = async () => {
|
|
122
|
+
for (let target = queue.shift(); target; target = queue.shift()) {
|
|
123
|
+
const failure = await deleteQuietly(target);
|
|
124
|
+
if (failure) failures.push(failure);
|
|
125
|
+
done += 1;
|
|
126
|
+
setFlow({
|
|
127
|
+
kind: "bulk-running",
|
|
128
|
+
done,
|
|
129
|
+
total: targets.length
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, targets.length) }, worker));
|
|
134
|
+
refresh();
|
|
135
|
+
setFlow({
|
|
136
|
+
kind: "bulk-done",
|
|
137
|
+
total: targets.length,
|
|
138
|
+
failures
|
|
139
|
+
});
|
|
140
|
+
};
|
|
141
|
+
const close = () => setFlow(void 0);
|
|
142
|
+
return {
|
|
143
|
+
dialogs: flow && /* @__PURE__ */ jsxs(Fragment$1, { children: [
|
|
144
|
+
flow.kind === "confirm" && /* @__PURE__ */ jsx(ConfirmDeleteDialog, {
|
|
145
|
+
title: flow.target.title,
|
|
146
|
+
noun,
|
|
147
|
+
busy,
|
|
148
|
+
onCancel: close,
|
|
149
|
+
onConfirm: () => void attempt(flow.target, flow.target.etag)
|
|
150
|
+
}),
|
|
151
|
+
flow.kind === "conflict" && /* @__PURE__ */ jsx(ConflictDialog, {
|
|
152
|
+
title: flow.target.title,
|
|
153
|
+
changes: flow.changes,
|
|
154
|
+
busy,
|
|
155
|
+
onCancel: close,
|
|
156
|
+
onReview: () => {
|
|
157
|
+
close();
|
|
158
|
+
onReview?.(flow.target, flow.fresh);
|
|
159
|
+
},
|
|
160
|
+
onDeleteAnyway: () => void attempt(flow.target, "*")
|
|
161
|
+
}),
|
|
162
|
+
flow.kind === "bulk-confirm" && /* @__PURE__ */ jsx(BulkDeleteDialog, {
|
|
163
|
+
stage: "confirm",
|
|
164
|
+
count: flow.targets.length,
|
|
165
|
+
plural,
|
|
166
|
+
onCancel: close,
|
|
167
|
+
onConfirm: () => void runBulk(flow.targets)
|
|
168
|
+
}),
|
|
169
|
+
flow.kind === "bulk-running" && /* @__PURE__ */ jsx(BulkDeleteDialog, {
|
|
170
|
+
stage: "running",
|
|
171
|
+
done: flow.done,
|
|
172
|
+
total: flow.total
|
|
173
|
+
}),
|
|
174
|
+
flow.kind === "bulk-done" && /* @__PURE__ */ jsx(BulkDeleteDialog, {
|
|
175
|
+
stage: "done",
|
|
176
|
+
total: flow.total,
|
|
177
|
+
failures: flow.failures,
|
|
178
|
+
onClose: close
|
|
179
|
+
})
|
|
180
|
+
] }),
|
|
181
|
+
/** Soft-delete resources go straight through; others ask first. */
|
|
182
|
+
start: (target) => model.softDelete ? attempt(target, target.etag) : setFlow({
|
|
183
|
+
kind: "confirm",
|
|
184
|
+
target
|
|
185
|
+
}),
|
|
186
|
+
startBulk: (targets) => setFlow({
|
|
187
|
+
kind: "bulk-confirm",
|
|
188
|
+
targets
|
|
189
|
+
})
|
|
190
|
+
};
|
|
191
|
+
};
|
|
192
|
+
//#endregion
|
|
193
|
+
export { useDeleteFlow };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { useClient } from "../../data/api-provider.js";
|
|
2
|
+
//#region src/app/delete/use-restore.ts
|
|
3
|
+
/** Brings a soft-deleted record back (AIP-164), for the Undo toast. */
|
|
4
|
+
const useRestore = (model) => {
|
|
5
|
+
const client = useClient();
|
|
6
|
+
return model.softDelete ? async (target) => void await client.undelete(model.name, target.key) : void 0;
|
|
7
|
+
};
|
|
8
|
+
//#endregion
|
|
9
|
+
export { useRestore };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { guarded } from "../lib/guarded.js";
|
|
2
|
+
//#region src/app/draft-storage.ts
|
|
3
|
+
const draftStorageKey = (userId, resource, recordKey) => `protobase:draft:${userId}:${resource}:${recordKey}`;
|
|
4
|
+
/** The saved draft, only if it was made against the version of the record that is on screen now. */
|
|
5
|
+
const readDraft = (storage, key, etag) => guarded(() => {
|
|
6
|
+
const raw = storage.getItem(key);
|
|
7
|
+
if (raw === null) return void 0;
|
|
8
|
+
const parsed = JSON.parse(raw);
|
|
9
|
+
if (!parsed || parsed.etag !== etag || typeof parsed.fields !== "object" || parsed.fields === null) return void 0;
|
|
10
|
+
return {
|
|
11
|
+
etag,
|
|
12
|
+
fields: parsed.fields,
|
|
13
|
+
...Array.isArray(parsed.order) && { order: parsed.order.map(String) }
|
|
14
|
+
};
|
|
15
|
+
}, void 0);
|
|
16
|
+
const writeDraft = (storage, key, draft) => guarded(() => draft ? storage.setItem(key, JSON.stringify(draft)) : storage.removeItem(key), void 0);
|
|
17
|
+
//#endregion
|
|
18
|
+
export { draftStorageKey, readDraft, writeDraft };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { TriangleAlert } from "lucide-react";
|
|
3
|
+
import { isApiError } from "@protobase/client";
|
|
4
|
+
//#region src/app/error-banner.tsx
|
|
5
|
+
const describe = (error) => {
|
|
6
|
+
if (!isApiError(error)) return [{ message: error instanceof Error ? error.message : "Something went wrong" }];
|
|
7
|
+
const detailed = error.errors.map((entry) => ({
|
|
8
|
+
message: entry.message,
|
|
9
|
+
hint: entry.hint
|
|
10
|
+
}));
|
|
11
|
+
return detailed.length > 0 ? detailed : [{ message: error.message }];
|
|
12
|
+
};
|
|
13
|
+
const ErrorBanner = ({ error, title }) => /* @__PURE__ */ jsxs("div", {
|
|
14
|
+
role: "alert",
|
|
15
|
+
className: "flex gap-2.5 rounded-lg border border-danger/30 bg-danger-soft px-3 py-2 text-[13px] text-danger-text",
|
|
16
|
+
children: [/* @__PURE__ */ jsx(TriangleAlert, { className: "mt-0.5 size-4 shrink-0" }), /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("p", {
|
|
17
|
+
className: "font-medium",
|
|
18
|
+
children: title
|
|
19
|
+
}), describe(error).map((detail) => /* @__PURE__ */ jsxs("p", {
|
|
20
|
+
className: "text-xs",
|
|
21
|
+
children: [detail.message, detail.hint && /* @__PURE__ */ jsxs("span", {
|
|
22
|
+
className: "text-muted-foreground",
|
|
23
|
+
children: [" ", detail.hint]
|
|
24
|
+
})]
|
|
25
|
+
}, detail.message))] })]
|
|
26
|
+
});
|
|
27
|
+
//#endregion
|
|
28
|
+
export { ErrorBanner };
|