@protobase/ui 0.19.4 → 0.19.5

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.
Files changed (55) hide show
  1. package/dist/account/backup-codes.js +2 -9
  2. package/dist/app/displays/list-displays.js +2 -1
  3. package/dist/app/filled-fields.js +5 -0
  4. package/dist/app/gallery/details-open-storage.js +10 -0
  5. package/dist/app/gallery/gallery-layout.js +3 -1
  6. package/dist/app/gallery/lightbox.js +107 -61
  7. package/dist/app/import/convert.js +151 -0
  8. package/dist/app/import/csv-parser.js +119 -0
  9. package/dist/app/import/failed-rows.js +21 -0
  10. package/dist/app/import/import-button.js +31 -0
  11. package/dist/app/import/import-check-step.js +123 -0
  12. package/dist/app/import/import-counts.js +19 -0
  13. package/dist/app/import/import-dialog.js +152 -0
  14. package/dist/app/import/import-file-step.js +65 -0
  15. package/dist/app/import/import-mapping-step.js +195 -0
  16. package/dist/app/import/import-result-step.js +86 -0
  17. package/dist/app/import/import-select.js +35 -0
  18. package/dist/app/import/locale-formats.js +15 -0
  19. package/dist/app/import/lookups.js +48 -0
  20. package/dist/app/import/mapping.js +78 -0
  21. package/dist/app/import/parse-worker.d.ts +1 -0
  22. package/dist/app/import/parse-worker.js +39 -0
  23. package/dist/app/import/prepare-rows.js +129 -0
  24. package/dist/app/import/run-import.js +130 -0
  25. package/dist/app/import/sheet-cell.js +17 -0
  26. package/dist/app/import/sheet-file.js +58 -0
  27. package/dist/app/import/sheet-source.js +72 -0
  28. package/dist/app/import/use-import-flow.js +225 -0
  29. package/dist/app/import/xlsx-reader.js +43 -0
  30. package/dist/app/import/xlsx-xml.js +169 -0
  31. package/dist/app/import/zip-reader.js +72 -0
  32. package/dist/app/list-page.js +8 -1
  33. package/dist/app/named-icon.js +9 -1
  34. package/dist/app/record-page.js +11 -2
  35. package/dist/auth/sign-in-prompt.js +1 -1
  36. package/dist/data/api-provider.js +8 -2
  37. package/dist/lib/save-file.js +12 -0
  38. package/dist/lib/use-focus-trap.js +7 -1
  39. package/dist/package.js +1 -1
  40. package/dist/primitives/sheet-swipe.js +5 -0
  41. package/dist/primitives/sheet.js +40 -12
  42. package/dist/record-view/key-facts.js +29 -0
  43. package/dist/shell/assets/app-CZ4drn7G.js +67 -0
  44. package/dist/shell/assets/index-BoeKjvo8.css +2 -0
  45. package/dist/shell/assets/{index-CEvfMgFJ.js → index-BzCCIfII.js} +1 -1
  46. package/dist/shell/assets/{maplibre-D6zfLfTV.js → maplibre-BOsAtlAo.js} +1 -1
  47. package/dist/shell/assets/parse-worker-BtcY0dQw.js +5 -0
  48. package/dist/shell/assets/{record-map-B6_rbCcG.js → record-map-BK_02Lmk.js} +1 -1
  49. package/dist/shell/assets/{records-map-bQbuIc13.js → records-map-Dn72A_l-.js} +1 -1
  50. package/dist/shell/assets/{shared-protobase-ui-8pJgwjfc.js → shared-protobase-ui-B6lrj7eA.js} +1 -1
  51. package/dist/shell/index.html +4 -4
  52. package/dist/shell/protobase.shell.json +1 -1
  53. package/package.json +4 -4
  54. package/dist/shell/assets/app-BYFiTXK6.js +0 -66
  55. package/dist/shell/assets/index-CiGP6kle.css +0 -2
