@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
|
@@ -1,23 +1,32 @@
|
|
|
1
1
|
import { cn } from "../../lib/cn.js";
|
|
2
|
+
import { useIsDesktop } from "../../lib/use-media-query.js";
|
|
2
3
|
import { useFocusTrap } from "../../lib/use-focus-trap.js";
|
|
3
4
|
import { shownImage } from "../../live/file-values.js";
|
|
4
5
|
import { FileImage } from "../../live/file-image.js";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { Sheet } from "../../primitives/sheet.js";
|
|
7
|
+
import { keyStep, steppedPhoto, swipeStep, togglesDetails } from "./gallery-layout.js";
|
|
8
|
+
import { readDetailsOpen, writeDetailsOpen } from "./details-open-storage.js";
|
|
9
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
10
|
+
import { ChevronLeft, ChevronRight, Info, X } from "lucide-react";
|
|
8
11
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
9
12
|
//#region src/app/gallery/lightbox.tsx
|
|
10
13
|
const control = "flex size-10 shrink-0 items-center justify-center rounded-full bg-background/85 text-foreground shadow-sm transition-colors hover:bg-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40";
|
|
11
14
|
/**
|
|
12
15
|
* Photos one at a time at full size over the page: a modal dialog with a counter, arrows and keys (left, right, Escape)
|
|
13
|
-
* that step through them, stopping at the first and the last, a sideways swipe on a touch screen
|
|
14
|
-
* read,
|
|
15
|
-
*
|
|
16
|
+
* that step through them, stopping at the first and the last, and a sideways swipe on a touch screen. For a photo with
|
|
17
|
+
* details the user may read, an info button, or the `i` key, shows them in a sidebar beside it, or in a sheet over the
|
|
18
|
+
* lower part of a mobile screen; this browser remembers whether they were left shown. The page does not scroll behind
|
|
19
|
+
* it, and focus returns to what opened it.
|
|
16
20
|
*/
|
|
17
21
|
const Lightbox = ({ title, photos, index, onIndex, onClose, details, detailsTitle = "Details" }) => {
|
|
18
22
|
const panel = useRef(null);
|
|
19
23
|
const start = useRef(void 0);
|
|
24
|
+
const sidebarId = useId();
|
|
25
|
+
const desktop = useIsDesktop();
|
|
26
|
+
const [detailsOpen, setDetailsOpen] = useState(() => readDetailsOpen(localStorage));
|
|
27
|
+
const [trapped, setTrapped] = useState(false);
|
|
20
28
|
useFocusTrap(panel, true, onClose);
|
|
29
|
+
useEffect(() => setTrapped(true), []);
|
|
21
30
|
const count = photos.length;
|
|
22
31
|
const step = (by) => {
|
|
23
32
|
const next = steppedPhoto(index, by, count);
|
|
@@ -28,7 +37,16 @@ const Lightbox = ({ title, photos, index, onIndex, onClose, details, detailsTitl
|
|
|
28
37
|
height: 1200
|
|
29
38
|
});
|
|
30
39
|
const shownDetails = details?.(index);
|
|
40
|
+
const hasDetails = shownDetails !== void 0;
|
|
41
|
+
const toggleDetails = (open) => {
|
|
42
|
+
setDetailsOpen(open);
|
|
43
|
+
writeDetailsOpen(localStorage, open);
|
|
44
|
+
};
|
|
31
45
|
const key = (event) => {
|
|
46
|
+
if (hasDetails && togglesDetails(event)) {
|
|
47
|
+
event.preventDefault();
|
|
48
|
+
return toggleDetails(!detailsOpen);
|
|
49
|
+
}
|
|
32
50
|
const by = keyStep(event.key);
|
|
33
51
|
if (by === 0) return;
|
|
34
52
|
event.preventDefault();
|
|
@@ -53,62 +71,90 @@ const Lightbox = ({ title, photos, index, onIndex, onClose, details, detailsTitl
|
|
|
53
71
|
"aria-label": title,
|
|
54
72
|
onKeyDown: key,
|
|
55
73
|
className: "fixed inset-0 z-[90] flex flex-col overflow-y-auto overscroll-contain bg-backdrop outline-none md:flex-row",
|
|
56
|
-
children: [
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
74
|
+
children: [
|
|
75
|
+
/* @__PURE__ */ jsxs("div", {
|
|
76
|
+
className: "flex h-dvh shrink-0 flex-col md:sticky md:top-0 md:min-w-0 md:flex-1",
|
|
77
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
78
|
+
className: "flex shrink-0 items-center justify-between gap-2 p-3",
|
|
79
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
80
|
+
className: "rounded-full bg-background/85 px-3 py-1 font-medium tabular-nums text-foreground",
|
|
81
|
+
"aria-live": "polite",
|
|
82
|
+
children: `${index + 1} / ${count}`
|
|
83
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
84
|
+
className: "flex items-center gap-2",
|
|
85
|
+
children: [hasDetails && /* @__PURE__ */ jsx("button", {
|
|
86
|
+
type: "button",
|
|
87
|
+
className: cn(control, "aria-expanded:bg-foreground aria-expanded:text-background"),
|
|
88
|
+
"aria-label": detailsTitle,
|
|
89
|
+
"aria-expanded": detailsOpen,
|
|
90
|
+
"aria-controls": desktop ? sidebarId : void 0,
|
|
91
|
+
"aria-keyshortcuts": "i",
|
|
92
|
+
onClick: () => toggleDetails(!detailsOpen),
|
|
93
|
+
children: /* @__PURE__ */ jsx(Info, {
|
|
94
|
+
className: "size-4",
|
|
95
|
+
"aria-hidden": true
|
|
96
|
+
})
|
|
97
|
+
}), /* @__PURE__ */ jsx("button", {
|
|
98
|
+
type: "button",
|
|
99
|
+
className: control,
|
|
100
|
+
"aria-label": "Close",
|
|
101
|
+
onClick: onClose,
|
|
102
|
+
children: /* @__PURE__ */ jsx(X, {
|
|
103
|
+
className: "size-4",
|
|
104
|
+
"aria-hidden": true
|
|
105
|
+
})
|
|
106
|
+
})]
|
|
107
|
+
})]
|
|
108
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
109
|
+
className: "relative flex min-h-0 flex-1 touch-pan-y items-center justify-center px-3 pb-3 md:px-16",
|
|
110
|
+
onPointerDown: down,
|
|
111
|
+
onPointerUp: up,
|
|
112
|
+
onPointerCancel: () => start.current = void 0,
|
|
113
|
+
children: [image && /* @__PURE__ */ jsx(FileImage, {
|
|
114
|
+
image,
|
|
115
|
+
className: "max-h-full max-w-full select-none object-contain"
|
|
116
|
+
}, image.url), count > 1 && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("button", {
|
|
117
|
+
type: "button",
|
|
118
|
+
className: cn(control, "absolute left-3 top-1/2 -translate-y-1/2 aria-disabled:cursor-default aria-disabled:opacity-40"),
|
|
119
|
+
"aria-label": "Previous photo",
|
|
120
|
+
"aria-disabled": index === 0,
|
|
121
|
+
onClick: () => step(-1),
|
|
122
|
+
children: /* @__PURE__ */ jsx(ChevronLeft, {
|
|
123
|
+
className: "size-5",
|
|
124
|
+
"aria-hidden": true
|
|
125
|
+
})
|
|
126
|
+
}), /* @__PURE__ */ jsx("button", {
|
|
127
|
+
type: "button",
|
|
128
|
+
className: cn(control, "absolute right-3 top-1/2 -translate-y-1/2 aria-disabled:cursor-default aria-disabled:opacity-40"),
|
|
129
|
+
"aria-label": "Next photo",
|
|
130
|
+
"aria-disabled": index === count - 1,
|
|
131
|
+
onClick: () => step(1),
|
|
132
|
+
children: /* @__PURE__ */ jsx(ChevronRight, {
|
|
133
|
+
className: "size-5",
|
|
134
|
+
"aria-hidden": true
|
|
135
|
+
})
|
|
136
|
+
})] })]
|
|
73
137
|
})]
|
|
74
|
-
}),
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
children: [
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
className: cn(control, "absolute right-3 top-1/2 -translate-y-1/2 aria-disabled:cursor-default aria-disabled:opacity-40"),
|
|
95
|
-
"aria-label": "Next photo",
|
|
96
|
-
"aria-disabled": index === count - 1,
|
|
97
|
-
onClick: () => step(1),
|
|
98
|
-
children: /* @__PURE__ */ jsx(ChevronRight, {
|
|
99
|
-
className: "size-5",
|
|
100
|
-
"aria-hidden": true
|
|
101
|
-
})
|
|
102
|
-
})] })]
|
|
103
|
-
})]
|
|
104
|
-
}), shownDetails !== void 0 && /* @__PURE__ */ jsxs("aside", {
|
|
105
|
-
"aria-label": detailsTitle,
|
|
106
|
-
className: "shrink-0 border-t border-border bg-background p-5 text-foreground md:min-h-dvh md:w-[360px] md:border-l md:border-t-0",
|
|
107
|
-
children: [/* @__PURE__ */ jsx("h2", {
|
|
108
|
-
className: "mb-3 text-[15px] font-semibold",
|
|
109
|
-
children: detailsTitle
|
|
110
|
-
}), shownDetails]
|
|
111
|
-
})]
|
|
138
|
+
}),
|
|
139
|
+
hasDetails && detailsOpen && desktop && /* @__PURE__ */ jsxs("aside", {
|
|
140
|
+
id: sidebarId,
|
|
141
|
+
"aria-label": detailsTitle,
|
|
142
|
+
className: "min-h-dvh w-[360px] shrink-0 border-l border-border bg-background p-5 text-foreground",
|
|
143
|
+
children: [/* @__PURE__ */ jsx("h2", {
|
|
144
|
+
className: "mb-3 text-[16px] font-semibold",
|
|
145
|
+
children: detailsTitle
|
|
146
|
+
}), shownDetails]
|
|
147
|
+
}),
|
|
148
|
+
/* @__PURE__ */ jsx(Sheet, {
|
|
149
|
+
title: detailsTitle,
|
|
150
|
+
open: hasDetails && detailsOpen && !desktop && trapped,
|
|
151
|
+
onClose: () => toggleDetails(false),
|
|
152
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
153
|
+
className: "pt-3",
|
|
154
|
+
children: shownDetails
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
]
|
|
112
158
|
});
|
|
113
159
|
};
|
|
114
160
|
//#endregion
|
|
@@ -34,7 +34,7 @@ const PhotoDetails = ({ model, view, photoKey, fields }) => {
|
|
|
34
34
|
resource: model.name,
|
|
35
35
|
key: photoKey
|
|
36
36
|
},
|
|
37
|
-
className: "mt-3 inline-block
|
|
37
|
+
className: "mt-3 inline-block font-medium text-primary-text hover:underline",
|
|
38
38
|
children: `Open ${singular}`
|
|
39
39
|
})] });
|
|
40
40
|
};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { cellText, isEmptyCell, isSheetDate } from "./sheet-cell.js";
|
|
2
|
+
import { enumLabel } from "@protobase/schema";
|
|
3
|
+
//#region src/app/import/convert.ts
|
|
4
|
+
const ok = (value) => ({
|
|
5
|
+
ok: true,
|
|
6
|
+
value
|
|
7
|
+
});
|
|
8
|
+
const problem = (text) => ({
|
|
9
|
+
ok: false,
|
|
10
|
+
problem: text
|
|
11
|
+
});
|
|
12
|
+
const quoted = (cell) => `“${cellText(cell)}”`;
|
|
13
|
+
const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
14
|
+
/**
|
|
15
|
+
* A number written as text, as a plain decimal (`-1234.5`): digits grouped by thousands with the other separator or
|
|
16
|
+
* spaces, and the `decimal` separator before the fraction. Undefined when the text is no such number.
|
|
17
|
+
*/
|
|
18
|
+
const parseNumberText = (text, decimal) => {
|
|
19
|
+
const group = `[${escapeRegExp(decimal === "." ? "," : ".")}\\s\\u00a0\\u202f']`;
|
|
20
|
+
const match = new RegExp(`^([-+]?)(\\d{1,3}(?:${group}\\d{3})+|\\d+)(?:${escapeRegExp(decimal)}(\\d+))?$`).exec(text.trim());
|
|
21
|
+
if (match) return `${match[1] === "-" ? "-" : ""}${match[2].replace(/\D/g, "")}${match[3] ? `.${match[3]}` : ""}`;
|
|
22
|
+
if (decimal === "." && /^[-+]?\d+(\.\d+)?e[-+]?\d+$/i.test(text.trim())) return String(Number(text.trim()));
|
|
23
|
+
};
|
|
24
|
+
const toNumberText = (cell, options) => {
|
|
25
|
+
if (typeof cell === "number") return String(cell);
|
|
26
|
+
if (typeof cell === "string") return parseNumberText(cell, options.decimal);
|
|
27
|
+
};
|
|
28
|
+
const integerValue = (field, cell, options) => {
|
|
29
|
+
const text = toNumberText(cell, options);
|
|
30
|
+
if (text === void 0) return problem(`${quoted(cell)} is not a number`);
|
|
31
|
+
const [whole, fraction = ""] = text.split(".");
|
|
32
|
+
if (/[1-9]/.test(fraction) || /e/i.test(text)) return problem(`${quoted(cell)} is not a whole number`);
|
|
33
|
+
if (field.type === "bigint") return ok(String(BigInt(whole)));
|
|
34
|
+
const value = Number(whole);
|
|
35
|
+
return Number.isSafeInteger(value) ? ok(value) : problem(`${quoted(cell)} is too large`);
|
|
36
|
+
};
|
|
37
|
+
const decimalValue = (cell, options) => {
|
|
38
|
+
const text = toNumberText(cell, options);
|
|
39
|
+
return text === void 0 ? problem(`${quoted(cell)} is not a number`) : ok(text);
|
|
40
|
+
};
|
|
41
|
+
const trueWords = /* @__PURE__ */ new Set([
|
|
42
|
+
"true",
|
|
43
|
+
"yes",
|
|
44
|
+
"1"
|
|
45
|
+
]);
|
|
46
|
+
const falseWords = /* @__PURE__ */ new Set([
|
|
47
|
+
"false",
|
|
48
|
+
"no",
|
|
49
|
+
"0"
|
|
50
|
+
]);
|
|
51
|
+
const booleanValue = (cell) => {
|
|
52
|
+
if (typeof cell === "boolean") return ok(cell);
|
|
53
|
+
const text = typeof cell === "string" || typeof cell === "number" ? String(cell).trim().toLowerCase() : "";
|
|
54
|
+
if (trueWords.has(text)) return ok(true);
|
|
55
|
+
if (falseWords.has(text)) return ok(false);
|
|
56
|
+
return problem(`${quoted(cell)} is not true or false (true, false, yes, no, 1 or 0)`);
|
|
57
|
+
};
|
|
58
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
59
|
+
const calendarDate = (year, month, day) => {
|
|
60
|
+
const at = new Date(Date.UTC(year, month - 1, day));
|
|
61
|
+
if (at.getUTCFullYear() !== year || at.getUTCMonth() !== month - 1 || at.getUTCDate() !== day) return void 0;
|
|
62
|
+
return `${year}-${pad(month)}-${pad(day)}`;
|
|
63
|
+
};
|
|
64
|
+
const timePattern = "(\\d{1,2}):(\\d{2})(?::(\\d{2})(?:\\.\\d+)?)?";
|
|
65
|
+
const readWallTime = (text, order) => {
|
|
66
|
+
const iso = new RegExp(`^(\\d{4})[-/](\\d{1,2})[-/](\\d{1,2})(?:[T ]${timePattern}(Z|[+-]\\d{2}:?\\d{2})?)?$`).exec(text.trim());
|
|
67
|
+
const local = new RegExp(`^(\\d{1,2})[./-](\\d{1,2})[./-](\\d{4})(?: ${timePattern})?$`).exec(text.trim());
|
|
68
|
+
if (!iso && !local) return void 0;
|
|
69
|
+
const [year, month, day] = iso ? [
|
|
70
|
+
iso[1],
|
|
71
|
+
iso[2],
|
|
72
|
+
iso[3]
|
|
73
|
+
].map(Number) : order === "dmy" ? [
|
|
74
|
+
local[3],
|
|
75
|
+
local[2],
|
|
76
|
+
local[1]
|
|
77
|
+
].map(Number) : [
|
|
78
|
+
local[3],
|
|
79
|
+
local[1],
|
|
80
|
+
local[2]
|
|
81
|
+
].map(Number);
|
|
82
|
+
const date = calendarDate(year, month, day);
|
|
83
|
+
if (!date) return void 0;
|
|
84
|
+
const [hours, minutes, seconds = "0"] = iso ? iso.slice(4, 7) : local.slice(4, 7);
|
|
85
|
+
if (hours === void 0) return { date };
|
|
86
|
+
if (Number(hours) > 23 || Number(minutes) > 59 || Number(seconds) > 59) return void 0;
|
|
87
|
+
return {
|
|
88
|
+
date,
|
|
89
|
+
time: `${pad(Number(hours))}:${minutes}:${pad(Number(seconds))}`,
|
|
90
|
+
zone: iso?.[7]
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
const wallTimeOf = (cell, options) => {
|
|
94
|
+
if (isSheetDate(cell)) {
|
|
95
|
+
const [date, time] = cell.date.split("T");
|
|
96
|
+
return {
|
|
97
|
+
date,
|
|
98
|
+
time
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return typeof cell === "string" ? readWallTime(cell, options.dateOrder) : void 0;
|
|
102
|
+
};
|
|
103
|
+
const notDate = (cell, options) => problem(`${quoted(cell)} is not a date (2026-04-03 or ${options.dateOrder === "dmy" ? "day/month/year" : "month/day/year"})`);
|
|
104
|
+
const dateValue = (cell, options) => {
|
|
105
|
+
const at = wallTimeOf(cell, options);
|
|
106
|
+
if (!at) return notDate(cell, options);
|
|
107
|
+
if (at.time && at.time !== "00:00:00") return problem(`${quoted(cell)} has a time of day; the field holds a date`);
|
|
108
|
+
return ok(at.date);
|
|
109
|
+
};
|
|
110
|
+
const timestampValue = (cell, options) => {
|
|
111
|
+
const at = wallTimeOf(cell, options);
|
|
112
|
+
if (!at) return notDate(cell, options);
|
|
113
|
+
if (at.zone) return ok((/* @__PURE__ */ new Date(`${at.date}T${at.time}${at.zone === "Z" ? "Z" : at.zone.replace(/^([+-]\d{2})(\d{2})$/, "$1:$2")}`)).toISOString());
|
|
114
|
+
const [year, month, day] = at.date.split("-").map(Number);
|
|
115
|
+
const [hours, minutes, seconds] = (at.time ?? "00:00:00").split(":").map(Number);
|
|
116
|
+
return ok(new Date(year, month - 1, day, hours, minutes, seconds).toISOString());
|
|
117
|
+
};
|
|
118
|
+
const normalized = (text) => text.trim().toLowerCase().replace(/\s+/g, " ");
|
|
119
|
+
const enumValue = (field, hints, cell) => {
|
|
120
|
+
const values = field.enumValues ?? [];
|
|
121
|
+
const text = normalized(cellText(cell));
|
|
122
|
+
const matches = values.filter((value) => normalized(value) === text || normalized(enumLabel(value, hints?.valueLabels)) === text);
|
|
123
|
+
if (matches.length === 1) return ok(matches[0]);
|
|
124
|
+
if (matches.length > 1) return problem(`${quoted(cell)} matches more than one value: ${matches.map((value) => enumLabel(value, hints?.valueLabels)).join(", ")}`);
|
|
125
|
+
return problem(`${quoted(cell)} is not one of ${values.map((value) => enumLabel(value, hints?.valueLabels)).join(", ")}`);
|
|
126
|
+
};
|
|
127
|
+
const textValue = (cell) => typeof cell === "boolean" ? problem(`${quoted(cell)} is true or false, not text`) : ok(cellText(cell));
|
|
128
|
+
/**
|
|
129
|
+
* The value a cell gives a field, as the API takes it, or the problem that keeps it from being one: never a guess, and an
|
|
130
|
+
* empty cell is null, never a default. A relation's cell is the value of the field its records are found by, so it
|
|
131
|
+
* converts as that field (`convertCell(targetField, ...)`).
|
|
132
|
+
*/
|
|
133
|
+
const convertCell = (field, hints, cell, options) => {
|
|
134
|
+
if (isEmptyCell(cell)) return field.nullable ? ok(null) : problem("is empty, and it is required");
|
|
135
|
+
switch (field.type) {
|
|
136
|
+
case "integer":
|
|
137
|
+
case "bigint": return integerValue(field, cell, options);
|
|
138
|
+
case "decimal": return decimalValue(cell, options);
|
|
139
|
+
case "boolean": return booleanValue(cell);
|
|
140
|
+
case "date": return dateValue(cell, options);
|
|
141
|
+
case "timestamp": return timestampValue(cell, options);
|
|
142
|
+
case "enum": return enumValue(field, hints, cell);
|
|
143
|
+
case "text":
|
|
144
|
+
case "uuid":
|
|
145
|
+
case "currency":
|
|
146
|
+
case "country": return textValue(cell);
|
|
147
|
+
default: throw new Error(`Not implemented: importing ${field.type} fields, which importableFields leaves out`);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
//#endregion
|
|
151
|
+
export { convertCell, parseNumberText };
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
//#region src/app/import/csv-parser.ts
|
|
2
|
+
const delimiters = [
|
|
3
|
+
",",
|
|
4
|
+
";",
|
|
5
|
+
" "
|
|
6
|
+
];
|
|
7
|
+
/**
|
|
8
|
+
* The delimiter of a CSV's header line: whichever of comma, semicolon and tab it holds most often outside quotes, a
|
|
9
|
+
* comma when it holds none (a file of one column).
|
|
10
|
+
*/
|
|
11
|
+
const detectDelimiter = (headerLine) => {
|
|
12
|
+
const counts = new Map(delimiters.map((delimiter) => [delimiter, 0]));
|
|
13
|
+
let quoted = false;
|
|
14
|
+
for (const char of headerLine) if (char === "\"") quoted = !quoted;
|
|
15
|
+
else if (!quoted && counts.has(char)) counts.set(char, counts.get(char) + 1);
|
|
16
|
+
return [...counts].reduce((best, entry) => entry[1] > best[1] ? entry : best, [",", 0])[0];
|
|
17
|
+
};
|
|
18
|
+
const firstLine = (text) => {
|
|
19
|
+
let quoted = false;
|
|
20
|
+
for (let index = 0; index < text.length; index++) {
|
|
21
|
+
const char = text[index];
|
|
22
|
+
if (char === "\"") quoted = !quoted;
|
|
23
|
+
else if (!quoted && (char === "\n" || char === "\r")) return text.slice(0, index);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* A CSV parser fed text a chunk at a time (RFC 4180: quoted fields with `""` for a quote and line breaks inside, CRLF,
|
|
28
|
+
* LF or CR between rows), so a large file is read without holding it whole. `push` returns the rows completed by the
|
|
29
|
+
* chunk, `end` the last one. The delimiter is the header line's (`detectDelimiter`) unless given; a byte order mark at
|
|
30
|
+
* the start is dropped.
|
|
31
|
+
*/
|
|
32
|
+
const createCsvParser = (given) => {
|
|
33
|
+
let delimiter = given;
|
|
34
|
+
let pending = "";
|
|
35
|
+
let started = false;
|
|
36
|
+
let field = "";
|
|
37
|
+
let row = [];
|
|
38
|
+
let quoted = false;
|
|
39
|
+
let quoteSeen = false;
|
|
40
|
+
let crSeen = false;
|
|
41
|
+
let rowStarted = false;
|
|
42
|
+
const endField = () => {
|
|
43
|
+
row.push(field);
|
|
44
|
+
field = "";
|
|
45
|
+
};
|
|
46
|
+
const endRow = (rows) => {
|
|
47
|
+
endField();
|
|
48
|
+
rows.push(row);
|
|
49
|
+
row = [];
|
|
50
|
+
rowStarted = false;
|
|
51
|
+
};
|
|
52
|
+
const consume = (text) => {
|
|
53
|
+
const rows = [];
|
|
54
|
+
for (const char of text) {
|
|
55
|
+
if (crSeen) {
|
|
56
|
+
crSeen = false;
|
|
57
|
+
if (char === "\n") continue;
|
|
58
|
+
}
|
|
59
|
+
if (quoted) {
|
|
60
|
+
if (quoteSeen) {
|
|
61
|
+
quoteSeen = false;
|
|
62
|
+
if (char === "\"") {
|
|
63
|
+
field += "\"";
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
quoted = false;
|
|
67
|
+
} else if (char === "\"") {
|
|
68
|
+
quoteSeen = true;
|
|
69
|
+
continue;
|
|
70
|
+
} else {
|
|
71
|
+
field += char;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (char === "\"" && field === "") {
|
|
76
|
+
quoted = true;
|
|
77
|
+
rowStarted = true;
|
|
78
|
+
} else if (char === delimiter) {
|
|
79
|
+
endField();
|
|
80
|
+
rowStarted = true;
|
|
81
|
+
} else if (char === "\n" || char === "\r") {
|
|
82
|
+
crSeen = char === "\r";
|
|
83
|
+
endRow(rows);
|
|
84
|
+
} else {
|
|
85
|
+
field += char;
|
|
86
|
+
rowStarted = true;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return rows;
|
|
90
|
+
};
|
|
91
|
+
const push = (chunk) => {
|
|
92
|
+
if (started) return consume(chunk);
|
|
93
|
+
pending += chunk;
|
|
94
|
+
if (pending.charCodeAt(0) === 65279) pending = pending.slice(1);
|
|
95
|
+
const header = firstLine(pending);
|
|
96
|
+
if (header === void 0) return [];
|
|
97
|
+
started = true;
|
|
98
|
+
delimiter ??= detectDelimiter(header);
|
|
99
|
+
const text = pending;
|
|
100
|
+
pending = "";
|
|
101
|
+
return consume(text);
|
|
102
|
+
};
|
|
103
|
+
const end = () => {
|
|
104
|
+
const rows = started ? [] : push("\n");
|
|
105
|
+
if (quoteSeen) {
|
|
106
|
+
quoteSeen = false;
|
|
107
|
+
quoted = false;
|
|
108
|
+
}
|
|
109
|
+
if (rowStarted || field !== "") endRow(rows);
|
|
110
|
+
return rows;
|
|
111
|
+
};
|
|
112
|
+
return {
|
|
113
|
+
push,
|
|
114
|
+
end,
|
|
115
|
+
delimiter: () => delimiter
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
//#endregion
|
|
119
|
+
export { createCsvParser, detectDelimiter };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { cellText } from "./sheet-cell.js";
|
|
2
|
+
//#region src/app/import/failed-rows.ts
|
|
3
|
+
const csvField = (text) => /[",\r\n]/.test(text) ? `"${text.replace(/"/g, "\"\"")}"` : text;
|
|
4
|
+
/**
|
|
5
|
+
* The rows an import did not write as a CSV file to fix and import again: the file's own columns, then the row's line in
|
|
6
|
+
* the file and why it was not written. Sorted by line, with a byte order mark so spreadsheet programs read it as UTF-8.
|
|
7
|
+
*/
|
|
8
|
+
const failedRowsCsv = (headers, rows) => {
|
|
9
|
+
const lines = [...rows].sort((a, b) => a.line - b.line).map((row) => [
|
|
10
|
+
...headers.map((_, column) => cellText(row.cells[column] ?? null)),
|
|
11
|
+
String(row.line),
|
|
12
|
+
row.problems.join("; ")
|
|
13
|
+
]);
|
|
14
|
+
return `${[[
|
|
15
|
+
...headers,
|
|
16
|
+
"Line",
|
|
17
|
+
"Problems"
|
|
18
|
+
], ...lines].map((fields) => fields.map(csvField).join(",")).join("\r\n")}\r\n`;
|
|
19
|
+
};
|
|
20
|
+
//#endregion
|
|
21
|
+
export { failedRowsCsv };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Button } from "../../primitives/button.js";
|
|
2
|
+
import { useAdminMeta, usePermissions } from "../meta-gate.js";
|
|
3
|
+
import { can } from "../permissions.js";
|
|
4
|
+
import { ImportDialog } from "./import-dialog.js";
|
|
5
|
+
import { useState } from "react";
|
|
6
|
+
import { Upload } from "lucide-react";
|
|
7
|
+
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
8
|
+
//#region src/app/import/import-button.tsx
|
|
9
|
+
/**
|
|
10
|
+
* The list page's Import button, for a user who may create records of a resource whose view does not turn it off
|
|
11
|
+
* (`.import(false)`), and the import dialog it opens. The server checks every write as it would a single one.
|
|
12
|
+
*/
|
|
13
|
+
const ImportButton = ({ model }) => {
|
|
14
|
+
const { resources, views } = useAdminMeta();
|
|
15
|
+
const permissions = usePermissions(model.name);
|
|
16
|
+
const [open, setOpen] = useState(false);
|
|
17
|
+
if (views[model.name]?.import === false || !can(permissions, "create")) return null;
|
|
18
|
+
return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs(Button, {
|
|
19
|
+
onClick: () => setOpen(true),
|
|
20
|
+
"aria-haspopup": "dialog",
|
|
21
|
+
children: [/* @__PURE__ */ jsx(Upload, { className: "size-3.5" }), "Import"]
|
|
22
|
+
}), open && /* @__PURE__ */ jsx(ImportDialog, {
|
|
23
|
+
model,
|
|
24
|
+
resources,
|
|
25
|
+
views,
|
|
26
|
+
canUpdate: can(permissions, "update"),
|
|
27
|
+
onClose: () => setOpen(false)
|
|
28
|
+
})] });
|
|
29
|
+
};
|
|
30
|
+
//#endregion
|
|
31
|
+
export { ImportButton };
|
|
@@ -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-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",
|
|
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 };
|