@protobase/ui 0.19.4 → 0.19.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/account/account-section.js +2 -2
- package/dist/account/account-security-page.js +2 -2
- package/dist/account/backup-codes.js +3 -10
- package/dist/account/passkeys-section.js +2 -2
- package/dist/account/password-dialog.js +1 -1
- package/dist/account/sign-in-policy-form.js +2 -2
- package/dist/account/sign-in-policy-page.js +2 -2
- package/dist/account/sign-in-providers-section.js +1 -1
- package/dist/account/staff-sign-in-log.js +3 -3
- package/dist/account/two-factor-setup.js +6 -6
- package/dist/action-card/action-card.js +1 -1
- package/dist/activity-chart/activity-chart.js +2 -2
- package/dist/app/delete/dialogs.js +3 -3
- package/dist/app/displays/display-controls.js +1 -1
- package/dist/app/displays/display-frame.js +1 -1
- package/dist/app/displays/list-cards.js +1 -1
- package/dist/app/displays/list-displays.js +2 -1
- package/dist/app/displays/map-display.js +1 -1
- package/dist/app/displays/record-card.js +1 -1
- package/dist/app/embedded-list/embedded-list.js +2 -2
- package/dist/app/error-banner.js +1 -1
- package/dist/app/field-editor.js +6 -12
- package/dist/app/file-field.js +4 -4
- package/dist/app/filled-fields.js +5 -0
- package/dist/app/gallery/details-open-storage.js +10 -0
- package/dist/app/gallery/gallery-layout.js +3 -1
- package/dist/app/gallery/lightbox.js +107 -61
- package/dist/app/gallery/photo-details.js +1 -1
- package/dist/app/import/convert.js +151 -0
- package/dist/app/import/csv-parser.js +119 -0
- package/dist/app/import/failed-rows.js +21 -0
- package/dist/app/import/import-button.js +31 -0
- package/dist/app/import/import-check-step.js +123 -0
- package/dist/app/import/import-counts.js +19 -0
- package/dist/app/import/import-dialog.js +152 -0
- package/dist/app/import/import-file-step.js +65 -0
- package/dist/app/import/import-mapping-step.js +195 -0
- package/dist/app/import/import-result-step.js +86 -0
- package/dist/app/import/import-select.js +35 -0
- package/dist/app/import/locale-formats.js +15 -0
- package/dist/app/import/lookups.js +48 -0
- package/dist/app/import/mapping.js +78 -0
- package/dist/app/import/parse-worker.d.ts +1 -0
- package/dist/app/import/parse-worker.js +39 -0
- package/dist/app/import/prepare-rows.js +129 -0
- package/dist/app/import/run-import.js +130 -0
- package/dist/app/import/sheet-cell.js +17 -0
- package/dist/app/import/sheet-file.js +58 -0
- package/dist/app/import/sheet-source.js +72 -0
- package/dist/app/import/use-import-flow.js +225 -0
- package/dist/app/import/xlsx-reader.js +43 -0
- package/dist/app/import/xlsx-xml.js +169 -0
- package/dist/app/import/zip-reader.js +72 -0
- package/dist/app/list-page.js +8 -1
- package/dist/app/meta-gate.js +3 -3
- package/dist/app/named-icon.js +9 -1
- package/dist/app/pages/blocks/card-frame.js +4 -4
- package/dist/app/pages/blocks/field.js +2 -5
- package/dist/app/pages/blocks/stat.js +2 -2
- package/dist/app/pages/problem.js +1 -1
- package/dist/app/pages/render-node.js +1 -1
- package/dist/app/record-page.js +13 -4
- package/dist/app/record-row/record-row-body.js +3 -3
- package/dist/app/record-row/saved-record-row.js +1 -1
- package/dist/app/related-section.js +1 -1
- package/dist/app/relation-picker.js +1 -1
- package/dist/app/runtime/runtime-update.js +3 -3
- package/dist/app/saved/saved-list.js +1 -1
- package/dist/app/searches/saved-search-rows.js +2 -2
- package/dist/app/searches/saved-search-section.js +1 -1
- package/dist/app/searches/saved-searches.js +1 -1
- package/dist/app/searches/search-controls.js +2 -2
- package/dist/app/searches/search-name-dialog.js +1 -1
- package/dist/app/searches/update-search-dialog.js +16 -19
- package/dist/app/sensitive-field.js +1 -1
- package/dist/app/source-notice.js +3 -3
- package/dist/app/staff-banner.js +1 -1
- package/dist/app-shell/breadcrumb.js +1 -1
- package/dist/app-shell/global-search.js +1 -1
- package/dist/app-shell/page-header.js +1 -1
- package/dist/app-shell/profile-menu.js +3 -3
- package/dist/app-shell/sidebar-group.js +1 -1
- package/dist/app-shell/sidebar-item.js +1 -1
- package/dist/app-shell/sidebar.js +2 -2
- package/dist/assistant-dock/part-view.js +1 -4
- package/dist/assistant-dock/widget-view.js +1 -4
- package/dist/auth/auth-gate.js +2 -2
- package/dist/auth/auth-layout.js +1 -1
- package/dist/auth/code-form.js +2 -2
- package/dist/auth/first-run-page.js +1 -1
- package/dist/auth/forgot-password-form.js +2 -2
- package/dist/auth/provider-sign-in-page.js +1 -1
- package/dist/auth/reset-password-form.js +3 -3
- package/dist/auth/setup-required-form.js +2 -2
- package/dist/auth/setup-required-page.js +1 -1
- package/dist/auth/sign-in-form.js +5 -5
- package/dist/auth/sign-in-prompt.js +3 -3
- package/dist/auth/sign-up-details-form.js +3 -3
- package/dist/auth/sign-up-email-form.js +4 -4
- package/dist/auth/staff-sign-in-form.js +3 -3
- package/dist/auth/two-factor-form.js +5 -5
- package/dist/chat/chat-message.js +1 -1
- package/dist/chat/chat-thread.js +1 -1
- package/dist/data/api-provider.js +8 -2
- package/dist/data-grid/grid-row.js +1 -1
- package/dist/data-grid/header-menu.js +1 -1
- package/dist/data-grid/row-menu.js +1 -1
- package/dist/filter-panel/facet-combobox.js +1 -1
- package/dist/filter-panel/filter-menu.js +1 -1
- package/dist/filter-panel/filter-panel.js +2 -2
- package/dist/inline-child-table/line-menu.js +1 -1
- package/dist/inline-child-table/line-row.js +4 -4
- package/dist/inline-child-table/number-cell.js +1 -1
- package/dist/inline-child-table/totals-footer.js +2 -2
- package/dist/lib/save-file.js +12 -0
- package/dist/lib/use-focus-trap.js +7 -1
- package/dist/organizations/app-roles-picker.js +1 -1
- package/dist/organizations/invitation-page.js +3 -3
- package/dist/organizations/invitations-page.js +2 -2
- package/dist/organizations/members-page.js +2 -2
- package/dist/organizations/organization-page.js +1 -1
- package/dist/organizations/organizations-page.js +2 -2
- package/dist/package.js +1 -1
- package/dist/plugins/before-sign-in.js +1 -1
- package/dist/plugins/plugin-gate.js +1 -1
- package/dist/plugins/plugin-notice.js +1 -1
- package/dist/primitives/button.js +1 -1
- package/dist/primitives/dialog.js +2 -2
- package/dist/primitives/input.js +1 -1
- package/dist/primitives/menu-select.js +2 -2
- package/dist/primitives/responsive-dialog.js +1 -1
- package/dist/primitives/segmented.js +1 -1
- package/dist/primitives/sheet-swipe.js +5 -0
- package/dist/primitives/sheet.js +40 -12
- package/dist/primitives/textarea.js +1 -1
- package/dist/record-view/action-rail.js +1 -1
- package/dist/record-view/field.js +1 -1
- package/dist/record-view/key-facts.js +29 -0
- package/dist/record-view/record-sidebar.js +2 -2
- package/dist/record-view/section.js +2 -2
- package/dist/record-view/spec-table.js +1 -1
- package/dist/shell/assets/app-DwKzLvfm.js +67 -0
- package/dist/shell/assets/index-CwhnZIJp.css +2 -0
- package/dist/shell/assets/{index-CEvfMgFJ.js → index-Dqgcyg7M.js} +1 -1
- package/dist/shell/assets/{maplibre-D6zfLfTV.js → maplibre-BWjNhvrH.js} +1 -1
- package/dist/shell/assets/parse-worker-BtcY0dQw.js +5 -0
- package/dist/shell/assets/{record-map-B6_rbCcG.js → record-map-m5K2uttt.js} +1 -1
- package/dist/shell/assets/{records-map-bQbuIc13.js → records-map-CA_JG9RR.js} +1 -1
- package/dist/shell/assets/{shared-protobase-ui-8pJgwjfc.js → shared-protobase-ui-BBqKlVLh.js} +1 -1
- package/dist/shell/index.html +4 -4
- package/dist/shell/protobase.shell.json +1 -1
- package/dist/side-panel/side-panel.js +1 -1
- package/dist/step-list/step-list.js +1 -1
- package/dist/styles.css +1 -1
- package/dist/theme.css +2 -0
- package/dist/toasts/toast.js +2 -2
- package/package.json +4 -4
- package/dist/shell/assets/app-BYFiTXK6.js +0 -66
- package/dist/shell/assets/index-CiGP6kle.css +0 -2
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
//#region src/app/import/import-counts.tsx
|
|
3
|
+
/** Counts side by side, each under its label, such as the rows an import created and updated. */
|
|
4
|
+
const ImportCounts = ({ label, counts }) => /* @__PURE__ */ jsx("dl", {
|
|
5
|
+
"aria-label": label,
|
|
6
|
+
className: "grid grid-cols-2 gap-3 sm:grid-cols-4",
|
|
7
|
+
children: counts.map(([name, value]) => /* @__PURE__ */ jsxs("div", {
|
|
8
|
+
className: "rounded-lg border px-3 py-2",
|
|
9
|
+
children: [/* @__PURE__ */ jsx("dt", {
|
|
10
|
+
className: "text-xs text-muted-foreground",
|
|
11
|
+
children: name
|
|
12
|
+
}), /* @__PURE__ */ jsx("dd", {
|
|
13
|
+
className: "text-lg font-semibold tabular-nums",
|
|
14
|
+
children: value.toLocaleString("en-IE")
|
|
15
|
+
})]
|
|
16
|
+
}, name))
|
|
17
|
+
});
|
|
18
|
+
//#endregion
|
|
19
|
+
export { ImportCounts };
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { Button } from "../../primitives/button.js";
|
|
2
|
+
import { humanize } from "../../live/naming.js";
|
|
3
|
+
import { ErrorBanner } from "../error-banner.js";
|
|
4
|
+
import { ResponsiveDialog } from "../../primitives/responsive-dialog.js";
|
|
5
|
+
import { unmappedRequired } from "./mapping.js";
|
|
6
|
+
import { ImportCheckStep, ImportChecking } from "./import-check-step.js";
|
|
7
|
+
import { ImportFileStep } from "./import-file-step.js";
|
|
8
|
+
import { ImportMappingStep } from "./import-mapping-step.js";
|
|
9
|
+
import { ImportProgressStep, ImportSummary } from "./import-result-step.js";
|
|
10
|
+
import { useImportFlow } from "./use-import-flow.js";
|
|
11
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
12
|
+
//#region src/app/import/import-dialog.tsx
|
|
13
|
+
const errorTitle = (flow) => flow.step.kind === "file" ? "Could not read the file" : "Could not check the rows";
|
|
14
|
+
const readyToCheck = (flow) => {
|
|
15
|
+
if (flow.columns.length === 0) return false;
|
|
16
|
+
if (flow.mode === "create") return unmappedRequired("create", flow.fields, flow.columns).length === 0;
|
|
17
|
+
return flow.match !== void 0;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Imports records from an .xlsx or CSV file into a resource: choose the file (and its sheet), map its columns to
|
|
21
|
+
* fields, check every row, then write them in batches with progress, Cancel between batches, and a summary with the rows
|
|
22
|
+
* not imported to download.
|
|
23
|
+
*/
|
|
24
|
+
const ImportDialog = ({ model, resources, views, canUpdate, onClose, sourceFor }) => {
|
|
25
|
+
const flow = useImportFlow({
|
|
26
|
+
model,
|
|
27
|
+
resources,
|
|
28
|
+
views,
|
|
29
|
+
sourceFor
|
|
30
|
+
});
|
|
31
|
+
const plural = views[model.name]?.names?.plural ?? humanize(model.name);
|
|
32
|
+
const { step } = flow;
|
|
33
|
+
const headers = flow.head?.headers ?? [];
|
|
34
|
+
const close = () => {
|
|
35
|
+
if (step.kind === "importing") return;
|
|
36
|
+
flow.stop();
|
|
37
|
+
onClose();
|
|
38
|
+
};
|
|
39
|
+
const cancelButton = /* @__PURE__ */ jsx(Button, {
|
|
40
|
+
onClick: close,
|
|
41
|
+
children: "Cancel"
|
|
42
|
+
});
|
|
43
|
+
const actions = () => {
|
|
44
|
+
switch (step.kind) {
|
|
45
|
+
case "file": return /* @__PURE__ */ jsxs(Fragment, { children: [cancelButton, flow.sheets.length > 1 && /* @__PURE__ */ jsx(Button, {
|
|
46
|
+
variant: "primary",
|
|
47
|
+
loading: step.reading,
|
|
48
|
+
onClick: flow.openSheet,
|
|
49
|
+
children: "Next"
|
|
50
|
+
})] });
|
|
51
|
+
case "map": return /* @__PURE__ */ jsxs(Fragment, { children: [cancelButton, /* @__PURE__ */ jsx(Button, {
|
|
52
|
+
variant: "primary",
|
|
53
|
+
disabled: !readyToCheck(flow),
|
|
54
|
+
onClick: flow.check,
|
|
55
|
+
children: "Check rows"
|
|
56
|
+
})] });
|
|
57
|
+
case "checking": return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Button, {
|
|
58
|
+
onClick: flow.back,
|
|
59
|
+
children: "Back"
|
|
60
|
+
}), /* @__PURE__ */ jsx(Button, {
|
|
61
|
+
variant: "primary",
|
|
62
|
+
loading: true,
|
|
63
|
+
children: "Checking"
|
|
64
|
+
})] });
|
|
65
|
+
case "checked": {
|
|
66
|
+
const rows = step.check.creates + step.check.updates;
|
|
67
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Button, {
|
|
68
|
+
onClick: flow.back,
|
|
69
|
+
children: "Back"
|
|
70
|
+
}), /* @__PURE__ */ jsxs(Button, {
|
|
71
|
+
variant: "primary",
|
|
72
|
+
disabled: rows === 0,
|
|
73
|
+
onClick: () => flow.start(rows),
|
|
74
|
+
children: [
|
|
75
|
+
"Import ",
|
|
76
|
+
rows.toLocaleString("en-IE"),
|
|
77
|
+
" ",
|
|
78
|
+
rows === 1 ? "row" : "rows"
|
|
79
|
+
]
|
|
80
|
+
})] });
|
|
81
|
+
}
|
|
82
|
+
case "importing": return /* @__PURE__ */ jsx(Button, {
|
|
83
|
+
variant: "danger",
|
|
84
|
+
disabled: step.cancelling,
|
|
85
|
+
onClick: flow.cancel,
|
|
86
|
+
children: "Cancel import"
|
|
87
|
+
});
|
|
88
|
+
case "done": return /* @__PURE__ */ jsx(Button, {
|
|
89
|
+
variant: "primary",
|
|
90
|
+
onClick: onClose,
|
|
91
|
+
children: "Done"
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
const body = () => {
|
|
96
|
+
switch (step.kind) {
|
|
97
|
+
case "file": return /* @__PURE__ */ jsx(ImportFileStep, {
|
|
98
|
+
file: flow.file,
|
|
99
|
+
sheets: flow.sheets,
|
|
100
|
+
sheet: flow.sheet,
|
|
101
|
+
reading: step.reading,
|
|
102
|
+
onChoose: flow.choose,
|
|
103
|
+
onSheet: flow.setSheet
|
|
104
|
+
});
|
|
105
|
+
case "map": return /* @__PURE__ */ jsx(ImportMappingStep, {
|
|
106
|
+
flow,
|
|
107
|
+
model,
|
|
108
|
+
resources,
|
|
109
|
+
views,
|
|
110
|
+
canUpdate
|
|
111
|
+
});
|
|
112
|
+
case "checking": return /* @__PURE__ */ jsx(ImportChecking, { rows: step.rows });
|
|
113
|
+
case "checked": return /* @__PURE__ */ jsx(ImportCheckStep, {
|
|
114
|
+
check: step.check,
|
|
115
|
+
model,
|
|
116
|
+
resources,
|
|
117
|
+
views,
|
|
118
|
+
columns: flow.columns,
|
|
119
|
+
headers,
|
|
120
|
+
fileName: flow.file?.name
|
|
121
|
+
});
|
|
122
|
+
case "importing": return /* @__PURE__ */ jsx(ImportProgressStep, {
|
|
123
|
+
progress: step.progress,
|
|
124
|
+
total: step.total,
|
|
125
|
+
cancelling: step.cancelling
|
|
126
|
+
});
|
|
127
|
+
case "done": return /* @__PURE__ */ jsx(ImportSummary, {
|
|
128
|
+
result: step.result,
|
|
129
|
+
stopped: step.stopped,
|
|
130
|
+
headers,
|
|
131
|
+
fileName: flow.file?.name
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
return /* @__PURE__ */ jsx(ResponsiveDialog, {
|
|
136
|
+
open: true,
|
|
137
|
+
wide: true,
|
|
138
|
+
title: `Import ${plural.toLowerCase()}`,
|
|
139
|
+
description: flow.file && step.kind !== "file" ? flow.file.name : void 0,
|
|
140
|
+
onClose: close,
|
|
141
|
+
actions: actions(),
|
|
142
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
143
|
+
className: "flex flex-col gap-4",
|
|
144
|
+
children: [flow.error !== void 0 && /* @__PURE__ */ jsx(ErrorBanner, {
|
|
145
|
+
title: errorTitle(flow),
|
|
146
|
+
error: flow.error
|
|
147
|
+
}), body()]
|
|
148
|
+
})
|
|
149
|
+
});
|
|
150
|
+
};
|
|
151
|
+
//#endregion
|
|
152
|
+
export { ImportDialog };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Spinner } from "../../primitives/spinner.js";
|
|
2
|
+
import { ImportSelect } from "./import-select.js";
|
|
3
|
+
import { useId } from "react";
|
|
4
|
+
import { FileSpreadsheet } from "lucide-react";
|
|
5
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
6
|
+
//#region src/app/import/import-file-step.tsx
|
|
7
|
+
/** The file to import, an .xlsx workbook or a CSV, and the sheet to read from a workbook with several. */
|
|
8
|
+
const ImportFileStep = ({ file, sheets, sheet, reading, onChoose, onSheet }) => {
|
|
9
|
+
const id = useId();
|
|
10
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
11
|
+
className: "flex flex-col gap-4",
|
|
12
|
+
children: [
|
|
13
|
+
/* @__PURE__ */ jsxs("div", {
|
|
14
|
+
className: "flex flex-col gap-1.5",
|
|
15
|
+
children: [
|
|
16
|
+
/* @__PURE__ */ jsx("label", {
|
|
17
|
+
htmlFor: id,
|
|
18
|
+
className: "font-medium",
|
|
19
|
+
children: "File"
|
|
20
|
+
}),
|
|
21
|
+
/* @__PURE__ */ jsx("input", {
|
|
22
|
+
id,
|
|
23
|
+
type: "file",
|
|
24
|
+
accept: ".xlsx,.csv,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
25
|
+
disabled: reading,
|
|
26
|
+
onChange: (event) => {
|
|
27
|
+
const chosen = event.target.files?.[0];
|
|
28
|
+
if (chosen) onChoose(chosen);
|
|
29
|
+
},
|
|
30
|
+
className: "file:mr-3 file:h-8 file:rounded-md file:border file:border-border-strong file:bg-background file:px-3 file:font-medium file:text-foreground max-sm:file:min-h-11"
|
|
31
|
+
}),
|
|
32
|
+
/* @__PURE__ */ jsx("p", {
|
|
33
|
+
className: "text-xs text-muted-foreground",
|
|
34
|
+
children: "An Excel workbook (.xlsx) or a CSV file saved as UTF-8. Its first row names the columns."
|
|
35
|
+
})
|
|
36
|
+
]
|
|
37
|
+
}),
|
|
38
|
+
reading && /* @__PURE__ */ jsxs("p", {
|
|
39
|
+
className: "flex items-center gap-2 text-muted-foreground",
|
|
40
|
+
role: "status",
|
|
41
|
+
children: [
|
|
42
|
+
/* @__PURE__ */ jsx(Spinner, { className: "size-3.5" }),
|
|
43
|
+
"Reading ",
|
|
44
|
+
file?.name ?? "the file",
|
|
45
|
+
"…"
|
|
46
|
+
]
|
|
47
|
+
}),
|
|
48
|
+
!reading && sheets.length > 1 && /* @__PURE__ */ jsxs("div", {
|
|
49
|
+
className: "flex items-end gap-3",
|
|
50
|
+
children: [/* @__PURE__ */ jsx(FileSpreadsheet, { className: "mb-2 size-4 shrink-0 text-muted-foreground" }), /* @__PURE__ */ jsx(ImportSelect, {
|
|
51
|
+
className: "flex-1",
|
|
52
|
+
label: "Sheet",
|
|
53
|
+
value: sheet ?? "",
|
|
54
|
+
options: sheets.map((name) => ({
|
|
55
|
+
value: name,
|
|
56
|
+
label: name
|
|
57
|
+
})),
|
|
58
|
+
onChange: onSheet
|
|
59
|
+
})]
|
|
60
|
+
})
|
|
61
|
+
]
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
//#endregion
|
|
65
|
+
export { ImportFileStep };
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { Segmented } from "../../primitives/segmented.js";
|
|
2
|
+
import { cellText, columnName } from "./sheet-cell.js";
|
|
3
|
+
import { fieldLabel, matchFields, relationModel, requiredOnCreate, unmappedRequired } from "./mapping.js";
|
|
4
|
+
import { ImportSelect } from "./import-select.js";
|
|
5
|
+
import { TriangleAlert } from "lucide-react";
|
|
6
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
7
|
+
//#region src/app/import/import-mapping-step.tsx
|
|
8
|
+
const modes = [
|
|
9
|
+
{
|
|
10
|
+
value: "create",
|
|
11
|
+
label: "Create"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
value: "update",
|
|
15
|
+
label: "Update"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
value: "upsert",
|
|
19
|
+
label: "Create or update"
|
|
20
|
+
}
|
|
21
|
+
];
|
|
22
|
+
const modeHelp = {
|
|
23
|
+
create: "Each row creates a record.",
|
|
24
|
+
update: "Each row updates the record its value finds; a row that finds none is not imported.",
|
|
25
|
+
upsert: "Each row updates the record its value finds, or creates one when it finds none."
|
|
26
|
+
};
|
|
27
|
+
const skip = "";
|
|
28
|
+
/**
|
|
29
|
+
* Which field each column of the file fills: matched by header to a field's label or name, changed or skipped here. A
|
|
30
|
+
* relation's column also says which field of the related records its values are, and an update which column finds the
|
|
31
|
+
* records it changes.
|
|
32
|
+
*/
|
|
33
|
+
const ImportMappingStep = ({ flow, model, resources, views, canUpdate }) => {
|
|
34
|
+
const view = views[model.name];
|
|
35
|
+
const headers = flow.head?.headers ?? [];
|
|
36
|
+
const sample = flow.head?.sample ?? [];
|
|
37
|
+
const missing = unmappedRequired(flow.mode, flow.fields, flow.columns);
|
|
38
|
+
const fieldOptions = [{
|
|
39
|
+
value: skip,
|
|
40
|
+
label: "Do not import"
|
|
41
|
+
}, ...flow.fields.map((field) => ({
|
|
42
|
+
value: field.name,
|
|
43
|
+
label: `${fieldLabel(view, field.name)}${requiredOnCreate(field) ? " *" : ""}`
|
|
44
|
+
}))];
|
|
45
|
+
const columnOptions = headers.map((_, column) => ({
|
|
46
|
+
value: String(column),
|
|
47
|
+
label: columnName(headers, column)
|
|
48
|
+
}));
|
|
49
|
+
const keys = matchFields(model);
|
|
50
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
51
|
+
className: "flex flex-col gap-5",
|
|
52
|
+
children: [
|
|
53
|
+
canUpdate && /* @__PURE__ */ jsxs("div", {
|
|
54
|
+
className: "flex flex-col gap-1.5",
|
|
55
|
+
children: [
|
|
56
|
+
/* @__PURE__ */ jsx("span", {
|
|
57
|
+
className: "font-medium",
|
|
58
|
+
children: "Rows"
|
|
59
|
+
}),
|
|
60
|
+
/* @__PURE__ */ jsx("div", { children: /* @__PURE__ */ jsx(Segmented, {
|
|
61
|
+
label: "What each row does",
|
|
62
|
+
size: "md",
|
|
63
|
+
value: flow.mode,
|
|
64
|
+
options: modes,
|
|
65
|
+
onChange: flow.setMode
|
|
66
|
+
}) }),
|
|
67
|
+
/* @__PURE__ */ jsx("p", {
|
|
68
|
+
className: "text-xs text-muted-foreground",
|
|
69
|
+
children: modeHelp[flow.mode]
|
|
70
|
+
})
|
|
71
|
+
]
|
|
72
|
+
}),
|
|
73
|
+
flow.mode !== "create" && /* @__PURE__ */ jsxs("div", {
|
|
74
|
+
className: "grid grid-cols-1 gap-3 sm:grid-cols-2",
|
|
75
|
+
children: [/* @__PURE__ */ jsx(ImportSelect, {
|
|
76
|
+
label: "Find records by",
|
|
77
|
+
value: flow.match?.field ?? skip,
|
|
78
|
+
options: [{
|
|
79
|
+
value: skip,
|
|
80
|
+
label: "Choose a field"
|
|
81
|
+
}, ...keys.map((field) => ({
|
|
82
|
+
value: field.name,
|
|
83
|
+
label: fieldLabel(view, field.name)
|
|
84
|
+
}))],
|
|
85
|
+
onChange: (field) => flow.setMatch(field ? {
|
|
86
|
+
field,
|
|
87
|
+
column: flow.match?.column ?? 0
|
|
88
|
+
} : void 0),
|
|
89
|
+
help: "A field no two records share, such as the key or an email."
|
|
90
|
+
}), /* @__PURE__ */ jsx(ImportSelect, {
|
|
91
|
+
label: "Found in column",
|
|
92
|
+
value: flow.match ? String(flow.match.column) : skip,
|
|
93
|
+
options: [{
|
|
94
|
+
value: skip,
|
|
95
|
+
label: "Choose a column"
|
|
96
|
+
}, ...columnOptions],
|
|
97
|
+
onChange: (column) => flow.setMatch(column && flow.match ? {
|
|
98
|
+
...flow.match,
|
|
99
|
+
column: Number(column)
|
|
100
|
+
} : flow.match)
|
|
101
|
+
})]
|
|
102
|
+
}),
|
|
103
|
+
/* @__PURE__ */ jsxs("div", {
|
|
104
|
+
className: "grid grid-cols-1 gap-3 sm:grid-cols-2",
|
|
105
|
+
children: [/* @__PURE__ */ jsx(ImportSelect, {
|
|
106
|
+
label: "Numbers",
|
|
107
|
+
value: flow.options.decimal,
|
|
108
|
+
options: [{
|
|
109
|
+
value: ".",
|
|
110
|
+
label: "1,234.56"
|
|
111
|
+
}, {
|
|
112
|
+
value: ",",
|
|
113
|
+
label: "1.234,56"
|
|
114
|
+
}],
|
|
115
|
+
onChange: (decimal) => flow.setOptions({
|
|
116
|
+
...flow.options,
|
|
117
|
+
decimal
|
|
118
|
+
})
|
|
119
|
+
}), /* @__PURE__ */ jsx(ImportSelect, {
|
|
120
|
+
label: "Dates",
|
|
121
|
+
value: flow.options.dateOrder,
|
|
122
|
+
options: [{
|
|
123
|
+
value: "dmy",
|
|
124
|
+
label: "Day first (31/12/2026)"
|
|
125
|
+
}, {
|
|
126
|
+
value: "mdy",
|
|
127
|
+
label: "Month first (12/31/2026)"
|
|
128
|
+
}],
|
|
129
|
+
onChange: (dateOrder) => flow.setOptions({
|
|
130
|
+
...flow.options,
|
|
131
|
+
dateOrder
|
|
132
|
+
}),
|
|
133
|
+
help: "Dates written as 2026-12-31 read the same either way. Times without a zone are your own time."
|
|
134
|
+
})]
|
|
135
|
+
}),
|
|
136
|
+
flow.mode !== "update" && /* @__PURE__ */ jsx("p", {
|
|
137
|
+
className: "-mb-3 text-xs text-muted-foreground",
|
|
138
|
+
children: "New records need the fields marked *."
|
|
139
|
+
}),
|
|
140
|
+
/* @__PURE__ */ jsx("section", {
|
|
141
|
+
"aria-label": "Columns",
|
|
142
|
+
className: "flex flex-col divide-y divide-border border-y",
|
|
143
|
+
children: headers.map((_, column) => {
|
|
144
|
+
const mapped = flow.columns.find((entry) => entry.column === column);
|
|
145
|
+
const field = mapped ? model.fields[mapped.field] : void 0;
|
|
146
|
+
const target = field ? relationModel(field, resources) : void 0;
|
|
147
|
+
const examples = sample.map((row) => cellText(row.cells[column] ?? null)).filter((text) => text !== "").slice(0, 3);
|
|
148
|
+
const name = columnName(headers, column);
|
|
149
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
150
|
+
className: "grid grid-cols-1 gap-2 py-3 sm:grid-cols-2 sm:gap-4",
|
|
151
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
152
|
+
className: "min-w-0",
|
|
153
|
+
children: [/* @__PURE__ */ jsx("p", {
|
|
154
|
+
className: "truncate font-medium",
|
|
155
|
+
children: name
|
|
156
|
+
}), /* @__PURE__ */ jsx("p", {
|
|
157
|
+
className: "truncate text-xs text-muted-foreground",
|
|
158
|
+
children: examples.length > 0 ? examples.join(", ") : "Empty in the first rows"
|
|
159
|
+
})]
|
|
160
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
161
|
+
className: "flex min-w-0 flex-col gap-2",
|
|
162
|
+
children: [/* @__PURE__ */ jsx(ImportSelect, {
|
|
163
|
+
label: "Field",
|
|
164
|
+
name: `Field for ${name}`,
|
|
165
|
+
value: mapped?.field ?? skip,
|
|
166
|
+
options: fieldOptions,
|
|
167
|
+
onChange: (next) => flow.mapColumn(column, next || void 0)
|
|
168
|
+
}), target && /* @__PURE__ */ jsx(ImportSelect, {
|
|
169
|
+
label: "Found by",
|
|
170
|
+
name: `${views[target.name]?.names?.plural ?? fieldLabel(view, field.name)} found by`,
|
|
171
|
+
value: mapped.matchBy ?? target.primaryKey[0],
|
|
172
|
+
options: matchFields(target).map((candidate) => ({
|
|
173
|
+
value: candidate.name,
|
|
174
|
+
label: fieldLabel(views[target.name], candidate.name)
|
|
175
|
+
})),
|
|
176
|
+
onChange: (matchBy) => flow.matchColumnBy(column, matchBy)
|
|
177
|
+
})]
|
|
178
|
+
})]
|
|
179
|
+
}, column);
|
|
180
|
+
})
|
|
181
|
+
}),
|
|
182
|
+
missing.length > 0 && /* @__PURE__ */ jsxs("p", {
|
|
183
|
+
role: "alert",
|
|
184
|
+
className: "flex gap-2 rounded-lg border border-warning/30 bg-warning-soft px-3 py-2 text-warning-text",
|
|
185
|
+
children: [/* @__PURE__ */ jsx(TriangleAlert, { className: "mt-0.5 size-4 shrink-0" }), /* @__PURE__ */ jsxs("span", { children: [
|
|
186
|
+
"No column fills ",
|
|
187
|
+
missing.map((field) => fieldLabel(view, field.name)).join(", "),
|
|
188
|
+
", which new records need."
|
|
189
|
+
] })]
|
|
190
|
+
})
|
|
191
|
+
]
|
|
192
|
+
});
|
|
193
|
+
};
|
|
194
|
+
//#endregion
|
|
195
|
+
export { ImportMappingStep };
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Button } from "../../primitives/button.js";
|
|
2
|
+
import { ErrorBanner } from "../error-banner.js";
|
|
3
|
+
import { ImportCounts } from "./import-counts.js";
|
|
4
|
+
import { ProblemList, downloadRows } from "./import-check-step.js";
|
|
5
|
+
import { Download } from "lucide-react";
|
|
6
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
7
|
+
//#region src/app/import/import-result-step.tsx
|
|
8
|
+
const count = (value) => value.toLocaleString("en-IE");
|
|
9
|
+
/** The rows written so far out of those to write, as a bar. */
|
|
10
|
+
const ImportProgressStep = ({ progress, total, cancelling }) => {
|
|
11
|
+
const done = progress.created + progress.updated + progress.failed;
|
|
12
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
13
|
+
className: "flex flex-col gap-2",
|
|
14
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
15
|
+
className: "flex items-baseline justify-between gap-2 text-xs",
|
|
16
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
17
|
+
className: "text-muted-foreground",
|
|
18
|
+
children: cancelling ? "Stopping after this batch…" : "Saving rows…"
|
|
19
|
+
}), /* @__PURE__ */ jsxs("span", {
|
|
20
|
+
className: "tabular-nums",
|
|
21
|
+
children: [
|
|
22
|
+
count(done),
|
|
23
|
+
" / ",
|
|
24
|
+
count(total)
|
|
25
|
+
]
|
|
26
|
+
})]
|
|
27
|
+
}), /* @__PURE__ */ jsx("div", {
|
|
28
|
+
role: "progressbar",
|
|
29
|
+
"aria-label": "Rows saved",
|
|
30
|
+
"aria-valuenow": done,
|
|
31
|
+
"aria-valuemin": 0,
|
|
32
|
+
"aria-valuemax": total,
|
|
33
|
+
className: "h-2 overflow-hidden rounded-full bg-muted",
|
|
34
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
35
|
+
className: "h-full rounded-full bg-primary transition-[width]",
|
|
36
|
+
style: { width: `${total > 0 ? Math.min(100, done / total * 100) : 0}%` }
|
|
37
|
+
})
|
|
38
|
+
})]
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
/** What the import did: created, updated, skipped and failed rows, and the rows it did not write with the reasons, to download. */
|
|
42
|
+
const ImportSummary = ({ result, stopped, headers, fileName }) => /* @__PURE__ */ jsxs("div", {
|
|
43
|
+
className: "flex flex-col gap-4",
|
|
44
|
+
children: [
|
|
45
|
+
stopped !== void 0 && /* @__PURE__ */ jsx(ErrorBanner, {
|
|
46
|
+
title: "The import stopped. The rows counted below were saved.",
|
|
47
|
+
error: stopped
|
|
48
|
+
}),
|
|
49
|
+
result.cancelled && /* @__PURE__ */ jsx("p", {
|
|
50
|
+
role: "status",
|
|
51
|
+
children: "Cancelled. The rows counted below were saved."
|
|
52
|
+
}),
|
|
53
|
+
/* @__PURE__ */ jsx(ImportCounts, {
|
|
54
|
+
label: "Import summary",
|
|
55
|
+
counts: [
|
|
56
|
+
["Created", result.created],
|
|
57
|
+
["Updated", result.updated],
|
|
58
|
+
["Skipped", result.skipped],
|
|
59
|
+
["Failed", result.failed]
|
|
60
|
+
]
|
|
61
|
+
}),
|
|
62
|
+
result.notWritten.length > 0 && /* @__PURE__ */ jsxs("div", {
|
|
63
|
+
className: "flex flex-col gap-2",
|
|
64
|
+
children: [
|
|
65
|
+
/* @__PURE__ */ jsxs("div", {
|
|
66
|
+
className: "flex items-center justify-between gap-2",
|
|
67
|
+
children: [/* @__PURE__ */ jsx("h3", {
|
|
68
|
+
className: "font-medium",
|
|
69
|
+
children: "Rows not imported"
|
|
70
|
+
}), /* @__PURE__ */ jsxs(Button, {
|
|
71
|
+
size: "sm",
|
|
72
|
+
onClick: () => downloadRows(fileName, headers, result.notWritten),
|
|
73
|
+
children: [/* @__PURE__ */ jsx(Download, { className: "size-3.5" }), "Download"]
|
|
74
|
+
})]
|
|
75
|
+
}),
|
|
76
|
+
/* @__PURE__ */ jsx("p", {
|
|
77
|
+
className: "text-xs text-muted-foreground",
|
|
78
|
+
children: "Skipped rows had problems in the file; the server refused the failed ones. The file holds each with the reason, to fix and import again."
|
|
79
|
+
}),
|
|
80
|
+
/* @__PURE__ */ jsx(ProblemList, { rows: [...result.notWritten].sort((a, b) => a.line - b.line) })
|
|
81
|
+
]
|
|
82
|
+
})
|
|
83
|
+
]
|
|
84
|
+
});
|
|
85
|
+
//#endregion
|
|
86
|
+
export { ImportProgressStep, ImportSummary };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { cn } from "../../lib/cn.js";
|
|
2
|
+
import { useId } from "react";
|
|
3
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
4
|
+
//#region src/app/import/import-select.tsx
|
|
5
|
+
/** A labelled native select, as the field editors draw theirs. */
|
|
6
|
+
const ImportSelect = ({ label, name, value, options, onChange, help, className }) => {
|
|
7
|
+
const id = useId();
|
|
8
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
9
|
+
className: cn("flex min-w-0 flex-col gap-1.5", className),
|
|
10
|
+
children: [
|
|
11
|
+
/* @__PURE__ */ jsx("label", {
|
|
12
|
+
htmlFor: id,
|
|
13
|
+
className: "font-medium",
|
|
14
|
+
children: label
|
|
15
|
+
}),
|
|
16
|
+
/* @__PURE__ */ jsx("select", {
|
|
17
|
+
id,
|
|
18
|
+
"aria-label": name,
|
|
19
|
+
value,
|
|
20
|
+
onChange: (event) => onChange(event.target.value),
|
|
21
|
+
className: "h-8 w-full min-w-0 rounded-md border border-border-strong bg-background px-2 shadow-sm focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 max-sm:min-h-11",
|
|
22
|
+
children: options.map((option) => /* @__PURE__ */ jsx("option", {
|
|
23
|
+
value: option.value,
|
|
24
|
+
children: option.label
|
|
25
|
+
}, option.value))
|
|
26
|
+
}),
|
|
27
|
+
help && /* @__PURE__ */ jsx("p", {
|
|
28
|
+
className: "text-xs text-muted-foreground",
|
|
29
|
+
children: help
|
|
30
|
+
})
|
|
31
|
+
]
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
//#endregion
|
|
35
|
+
export { ImportSelect };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
//#region src/app/import/locale-formats.ts
|
|
2
|
+
/**
|
|
3
|
+
* How the browser's locale writes numbers and dates, as the import's starting choice: its decimal separator, and whether
|
|
4
|
+
* its dates put the day or the month first. The user sees both and can change them.
|
|
5
|
+
*/
|
|
6
|
+
const localeFormats = (locale) => {
|
|
7
|
+
const decimal = new Intl.NumberFormat(locale).formatToParts(1.5).find((part) => part.type === "decimal")?.value === "," ? "," : ".";
|
|
8
|
+
const parts = new Intl.DateTimeFormat(locale).formatToParts(new Date(2026, 3, 3)).map((part) => part.type);
|
|
9
|
+
return {
|
|
10
|
+
decimal,
|
|
11
|
+
dateOrder: parts.indexOf("month") < parts.indexOf("day") ? "mdy" : "dmy"
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
//#endregion
|
|
15
|
+
export { localeFormats };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createWhere, printFilter } from "@protobase/schema";
|
|
2
|
+
//#region src/app/import/lookups.ts
|
|
3
|
+
const where = createWhere();
|
|
4
|
+
const valuesPerRequest = 100;
|
|
5
|
+
/** A value as the lookups compare it: the API answers numbers as numbers and the rest as text. */
|
|
6
|
+
const lookupKey = (value) => String(value);
|
|
7
|
+
/**
|
|
8
|
+
* Finds records by the value of a field, for the relations of an import's rows and the records it updates: each value
|
|
9
|
+
* is asked for once per import, a hundred at a time, and remembered.
|
|
10
|
+
*/
|
|
11
|
+
const createLookups = (clientFor) => {
|
|
12
|
+
const known = /* @__PURE__ */ new Map();
|
|
13
|
+
const foundOf = (resource, field) => {
|
|
14
|
+
const id = `${resource}.${field}`;
|
|
15
|
+
if (!known.has(id)) known.set(id, /* @__PURE__ */ new Map());
|
|
16
|
+
return known.get(id);
|
|
17
|
+
};
|
|
18
|
+
/** Asks the API for the values not asked for yet; the user reads only the records they may list. */
|
|
19
|
+
const load = async (model, field, values) => {
|
|
20
|
+
const found = foundOf(model.name, field);
|
|
21
|
+
const missing = [...new Map(values.filter((value) => !found.has(lookupKey(value))).map((value) => [lookupKey(value), value])).values()];
|
|
22
|
+
const key = model.primaryKey[0];
|
|
23
|
+
for (let at = 0; at < missing.length; at += valuesPerRequest) {
|
|
24
|
+
const asked = missing.slice(at, at + valuesPerRequest);
|
|
25
|
+
for (const value of asked) found.set(lookupKey(value), []);
|
|
26
|
+
const filter = printFilter(where.in(field, asked));
|
|
27
|
+
let pageToken;
|
|
28
|
+
do {
|
|
29
|
+
const page = await clientFor(model.name).list(model.name, {
|
|
30
|
+
filter,
|
|
31
|
+
fields: [.../* @__PURE__ */ new Set([key, field])],
|
|
32
|
+
pageSize: 1e3,
|
|
33
|
+
pageToken
|
|
34
|
+
});
|
|
35
|
+
for (const record of page.items) found.get(lookupKey(record[field]))?.push(record[key]);
|
|
36
|
+
pageToken = page.nextPageToken || void 0;
|
|
37
|
+
} while (pageToken);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
/** The keys of the records found by the value, once `load` asked for it. */
|
|
41
|
+
const keysOf = (resource, field, value) => foundOf(resource, field).get(lookupKey(value)) ?? [];
|
|
42
|
+
return {
|
|
43
|
+
load,
|
|
44
|
+
keysOf
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
//#endregion
|
|
48
|
+
export { createLookups, lookupKey };
|