@@ -0,0 +1,123 @@
1
+ import { Spinner } from "../../primitives/spinner.js";
2
+ import { Button } from "../../primitives/button.js";
3
+ import { saveFile } from "../../lib/save-file.js";
4
+ import { displayValue } from "../../live/display-value.js";
5
+ import { failedRowsCsv } from "./failed-rows.js";
6
+ import { ImportCounts } from "./import-counts.js";
7
+ import { fieldLabel, relationModel } from "./mapping.js";
8
+ import { Download } from "lucide-react";
9
+ import { jsx, jsxs } from "react/jsx-runtime";
10
+ //#region src/app/import/import-check-step.tsx
11
+ /** How many rows with problems the check lists; the file of them holds every one. */
12
+ const listedProblems = 50;
13
+ /** Saves the rows with their problems as a CSV named after the imported file. */
14
+ const downloadRows = (fileName, headers, rows) => saveFile(failedRowsCsv(headers, rows), `${(fileName ?? "import").replace(/\.[^.]+$/, "")} - not imported.csv`, "text/csv");
15
+ /** The rows with what keeps each from being imported, at most `listedProblems` of them. */
16
+ const ProblemList = ({ rows }) => /* @__PURE__ */ jsxs("ul", {
17
+ "aria-label": "Rows not imported",
18
+ className: "flex flex-col gap-1 text-xs",
19
+ children: [rows.slice(0, listedProblems).map((row) => /* @__PURE__ */ jsxs("li", { children: [
20
+ /* @__PURE__ */ jsxs("span", {
21
+ className: "font-medium",
22
+ children: [
23
+ "Row ",
24
+ row.line,
25
+ ":"
26
+ ]
27
+ }),
28
+ " ",
29
+ row.problems.join("; ")
30
+ ] }, row.line)), rows.length > listedProblems && /* @__PURE__ */ jsxs("li", {
31
+ className: "text-muted-foreground",
32
+ children: [
33
+ "And ",
34
+ (rows.length - listedProblems).toLocaleString("en-IE"),
35
+ " more rows, all in the file."
36
+ ]
37
+ })]
38
+ });
39
+ const ImportChecking = ({ rows }) => /* @__PURE__ */ jsxs("p", {
40
+ className: "flex items-center gap-2 text-[13px] text-muted-foreground",
41
+ role: "status",
42
+ children: [
43
+ /* @__PURE__ */ jsx(Spinner, { className: "size-3.5" }),
44
+ "Checking rows… ",
45
+ rows.toLocaleString("en-IE"),
46
+ " read"
47
+ ]
48
+ });
49
+ /**
50
+ * What the import would do, before it writes anything: how many rows create or update a record, the first rows as they
51
+ * would be written, and every row with problems, which the import skips.
52
+ */
53
+ const ImportCheckStep = ({ check, model, resources, views, columns, headers, fileName }) => {
54
+ const view = views[model.name];
55
+ const shown = columns.map((column) => model.fields[column.field]);
56
+ return /* @__PURE__ */ jsxs("div", {
57
+ className: "flex flex-col gap-4 text-[13px]",
58
+ children: [
59
+ /* @__PURE__ */ jsx(ImportCounts, {
60
+ label: "Check",
61
+ counts: [
62
+ ["Rows", check.rows],
63
+ ["To create", check.creates],
64
+ ["To update", check.updates],
65
+ ["With problems", check.problems.length]
66
+ ]
67
+ }),
68
+ check.preview.length > 0 && /* @__PURE__ */ jsx("div", {
69
+ className: "overflow-x-auto rounded-lg border",
70
+ children: /* @__PURE__ */ jsxs("table", {
71
+ "aria-label": "First rows",
72
+ className: "w-full text-left text-xs",
73
+ children: [/* @__PURE__ */ jsx("thead", {
74
+ className: "bg-muted text-muted-foreground",
75
+ children: /* @__PURE__ */ jsxs("tr", { children: [/* @__PURE__ */ jsx("th", {
76
+ className: "px-2 py-1.5 font-medium",
77
+ children: "Row"
78
+ }), shown.map((field) => /* @__PURE__ */ jsx("th", {
79
+ className: "whitespace-nowrap px-2 py-1.5 font-medium",
80
+ children: fieldLabel(view, field.name)
81
+ }, field.name))] })
82
+ }), /* @__PURE__ */ jsx("tbody", {
83
+ className: "divide-y",
84
+ children: check.preview.map((row) => /* @__PURE__ */ jsxs("tr", {
85
+ className: row.problems.length > 0 ? "bg-danger-soft" : void 0,
86
+ children: [/* @__PURE__ */ jsx("td", {
87
+ className: "px-2 py-1.5 tabular-nums text-muted-foreground",
88
+ children: row.line
89
+ }), shown.map((field) => /* @__PURE__ */ jsx("td", {
90
+ className: "max-w-48 truncate px-2 py-1.5",
91
+ children: relationModel(field, resources) ? String(row.values[field.name] ?? "—") : field.name in row.values ? displayValue(field, row.values[field.name], view?.fields[field.name]) : ""
92
+ }, field.name))]
93
+ }, row.line))
94
+ })]
95
+ })
96
+ }),
97
+ check.problems.length > 0 && /* @__PURE__ */ jsxs("div", {
98
+ className: "flex flex-col gap-2",
99
+ children: [/* @__PURE__ */ jsxs("div", {
100
+ className: "flex items-center justify-between gap-2",
101
+ children: [/* @__PURE__ */ jsx("h3", {
102
+ className: "font-medium",
103
+ children: "Rows with problems, which are skipped"
104
+ }), /* @__PURE__ */ jsxs(Button, {
105
+ size: "sm",
106
+ onClick: () => downloadRows(fileName, headers, check.problems),
107
+ children: [/* @__PURE__ */ jsx(Download, { className: "size-3.5" }), "Download"]
108
+ })]
109
+ }), /* @__PURE__ */ jsx(ProblemList, { rows: check.problems })]
110
+ }),
111
+ /* @__PURE__ */ jsxs("p", {
112
+ className: "text-xs text-muted-foreground",
113
+ children: [
114
+ "Rows are saved in batches of ",
115
+ 100,
116
+ ". Cancelling, or an error, keeps the batches already saved; a row the server refuses is listed and the others are saved."
117
+ ]
118
+ })
119
+ ]
120
+ });
121
+ };
122
+ //#endregion
123
+ export { ImportCheckStep, ImportChecking, ProblemList, downloadRows };
@@ -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 text-[13px]",
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: "text-[13px] 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 text-[13px]",
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 text-[13px]",
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 text-[13px]",
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: "text-[13px] 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 text-[13px] 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 };