@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.
Files changed (159) hide show
  1. package/dist/account/account-section.js +2 -2
  2. package/dist/account/account-security-page.js +2 -2
  3. package/dist/account/backup-codes.js +3 -10
  4. package/dist/account/passkeys-section.js +2 -2
  5. package/dist/account/password-dialog.js +1 -1
  6. package/dist/account/sign-in-policy-form.js +2 -2
  7. package/dist/account/sign-in-policy-page.js +2 -2
  8. package/dist/account/sign-in-providers-section.js +1 -1
  9. package/dist/account/staff-sign-in-log.js +3 -3
  10. package/dist/account/two-factor-setup.js +6 -6
  11. package/dist/action-card/action-card.js +1 -1
  12. package/dist/activity-chart/activity-chart.js +2 -2
  13. package/dist/app/delete/dialogs.js +3 -3
  14. package/dist/app/displays/display-controls.js +1 -1
  15. package/dist/app/displays/display-frame.js +1 -1
  16. package/dist/app/displays/list-cards.js +1 -1
  17. package/dist/app/displays/list-displays.js +2 -1
  18. package/dist/app/displays/map-display.js +1 -1
  19. package/dist/app/displays/record-card.js +1 -1
  20. package/dist/app/embedded-list/embedded-list.js +2 -2
  21. package/dist/app/error-banner.js +1 -1
  22. package/dist/app/field-editor.js +6 -12
  23. package/dist/app/file-field.js +4 -4
  24. package/dist/app/filled-fields.js +5 -0
  25. package/dist/app/gallery/details-open-storage.js +10 -0
  26. package/dist/app/gallery/gallery-layout.js +3 -1
  27. package/dist/app/gallery/lightbox.js +107 -61
  28. package/dist/app/gallery/photo-details.js +1 -1
  29. package/dist/app/import/convert.js +151 -0
  30. package/dist/app/import/csv-parser.js +119 -0
  31. package/dist/app/import/failed-rows.js +21 -0
  32. package/dist/app/import/import-button.js +31 -0
  33. package/dist/app/import/import-check-step.js +123 -0
  34. package/dist/app/import/import-counts.js +19 -0
  35. package/dist/app/import/import-dialog.js +152 -0
  36. package/dist/app/import/import-file-step.js +65 -0
  37. package/dist/app/import/import-mapping-step.js +195 -0
  38. package/dist/app/import/import-result-step.js +86 -0
  39. package/dist/app/import/import-select.js +35 -0
  40. package/dist/app/import/locale-formats.js +15 -0
  41. package/dist/app/import/lookups.js +48 -0
  42. package/dist/app/import/mapping.js +78 -0
  43. package/dist/app/import/parse-worker.d.ts +1 -0
  44. package/dist/app/import/parse-worker.js +39 -0
  45. package/dist/app/import/prepare-rows.js +129 -0
  46. package/dist/app/import/run-import.js +130 -0
  47. package/dist/app/import/sheet-cell.js +17 -0
  48. package/dist/app/import/sheet-file.js +58 -0
  49. package/dist/app/import/sheet-source.js +72 -0
  50. package/dist/app/import/use-import-flow.js +225 -0
  51. package/dist/app/import/xlsx-reader.js +43 -0
  52. package/dist/app/import/xlsx-xml.js +169 -0
  53. package/dist/app/import/zip-reader.js +72 -0
  54. package/dist/app/list-page.js +8 -1
  55. package/dist/app/meta-gate.js +3 -3
  56. package/dist/app/named-icon.js +9 -1
  57. package/dist/app/pages/blocks/card-frame.js +4 -4
  58. package/dist/app/pages/blocks/field.js +2 -5
  59. package/dist/app/pages/blocks/stat.js +2 -2
  60. package/dist/app/pages/problem.js +1 -1
  61. package/dist/app/pages/render-node.js +1 -1
  62. package/dist/app/record-page.js +13 -4
  63. package/dist/app/record-row/record-row-body.js +3 -3
  64. package/dist/app/record-row/saved-record-row.js +1 -1
  65. package/dist/app/related-section.js +1 -1
  66. package/dist/app/relation-picker.js +1 -1
  67. package/dist/app/runtime/runtime-update.js +3 -3
  68. package/dist/app/saved/saved-list.js +1 -1
  69. package/dist/app/searches/saved-search-rows.js +2 -2
  70. package/dist/app/searches/saved-search-section.js +1 -1
  71. package/dist/app/searches/saved-searches.js +1 -1
  72. package/dist/app/searches/search-controls.js +2 -2
  73. package/dist/app/searches/search-name-dialog.js +1 -1
  74. package/dist/app/searches/update-search-dialog.js +16 -19
  75. package/dist/app/sensitive-field.js +1 -1
  76. package/dist/app/source-notice.js +3 -3
  77. package/dist/app/staff-banner.js +1 -1
  78. package/dist/app-shell/breadcrumb.js +1 -1
  79. package/dist/app-shell/global-search.js +1 -1
  80. package/dist/app-shell/page-header.js +1 -1
  81. package/dist/app-shell/profile-menu.js +3 -3
  82. package/dist/app-shell/sidebar-group.js +1 -1
  83. package/dist/app-shell/sidebar-item.js +1 -1
  84. package/dist/app-shell/sidebar.js +2 -2
  85. package/dist/assistant-dock/part-view.js +1 -4
  86. package/dist/assistant-dock/widget-view.js +1 -4
  87. package/dist/auth/auth-gate.js +2 -2
  88. package/dist/auth/auth-layout.js +1 -1
  89. package/dist/auth/code-form.js +2 -2
  90. package/dist/auth/first-run-page.js +1 -1
  91. package/dist/auth/forgot-password-form.js +2 -2
  92. package/dist/auth/provider-sign-in-page.js +1 -1
  93. package/dist/auth/reset-password-form.js +3 -3
  94. package/dist/auth/setup-required-form.js +2 -2
  95. package/dist/auth/setup-required-page.js +1 -1
  96. package/dist/auth/sign-in-form.js +5 -5
  97. package/dist/auth/sign-in-prompt.js +3 -3
  98. package/dist/auth/sign-up-details-form.js +3 -3
  99. package/dist/auth/sign-up-email-form.js +4 -4
  100. package/dist/auth/staff-sign-in-form.js +3 -3
  101. package/dist/auth/two-factor-form.js +5 -5
  102. package/dist/chat/chat-message.js +1 -1
  103. package/dist/chat/chat-thread.js +1 -1
  104. package/dist/data/api-provider.js +8 -2
  105. package/dist/data-grid/grid-row.js +1 -1
  106. package/dist/data-grid/header-menu.js +1 -1
  107. package/dist/data-grid/row-menu.js +1 -1
  108. package/dist/filter-panel/facet-combobox.js +1 -1
  109. package/dist/filter-panel/filter-menu.js +1 -1
  110. package/dist/filter-panel/filter-panel.js +2 -2
  111. package/dist/inline-child-table/line-menu.js +1 -1
  112. package/dist/inline-child-table/line-row.js +4 -4
  113. package/dist/inline-child-table/number-cell.js +1 -1
  114. package/dist/inline-child-table/totals-footer.js +2 -2
  115. package/dist/lib/save-file.js +12 -0
  116. package/dist/lib/use-focus-trap.js +7 -1
  117. package/dist/organizations/app-roles-picker.js +1 -1
  118. package/dist/organizations/invitation-page.js +3 -3
  119. package/dist/organizations/invitations-page.js +2 -2
  120. package/dist/organizations/members-page.js +2 -2
  121. package/dist/organizations/organization-page.js +1 -1
  122. package/dist/organizations/organizations-page.js +2 -2
  123. package/dist/package.js +1 -1
  124. package/dist/plugins/before-sign-in.js +1 -1
  125. package/dist/plugins/plugin-gate.js +1 -1
  126. package/dist/plugins/plugin-notice.js +1 -1
  127. package/dist/primitives/button.js +1 -1
  128. package/dist/primitives/dialog.js +2 -2
  129. package/dist/primitives/input.js +1 -1
  130. package/dist/primitives/menu-select.js +2 -2
  131. package/dist/primitives/responsive-dialog.js +1 -1
  132. package/dist/primitives/segmented.js +1 -1
  133. package/dist/primitives/sheet-swipe.js +5 -0
  134. package/dist/primitives/sheet.js +40 -12
  135. package/dist/primitives/textarea.js +1 -1
  136. package/dist/record-view/action-rail.js +1 -1
  137. package/dist/record-view/field.js +1 -1
  138. package/dist/record-view/key-facts.js +29 -0
  139. package/dist/record-view/record-sidebar.js +2 -2
  140. package/dist/record-view/section.js +2 -2
  141. package/dist/record-view/spec-table.js +1 -1
  142. package/dist/shell/assets/app-DwKzLvfm.js +67 -0
  143. package/dist/shell/assets/index-CwhnZIJp.css +2 -0
  144. package/dist/shell/assets/{index-CEvfMgFJ.js → index-Dqgcyg7M.js} +1 -1
  145. package/dist/shell/assets/{maplibre-D6zfLfTV.js → maplibre-BWjNhvrH.js} +1 -1
  146. package/dist/shell/assets/parse-worker-BtcY0dQw.js +5 -0
  147. package/dist/shell/assets/{record-map-B6_rbCcG.js → record-map-m5K2uttt.js} +1 -1
  148. package/dist/shell/assets/{records-map-bQbuIc13.js → records-map-CA_JG9RR.js} +1 -1
  149. package/dist/shell/assets/{shared-protobase-ui-8pJgwjfc.js → shared-protobase-ui-BBqKlVLh.js} +1 -1
  150. package/dist/shell/index.html +4 -4
  151. package/dist/shell/protobase.shell.json +1 -1
  152. package/dist/side-panel/side-panel.js +1 -1
  153. package/dist/step-list/step-list.js +1 -1
  154. package/dist/styles.css +1 -1
  155. package/dist/theme.css +2 -0
  156. package/dist/toasts/toast.js +2 -2
  157. package/package.json +4 -4
  158. package/dist/shell/assets/app-BYFiTXK6.js +0 -66
  159. 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 };