@twoux/ui 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +61 -0
- package/LICENSES/Apache-2.0.txt +202 -0
- package/NOTICE.md +64 -0
- package/README.md +211 -2
- package/SHADCN-DIFF.md +228 -0
- package/agent/AGENTS.md +358 -0
- package/agent/docs-md/accessibility.md +49 -0
- package/agent/docs-md/action-catalogue.md +81 -0
- package/agent/docs-md/actions.md +74 -0
- package/agent/docs-md/agent-activity-log.md +70 -0
- package/agent/docs-md/agent-permissions.md +82 -0
- package/agent/docs-md/agent-run.md +90 -0
- package/agent/docs-md/agents-on-behalf.md +74 -0
- package/agent/docs-md/ai-column.md +85 -0
- package/agent/docs-md/ai-origin-label.md +70 -0
- package/agent/docs-md/announcing-features.md +73 -0
- package/agent/docs-md/answer-actions.md +81 -0
- package/agent/docs-md/app-shell.md +114 -0
- package/agent/docs-md/artifact-card.md +89 -0
- package/agent/docs-md/ask-on-screen.md +72 -0
- package/agent/docs-md/assistant-page.md +100 -0
- package/agent/docs-md/assistant-panel.md +142 -0
- package/agent/docs-md/avatar.md +82 -0
- package/agent/docs-md/banner.md +106 -0
- package/agent/docs-md/board.md +163 -0
- package/agent/docs-md/breadcrumb.md +89 -0
- package/agent/docs-md/breakdown-table.md +97 -0
- package/agent/docs-md/bulk-action-bar.md +112 -0
- package/agent/docs-md/button.md +293 -0
- package/agent/docs-md/card.md +115 -0
- package/agent/docs-md/change-list.md +99 -0
- package/agent/docs-md/change-request.md +166 -0
- package/agent/docs-md/chart-area.md +87 -0
- package/agent/docs-md/chart-bar.md +100 -0
- package/agent/docs-md/chart-box-plot.md +88 -0
- package/agent/docs-md/chart-bullet.md +89 -0
- package/agent/docs-md/chart-candlestick.md +112 -0
- package/agent/docs-md/chart-card-line.md +120 -0
- package/agent/docs-md/chart-donut.md +90 -0
- package/agent/docs-md/chart-drill-down.md +72 -0
- package/agent/docs-md/chart-funnel.md +71 -0
- package/agent/docs-md/chart-heatmap.md +76 -0
- package/agent/docs-md/chart-histogram.md +81 -0
- package/agent/docs-md/chart-map.md +165 -0
- package/agent/docs-md/chart-mixed.md +88 -0
- package/agent/docs-md/chart-price.md +92 -0
- package/agent/docs-md/chart-radial-bars.md +109 -0
- package/agent/docs-md/chart-radial-lines.md +111 -0
- package/agent/docs-md/chart-sankey.md +104 -0
- package/agent/docs-md/chart-scatter.md +87 -0
- package/agent/docs-md/chart-share-bar.md +89 -0
- package/agent/docs-md/chart-treemap.md +98 -0
- package/agent/docs-md/chart-waterfall.md +93 -0
- package/agent/docs-md/checkbox.md +131 -0
- package/agent/docs-md/choosing-selection-control.md +71 -0
- package/agent/docs-md/citations-sources.md +79 -0
- package/agent/docs-md/citing-sources.md +63 -0
- package/agent/docs-md/clarify-before-acting.md +69 -0
- package/agent/docs-md/clarifying-question.md +92 -0
- package/agent/docs-md/code-block.md +174 -0
- package/agent/docs-md/colours.md +87 -0
- package/agent/docs-md/columns-menu.md +140 -0
- package/agent/docs-md/combobox.md +161 -0
- package/agent/docs-md/command-palette.md +126 -0
- package/agent/docs-md/confidence-note.md +87 -0
- package/agent/docs-md/configurable-dashboard.md +76 -0
- package/agent/docs-md/confirmation-dialog.md +93 -0
- package/agent/docs-md/connected-tools.md +80 -0
- package/agent/docs-md/connector-picker.md +127 -0
- package/agent/docs-md/consistency.md +53 -0
- package/agent/docs-md/content-density.md +61 -0
- package/agent/docs-md/conversation-history.md +78 -0
- package/agent/docs-md/create-record.md +80 -0
- package/agent/docs-md/csv-import.md +88 -0
- package/agent/docs-md/dashboard-items.md +78 -0
- package/agent/docs-md/data-export.md +61 -0
- package/agent/docs-md/data-grid.md +137 -0
- package/agent/docs-md/data-vis-colours.md +76 -0
- package/agent/docs-md/data-visualisation.md +76 -0
- package/agent/docs-md/date-picker.md +178 -0
- package/agent/docs-md/delete-record.md +75 -0
- package/agent/docs-md/dense-table.md +495 -0
- package/agent/docs-md/design-tokens.md +53 -0
- package/agent/docs-md/disabled-read-only.md +75 -0
- package/agent/docs-md/drag-and-drop.md +74 -0
- package/agent/docs-md/dropdown-menu.md +137 -0
- package/agent/docs-md/edit-record.md +73 -0
- package/agent/docs-md/empty-loading-error.md +78 -0
- package/agent/docs-md/empty-state.md +128 -0
- package/agent/docs-md/error-messages.md +75 -0
- package/agent/docs-md/error-state.md +123 -0
- package/agent/docs-md/export-menu.md +86 -0
- package/agent/docs-md/feedback-prompt.md +88 -0
- package/agent/docs-md/field-mapping.md +78 -0
- package/agent/docs-md/file-drop.md +110 -0
- package/agent/docs-md/filter-bar.md +138 -0
- package/agent/docs-md/filter-panel.md +103 -0
- package/agent/docs-md/filtering-search.md +79 -0
- package/agent/docs-md/follow-ups-starters.md +79 -0
- package/agent/docs-md/form-validation.md +95 -0
- package/agent/docs-md/generated-columns.md +65 -0
- package/agent/docs-md/generated-drafts-files.md +71 -0
- package/agent/docs-md/grouped-list.md +89 -0
- package/agent/docs-md/guide-cue.md +170 -0
- package/agent/docs-md/help-panel.md +103 -0
- package/agent/docs-md/help-system.md +74 -0
- package/agent/docs-md/iconography.md +49 -0
- package/agent/docs-md/inbox.md +108 -0
- package/agent/docs-md/inline-editing-validation.md +80 -0
- package/agent/docs-md/inline-input-in-answer.md +70 -0
- package/agent/docs-md/items-palette.md +116 -0
- package/agent/docs-md/keyboard-model.md +47 -0
- package/agent/docs-md/keyboard-navigation.md +68 -0
- package/agent/docs-md/keyboard-shortcut-hint.md +87 -0
- package/agent/docs-md/keyboard-shortcut-sheet.md +90 -0
- package/agent/docs-md/labelling-ai-output.md +66 -0
- package/agent/docs-md/layout.md +47 -0
- package/agent/docs-md/list-table.md +254 -0
- package/agent/docs-md/loading-skeleton.md +91 -0
- package/agent/docs-md/managing-agents.md +71 -0
- package/agent/docs-md/message-composer.md +105 -0
- package/agent/docs-md/migrating.md +49 -0
- package/agent/docs-md/motion.md +93 -0
- package/agent/docs-md/navigation.md +76 -0
- package/agent/docs-md/notification-channels.md +65 -0
- package/agent/docs-md/notifications-decision.md +100 -0
- package/agent/docs-md/onboarding-step.md +114 -0
- package/agent/docs-md/order-book.md +108 -0
- package/agent/docs-md/page-header.md +96 -0
- package/agent/docs-md/pagination.md +88 -0
- package/agent/docs-md/pane-layout.md +105 -0
- package/agent/docs-md/period-comparison.md +66 -0
- package/agent/docs-md/plan-card.md +120 -0
- package/agent/docs-md/popover-rules.md +88 -0
- package/agent/docs-md/prompt-editor.md +90 -0
- package/agent/docs-md/propose-apply.md +65 -0
- package/agent/docs-md/provider-logo.md +97 -0
- package/agent/docs-md/radio.md +83 -0
- package/agent/docs-md/ranked-list.md +102 -0
- package/agent/docs-md/reasoning-progress.md +72 -0
- package/agent/docs-md/record-detail.md +71 -0
- package/agent/docs-md/regenerating-answers.md +70 -0
- package/agent/docs-md/run-status-bar.md +94 -0
- package/agent/docs-md/saved-tasks-schedules.md +63 -0
- package/agent/docs-md/saved-views.md +149 -0
- package/agent/docs-md/saving-views.md +68 -0
- package/agent/docs-md/scheduler.md +258 -0
- package/agent/docs-md/secondary-panels.md +72 -0
- package/agent/docs-md/segmented-control.md +88 -0
- package/agent/docs-md/selection-bulk-actions.md +70 -0
- package/agent/docs-md/separator.md +75 -0
- package/agent/docs-md/setup-checklist.md +92 -0
- package/agent/docs-md/shape-elevation.md +55 -0
- package/agent/docs-md/sheet-grid.md +211 -0
- package/agent/docs-md/side-panel.md +134 -0
- package/agent/docs-md/sign-in.md +114 -0
- package/agent/docs-md/spacing.md +55 -0
- package/agent/docs-md/split-button.md +100 -0
- package/agent/docs-md/stat-compare.md +82 -0
- package/agent/docs-md/static-dashboard.md +75 -0
- package/agent/docs-md/status-badge.md +140 -0
- package/agent/docs-md/step-card.md +75 -0
- package/agent/docs-md/stepper.md +92 -0
- package/agent/docs-md/suggested-changes.md +91 -0
- package/agent/docs-md/switch.md +78 -0
- package/agent/docs-md/table-settings.md +81 -0
- package/agent/docs-md/tabs.md +85 -0
- package/agent/docs-md/tag-input.md +108 -0
- package/agent/docs-md/tag.md +79 -0
- package/agent/docs-md/task-builder.md +87 -0
- package/agent/docs-md/team-instructions.md +72 -0
- package/agent/docs-md/templates-gallery.md +85 -0
- package/agent/docs-md/text-input.md +160 -0
- package/agent/docs-md/thinking-steps.md +89 -0
- package/agent/docs-md/timeline.md +189 -0
- package/agent/docs-md/timestamp.md +84 -0
- package/agent/docs-md/timestamps.md +70 -0
- package/agent/docs-md/toast.md +117 -0
- package/agent/docs-md/tooltip.md +90 -0
- package/agent/docs-md/typography.md +62 -0
- package/agent/docs-md/undo-destructive.md +66 -0
- package/agent/docs-md/unsaved-changes.md +71 -0
- package/agent/docs-md/usage-meter.md +80 -0
- package/agent/docs-md/user-feedback.md +71 -0
- package/agent/docs-md/voice-and-tone.md +60 -0
- package/agent/docs-md/why-choice.md +93 -0
- package/agent/docs-md/workspace-onboarding.md +74 -0
- package/agent/docs-md/writing.md +48 -0
- package/agent/llms-full.txt +18369 -0
- package/agent/llms.txt +198 -0
- package/agent/skills/twoux-audit/SKILL.md +49 -0
- package/agent/skills/twoux-build/SKILL.md +39 -0
- package/agent/skills/twoux-design/SKILL.md +54 -0
- package/agent/skills/twoux-write/SKILL.md +32 -0
- package/bin/check.mjs +123 -0
- package/bin/docs.mjs +24 -0
- package/bin/guard.mjs +27 -0
- package/bin/init.mjs +113 -0
- package/bin/knowledge.mjs +87 -0
- package/bin/mcp.mjs +116 -0
- package/bin/theme.mjs +168 -0
- package/bin/twoux.mjs +25 -0
- package/package.json +80 -3
- package/registry/jobs.json +1028 -0
- package/registry/palette.json +770 -0
- package/registry/rules.json +913 -0
- package/src/components/agent-review.tsx +364 -0
- package/src/components/ai-basics.tsx +212 -0
- package/src/components/ai-work.tsx +304 -0
- package/src/components/ai.tsx +227 -0
- package/src/components/alert-dialog.tsx +203 -0
- package/src/components/alert.tsx +86 -0
- package/src/components/avatar.tsx +114 -0
- package/src/components/badge.tsx +59 -0
- package/src/components/board.tsx +397 -0
- package/src/components/breadcrumb.tsx +114 -0
- package/src/components/button-group.tsx +90 -0
- package/src/components/button.tsx +109 -0
- package/src/components/calendar.tsx +247 -0
- package/src/components/card.tsx +102 -0
- package/src/components/chart-map.tsx +336 -0
- package/src/components/charts-more.tsx +1331 -0
- package/src/components/charts-radial.tsx +328 -0
- package/src/components/charts.tsx +390 -0
- package/src/components/checkbox.tsx +40 -0
- package/src/components/code.tsx +139 -0
- package/src/components/collapsible.tsx +37 -0
- package/src/components/combobox.tsx +339 -0
- package/src/components/command-extras.tsx +11 -0
- package/src/components/command.tsx +196 -0
- package/src/components/data-grid.tsx +213 -0
- package/src/components/data-table.tsx +334 -0
- package/src/components/data-views.tsx +740 -0
- package/src/components/date-picker.tsx +686 -0
- package/src/components/dense-table.tsx +207 -0
- package/src/components/dialog.tsx +162 -0
- package/src/components/dropdown-menu.tsx +264 -0
- package/src/components/empty.tsx +114 -0
- package/src/components/feedback-prompt.tsx +80 -0
- package/src/components/field.tsx +254 -0
- package/src/components/fields.tsx +112 -0
- package/src/components/flow.tsx +840 -0
- package/src/components/guide-cue.tsx +273 -0
- package/src/components/inbox.tsx +113 -0
- package/src/components/input-group.tsx +176 -0
- package/src/components/input.tsx +26 -0
- package/src/components/kbd.tsx +34 -0
- package/src/components/label.tsx +27 -0
- package/src/components/labels.tsx +176 -0
- package/src/components/motion.tsx +136 -0
- package/src/components/onboarding.tsx +454 -0
- package/src/components/order-book.tsx +185 -0
- package/src/components/pane-layout.tsx +417 -0
- package/src/components/popover.tsx +96 -0
- package/src/components/provider-logos.data.ts +49 -0
- package/src/components/radio-group.tsx +52 -0
- package/src/components/scheduler.tsx +997 -0
- package/src/components/segmented-control.tsx +28 -0
- package/src/components/select.tsx +195 -0
- package/src/components/separator.tsx +31 -0
- package/src/components/sheet.tsx +148 -0
- package/src/components/shell.tsx +426 -0
- package/src/components/shortcut-sheet.tsx +83 -0
- package/src/components/skeleton.tsx +18 -0
- package/src/components/sonner.tsx +107 -0
- package/src/components/spinner.tsx +13 -0
- package/src/components/split-button.tsx +58 -0
- package/src/components/stepper.tsx +78 -0
- package/src/components/switch.tsx +43 -0
- package/src/components/table.tsx +126 -0
- package/src/components/tabs.tsx +118 -0
- package/src/components/tag-input.tsx +253 -0
- package/src/components/textarea.tsx +21 -0
- package/src/components/theme.tsx +80 -0
- package/src/components/timeline.tsx +408 -0
- package/src/components/toggle-group.tsx +87 -0
- package/src/components/toggle.tsx +54 -0
- package/src/components/tooltip.tsx +65 -0
- package/src/components/workspace-actions.tsx +294 -0
- package/src/components/workspace-import.tsx +465 -0
- package/src/components/workspace.tsx +1884 -0
- package/src/index.ts +80 -0
- package/src/lib/active-indicator.tsx +53 -0
- package/src/lib/chart-kit.tsx +72 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/shortcuts.tsx +61 -0
- package/src/lib/workspace.tsx +10 -0
- package/src/shadcn/alert-dialog.tsx +196 -0
- package/src/shadcn/alert.tsx +65 -0
- package/src/shadcn/avatar.tsx +108 -0
- package/src/shadcn/badge.tsx +47 -0
- package/src/shadcn/breadcrumb.tsx +108 -0
- package/src/shadcn/button-group.tsx +83 -0
- package/src/shadcn/button.tsx +63 -0
- package/src/shadcn/calendar.tsx +220 -0
- package/src/shadcn/card.tsx +91 -0
- package/src/shadcn/checkbox.tsx +31 -0
- package/src/shadcn/collapsible.tsx +33 -0
- package/src/shadcn/command.tsx +184 -0
- package/src/shadcn/dialog.tsx +158 -0
- package/src/shadcn/dropdown-menu.tsx +256 -0
- package/src/shadcn/empty.tsx +103 -0
- package/src/shadcn/field.tsx +248 -0
- package/src/shadcn/input-group.tsx +170 -0
- package/src/shadcn/input.tsx +20 -0
- package/src/shadcn/kbd.tsx +28 -0
- package/src/shadcn/label.tsx +23 -0
- package/src/shadcn/popover.tsx +88 -0
- package/src/shadcn/radio-group.tsx +44 -0
- package/src/shadcn/select.tsx +189 -0
- package/src/shadcn/separator.tsx +27 -0
- package/src/shadcn/sheet.tsx +142 -0
- package/src/shadcn/skeleton.tsx +13 -0
- package/src/shadcn/sonner.tsx +40 -0
- package/src/shadcn/switch.tsx +34 -0
- package/src/shadcn/table.tsx +115 -0
- package/src/shadcn/tabs.tsx +90 -0
- package/src/shadcn/textarea.tsx +17 -0
- package/src/shadcn/toggle-group.tsx +83 -0
- package/src/shadcn/toggle.tsx +46 -0
- package/src/shadcn/tooltip.tsx +56 -0
- package/src/styles/palette.css +127 -0
- package/src/styles/theme.css +35 -0
- package/src/styles/twoux.css +236 -0
- package/theme.json +14 -0
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TwoUX compositions for bringing data in.
|
|
3
|
+
* FieldMapping: each target field with the source column that fills it, required fields marked, type mismatches
|
|
4
|
+
* warned in words, and the other source columns either skipped or created as new columns. Reusable for imports
|
|
5
|
+
* and automations alike.
|
|
6
|
+
* CsvImport: choose a CSV file (or a sample), set the delimiter and header row, map the columns, see the first 20 rows
|
|
7
|
+
* with problem cells marked in words, read the summary of what will be skipped, then "Import 1,248 rows".
|
|
8
|
+
*/
|
|
9
|
+
import { ArrowLeft, CircleAlert, FileSpreadsheet, TriangleAlert } from "lucide-react"
|
|
10
|
+
import * as React from "react"
|
|
11
|
+
import { cn } from "../lib/cn"
|
|
12
|
+
import { Alert, AlertDescription, AlertTitle } from "./alert"
|
|
13
|
+
import { Button } from "./button"
|
|
14
|
+
import { Checkbox } from "./checkbox"
|
|
15
|
+
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./dialog"
|
|
16
|
+
import { Input } from "./input"
|
|
17
|
+
import { FileDrop } from "./onboarding"
|
|
18
|
+
import { RadioGroup, RadioGroupItem } from "./radio-group"
|
|
19
|
+
import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from "./select"
|
|
20
|
+
import { Switch } from "./switch"
|
|
21
|
+
import { ColumnTypeIcon, toIsoDate, type ColumnType } from "./workspace"
|
|
22
|
+
|
|
23
|
+
/* ---------------------------------------------------------------- values */
|
|
24
|
+
|
|
25
|
+
export type FieldKind = "text" | "number" | "date" | "email" | "url" | "select"
|
|
26
|
+
export type MappingTarget = { id: string; label: string; kind: FieldKind; required?: boolean; options?: string[] }
|
|
27
|
+
export type MappingSource = { id: string; label: string; samples: string[] }
|
|
28
|
+
/** Target field id → source column id, or null for not filled. */
|
|
29
|
+
export type FieldMap = Record<string, string | null>
|
|
30
|
+
/** Source column id → what happens to a column no field uses. */
|
|
31
|
+
export type ExtraColumns = Record<string, "skip" | "create">
|
|
32
|
+
|
|
33
|
+
const KIND_WORD: Record<FieldKind, string> = { text: "text", number: "a number", date: "a date", email: "an email address", url: "a link", select: "one of the choices" }
|
|
34
|
+
const KIND_ICON: Record<FieldKind, ColumnType> = { text: "text", number: "number", date: "date", email: "text", url: "text", select: "select" }
|
|
35
|
+
|
|
36
|
+
/** The problem with a value for this kind of field, in two or three words, or undefined when it fits. */
|
|
37
|
+
export function checkFieldValue(kind: FieldKind, value: string, options?: string[]): string | undefined {
|
|
38
|
+
const v = value.trim()
|
|
39
|
+
if (!v) return undefined
|
|
40
|
+
if (kind === "number" && Number.isNaN(Number(v.replace(/[,£$€\s]/g, "")))) return "Not a number"
|
|
41
|
+
if (kind === "date" && !toIsoDate(v)) return "Not a date"
|
|
42
|
+
if (kind === "email" && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v)) return "Invalid email"
|
|
43
|
+
if (kind === "url" && !/^(https?:\/\/)?[\w-]+(\.[\w-]+)+\S*$/.test(v)) return "Not a link"
|
|
44
|
+
if (kind === "select" && options && !options.some((o) => o.toLowerCase() === v.toLowerCase())) return "Not a choice"
|
|
45
|
+
return undefined
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, "")
|
|
49
|
+
/** Pairs fields and columns whose names match, ignoring case and punctuation ("E-mail" fills "Email"). */
|
|
50
|
+
export function autoMapFields(targets: MappingTarget[], sources: MappingSource[], aliases: Record<string, string[]> = {}): FieldMap {
|
|
51
|
+
const used = new Set<string>()
|
|
52
|
+
return Object.fromEntries(targets.map((t) => {
|
|
53
|
+
const names = [t.label, t.id, ...(aliases[t.id] ?? [])].map(norm)
|
|
54
|
+
const s = sources.find((x) => !used.has(x.id) && names.includes(norm(x.label))) ?? sources.find((x) => !used.has(x.id) && names.some((n) => norm(x.label).includes(n) || n.includes(norm(x.label))))
|
|
55
|
+
if (s) used.add(s.id)
|
|
56
|
+
return [t.id, s?.id ?? null]
|
|
57
|
+
}))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** RFC 4180 CSV: quoted fields, doubled quotes, line breaks inside quotes. */
|
|
61
|
+
export function parseCsv(text: string, delimiter = ","): string[][] {
|
|
62
|
+
const rows: string[][] = []
|
|
63
|
+
let row: string[] = [], cell = "", quoted = false
|
|
64
|
+
for (let i = 0; i < text.length; i++) {
|
|
65
|
+
const ch = text[i]
|
|
66
|
+
if (quoted) {
|
|
67
|
+
if (ch === '"' && text[i + 1] === '"') { cell += '"'; i++ }
|
|
68
|
+
else if (ch === '"') quoted = false
|
|
69
|
+
else cell += ch
|
|
70
|
+
} else if (ch === '"' && cell === "") quoted = true
|
|
71
|
+
else if (ch === delimiter) { row.push(cell); cell = "" }
|
|
72
|
+
else if (ch === "\n" || ch === "\r") {
|
|
73
|
+
if (ch === "\r" && text[i + 1] === "\n") i++
|
|
74
|
+
row.push(cell); cell = ""
|
|
75
|
+
if (row.some((c) => c !== "")) rows.push(row)
|
|
76
|
+
row = []
|
|
77
|
+
} else cell += ch
|
|
78
|
+
}
|
|
79
|
+
row.push(cell)
|
|
80
|
+
if (row.some((c) => c !== "")) rows.push(row)
|
|
81
|
+
return rows
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const detectDelimiter = (text: string) => {
|
|
85
|
+
const line = text.split(/\r?\n/)[0] ?? ""
|
|
86
|
+
return [",", ";", "\t", "|"].map((d) => [d, line.split(d).length] as const).sort((a, b) => b[1] - a[1])[0][0]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* ---------------------------------------------------------------- field mapping */
|
|
90
|
+
|
|
91
|
+
export function FieldMapping({
|
|
92
|
+
targets,
|
|
93
|
+
sources,
|
|
94
|
+
value,
|
|
95
|
+
onChange,
|
|
96
|
+
extra,
|
|
97
|
+
onExtraChange,
|
|
98
|
+
errors = {},
|
|
99
|
+
targetHeading = "Field in the table",
|
|
100
|
+
sourceHeading = "Column in the file",
|
|
101
|
+
className,
|
|
102
|
+
}: {
|
|
103
|
+
targets: MappingTarget[]
|
|
104
|
+
sources: MappingSource[]
|
|
105
|
+
value: FieldMap
|
|
106
|
+
onChange: (value: FieldMap) => void
|
|
107
|
+
/** What happens to source columns no field uses. Omit both to hide that section. */
|
|
108
|
+
extra?: ExtraColumns
|
|
109
|
+
onExtraChange?: (extra: ExtraColumns) => void
|
|
110
|
+
/** Messages per target id after a failed submit, e.g. a required field with no column. */
|
|
111
|
+
errors?: Record<string, string>
|
|
112
|
+
targetHeading?: string
|
|
113
|
+
sourceHeading?: string
|
|
114
|
+
className?: string
|
|
115
|
+
}) {
|
|
116
|
+
const id = React.useId()
|
|
117
|
+
const used = new Set(Object.values(value).filter(Boolean) as string[])
|
|
118
|
+
const unused = sources.filter((s) => !used.has(s.id))
|
|
119
|
+
const NONE = "__none"
|
|
120
|
+
return (
|
|
121
|
+
<div data-slot="field-mapping" className={cn("grid gap-4", className)}>
|
|
122
|
+
<div role="table" aria-label="Field mapping" className="grid">
|
|
123
|
+
<div role="row" className="grid grid-cols-[minmax(0,1fr)_1.25rem_minmax(0,1fr)] items-center gap-2 border-b border-line pb-2 text-sm font-medium text-ink-2">
|
|
124
|
+
<span role="columnheader">{targetHeading}</span><span aria-hidden="true" /><span role="columnheader">{sourceHeading}</span>
|
|
125
|
+
</div>
|
|
126
|
+
{targets.map((t) => {
|
|
127
|
+
const src = sources.find((s) => s.id === value[t.id])
|
|
128
|
+
const bad = src ? src.samples.filter((v) => checkFieldValue(t.kind, v, t.options)).length : 0
|
|
129
|
+
const err = errors[t.id]
|
|
130
|
+
const fid = `${id}-${t.id}`
|
|
131
|
+
return (
|
|
132
|
+
<div key={t.id} role="row" className="grid gap-1 border-b border-line py-2 last:border-b-0">
|
|
133
|
+
<div className="grid grid-cols-[minmax(0,1fr)_1.25rem_minmax(0,1fr)] items-center gap-2">
|
|
134
|
+
<label role="rowheader" htmlFor={fid} className="flex min-w-0 items-center gap-control text-base text-ink">
|
|
135
|
+
<ColumnTypeIcon type={KIND_ICON[t.kind]} />
|
|
136
|
+
<span className="truncate">{t.label}</span>
|
|
137
|
+
{t.required && <span className="shrink-0 text-sm text-ink-3">Required</span>}
|
|
138
|
+
</label>
|
|
139
|
+
<ArrowLeft className="size-3.5 text-ink-3" aria-hidden="true" />
|
|
140
|
+
<span role="cell" className="min-w-0">
|
|
141
|
+
<Select value={value[t.id] ?? NONE} onValueChange={(v) => onChange({ ...value, [t.id]: v === NONE ? null : v })}>
|
|
142
|
+
<SelectTrigger id={fid} size="sm" className="w-full" aria-invalid={!!err || undefined} aria-describedby={err || bad ? `${fid}-msg` : undefined}><SelectValue /></SelectTrigger>
|
|
143
|
+
<SelectContent>
|
|
144
|
+
<SelectItem value={NONE}>{t.required ? "Choose a column" : "Leave empty"}</SelectItem>
|
|
145
|
+
<SelectSeparator />
|
|
146
|
+
{sources.map((s) => (
|
|
147
|
+
<SelectItem key={s.id} value={s.id} disabled={used.has(s.id) && value[t.id] !== s.id}>
|
|
148
|
+
{s.label}{s.samples[0] ? <span className="text-ink-3"> · {s.samples[0]}</span> : null}
|
|
149
|
+
</SelectItem>
|
|
150
|
+
))}
|
|
151
|
+
</SelectContent>
|
|
152
|
+
</Select>
|
|
153
|
+
</span>
|
|
154
|
+
</div>
|
|
155
|
+
{err ? (
|
|
156
|
+
<p id={`${fid}-msg`} className="flex items-start gap-control text-sm text-bad"><CircleAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />{err}</p>
|
|
157
|
+
) : bad > 0 && src ? (
|
|
158
|
+
<p id={`${fid}-msg`} className="flex items-start gap-control text-sm text-warn"><TriangleAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />{bad} of {src.samples.length} values in “{src.label}” aren't {KIND_WORD[t.kind]}.</p>
|
|
159
|
+
) : null}
|
|
160
|
+
</div>
|
|
161
|
+
)
|
|
162
|
+
})}
|
|
163
|
+
</div>
|
|
164
|
+
{extra && onExtraChange && unused.length > 0 && (
|
|
165
|
+
<section aria-labelledby={`${id}-other`} className="grid gap-1">
|
|
166
|
+
<h3 id={`${id}-other`} className="text-sm font-medium text-ink-2">Other columns in the file</h3>
|
|
167
|
+
<ul className="grid divide-y divide-line rounded-sm border border-line">
|
|
168
|
+
{unused.map((s) => (
|
|
169
|
+
<li key={s.id} className="flex flex-wrap items-center gap-2 px-3 py-2">
|
|
170
|
+
<span className="grid min-w-0 flex-1"><span className="truncate text-base text-ink">{s.label}</span>{s.samples[0] && <span className="truncate text-sm text-ink-3">e.g. {s.samples[0]}</span>}</span>
|
|
171
|
+
<Select value={extra[s.id] ?? "skip"} onValueChange={(v) => onExtraChange({ ...extra, [s.id]: v as "skip" | "create" })}>
|
|
172
|
+
<SelectTrigger size="sm" className="w-40" aria-label={`What to do with ${s.label}`}><SelectValue /></SelectTrigger>
|
|
173
|
+
<SelectContent>
|
|
174
|
+
<SelectItem value="skip">Don't import</SelectItem>
|
|
175
|
+
<SelectItem value="create">Create column</SelectItem>
|
|
176
|
+
</SelectContent>
|
|
177
|
+
</Select>
|
|
178
|
+
</li>
|
|
179
|
+
))}
|
|
180
|
+
</ul>
|
|
181
|
+
</section>
|
|
182
|
+
)}
|
|
183
|
+
</div>
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/* ---------------------------------------------------------------- CSV import */
|
|
188
|
+
|
|
189
|
+
export type CsvImportResult = {
|
|
190
|
+
/** One object per imported row, keyed by target id; created columns are keyed by their source label. Date fields arrive as ISO 8601 (2026-11-02); 02/11/2026 is read day first, as in the UK. */
|
|
191
|
+
rows: Record<string, string>[]
|
|
192
|
+
/** Source labels turned into new columns. */
|
|
193
|
+
created: string[]
|
|
194
|
+
skipped: number
|
|
195
|
+
/** Why rows were skipped or imported with empty cells, one entry per field and problem, so the app can say what happened. */
|
|
196
|
+
problems: CsvImportProblem[]
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export type CsvImportProblem = {
|
|
200
|
+
/** Target id and label of the field. */
|
|
201
|
+
field: string
|
|
202
|
+
label: string
|
|
203
|
+
/** missing: a required value was empty. invalid: the value didn't fit the field (Not a date, Invalid email…). */
|
|
204
|
+
problem: "missing" | "invalid"
|
|
205
|
+
/** Rows with this problem. A row with two problems counts once in each. */
|
|
206
|
+
rows: number
|
|
207
|
+
/** What happened to those rows: skipped, or imported with this cell left empty. */
|
|
208
|
+
outcome: "skipped" | "emptied"
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const DELIMS = [["auto", "Detect automatically"], [",", "Comma"], [";", "Semicolon"], ["\t", "Tab"], ["|", "Pipe"]] as const
|
|
212
|
+
const n = (x: number) => x.toLocaleString("en-GB")
|
|
213
|
+
|
|
214
|
+
export function CsvImport({
|
|
215
|
+
open,
|
|
216
|
+
onOpenChange,
|
|
217
|
+
targets,
|
|
218
|
+
tableName,
|
|
219
|
+
sample,
|
|
220
|
+
aliases,
|
|
221
|
+
onImport,
|
|
222
|
+
}: {
|
|
223
|
+
open: boolean
|
|
224
|
+
onOpenChange: (open: boolean) => void
|
|
225
|
+
targets: MappingTarget[]
|
|
226
|
+
/** "People" */
|
|
227
|
+
tableName: string
|
|
228
|
+
/** Lets people try the flow without a file. */
|
|
229
|
+
sample?: { name: string; text: string }
|
|
230
|
+
/** Other names a field goes by, for automatic mapping: { email: ["work email"] } */
|
|
231
|
+
aliases?: Record<string, string[]>
|
|
232
|
+
onImport: (result: CsvImportResult) => void
|
|
233
|
+
}) {
|
|
234
|
+
const [file, setFile] = React.useState<{ name: string; text: string } | null>(null)
|
|
235
|
+
const [fileError, setFileError] = React.useState("")
|
|
236
|
+
const [delim, setDelim] = React.useState<string>("auto")
|
|
237
|
+
const [header, setHeader] = React.useState(true)
|
|
238
|
+
const [step, setStep] = React.useState<1 | 2>(1)
|
|
239
|
+
const [map, setMap] = React.useState<FieldMap>({})
|
|
240
|
+
const [extra, setExtra] = React.useState<ExtraColumns>({})
|
|
241
|
+
const [badRows, setBadRows] = React.useState<"skip" | "empty">("skip")
|
|
242
|
+
const [onlyProblems, setOnlyProblems] = React.useState(false)
|
|
243
|
+
const [edits, setEdits] = React.useState<Record<string, string>>({})
|
|
244
|
+
const [fixing, setFixing] = React.useState<string | null>(null)
|
|
245
|
+
const [errors, setErrors] = React.useState<Record<string, string>>({})
|
|
246
|
+
const summary = React.useRef<HTMLDivElement>(null)
|
|
247
|
+
|
|
248
|
+
React.useEffect(() => {
|
|
249
|
+
if (!open) return
|
|
250
|
+
setFile(null); setFileError(""); setDelim("auto"); setHeader(true); setStep(1); setMap({}); setExtra({}); setBadRows("skip"); setOnlyProblems(false); setEdits({}); setFixing(null); setErrors({})
|
|
251
|
+
}, [open])
|
|
252
|
+
|
|
253
|
+
const parsed = React.useMemo(() => {
|
|
254
|
+
if (!file) return null
|
|
255
|
+
const d = delim === "auto" ? detectDelimiter(file.text) : delim
|
|
256
|
+
const all = parseCsv(file.text, d)
|
|
257
|
+
const width = Math.max(0, ...all.map((r) => r.length))
|
|
258
|
+
const head = header ? all[0] ?? [] : []
|
|
259
|
+
const body = header ? all.slice(1) : all
|
|
260
|
+
const sources: MappingSource[] = Array.from({ length: width }, (_, i) => ({ id: `c${i}`, label: (head[i] ?? "").trim() || `Column ${i + 1}`, samples: body.slice(0, 20).map((r) => r[i] ?? "").filter(Boolean) }))
|
|
261
|
+
return { sources, body, delimiter: d }
|
|
262
|
+
}, [file, delim, header])
|
|
263
|
+
|
|
264
|
+
// Map once per parsed file; targets and aliases may be new objects on every render.
|
|
265
|
+
const mapInputs = React.useRef({ targets, aliases })
|
|
266
|
+
mapInputs.current = { targets, aliases }
|
|
267
|
+
React.useEffect(() => { if (parsed) { setMap(autoMapFields(mapInputs.current.targets, parsed.sources, mapInputs.current.aliases)); setExtra({}); setEdits({}) } }, [parsed])
|
|
268
|
+
|
|
269
|
+
const cellOf = (r: number, sid: string) => edits[`${r}:${sid}`] ?? parsed?.body[r]?.[Number(sid.slice(1))] ?? ""
|
|
270
|
+
const problems = React.useMemo(() => {
|
|
271
|
+
if (!parsed) return { byRow: new Map<number, Map<string, string>>(), byTarget: new Map<string, number>(), missing: 0 }
|
|
272
|
+
const byRow = new Map<number, Map<string, string>>()
|
|
273
|
+
const byTarget = new Map<string, number>()
|
|
274
|
+
let missing = 0
|
|
275
|
+
parsed.body.forEach((_, r) => {
|
|
276
|
+
let rowMissing = false
|
|
277
|
+
for (const t of targets) {
|
|
278
|
+
const sid = map[t.id]
|
|
279
|
+
if (!sid) continue
|
|
280
|
+
const v = cellOf(r, sid)
|
|
281
|
+
const p = t.required && !v.trim() ? `No ${t.label.toLowerCase()}` : checkFieldValue(t.kind, v, t.options)
|
|
282
|
+
if (!p) continue
|
|
283
|
+
if (t.required && !v.trim()) rowMissing = true
|
|
284
|
+
if (!byRow.has(r)) byRow.set(r, new Map())
|
|
285
|
+
byRow.get(r)!.set(sid, p)
|
|
286
|
+
const key = `${t.id}:${t.required && !v.trim() ? "missing" : "invalid"}`
|
|
287
|
+
byTarget.set(key, (byTarget.get(key) ?? 0) + 1)
|
|
288
|
+
}
|
|
289
|
+
if (rowMissing) missing++
|
|
290
|
+
})
|
|
291
|
+
return { byRow, byTarget, missing }
|
|
292
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
293
|
+
}, [parsed, map, edits, targets])
|
|
294
|
+
|
|
295
|
+
const total = parsed?.body.length ?? 0
|
|
296
|
+
const problemRows = problems.byRow.size
|
|
297
|
+
const skipped = badRows === "skip" ? problemRows : problems.missing
|
|
298
|
+
const importing = total - skipped
|
|
299
|
+
|
|
300
|
+
const take = async (f: File) => {
|
|
301
|
+
if (!/\.csv$/i.test(f.name)) { setFileError("That isn't a CSV file. Save the sheet as CSV and choose it again."); return }
|
|
302
|
+
setFileError("")
|
|
303
|
+
setFile({ name: f.name, text: await f.text() })
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const next = () => {
|
|
307
|
+
if (!parsed || !parsed.body.length) { setFileError(parsed ? "That file has no rows. Choose a file with at least one row under the column names." : "Choose a CSV file, or use the sample file."); return }
|
|
308
|
+
setStep(2)
|
|
309
|
+
}
|
|
310
|
+
const submit = () => {
|
|
311
|
+
const errs: Record<string, string> = {}
|
|
312
|
+
for (const t of targets) if (t.required && !map[t.id]) errs[t.id] = `Choose the column that holds ${t.label.toLowerCase()}. It's required for every row.`
|
|
313
|
+
setErrors(errs)
|
|
314
|
+
if (Object.keys(errs).length) { summary.current?.focus(); return }
|
|
315
|
+
const created = Object.entries(extra).filter(([, v]) => v === "create").map(([sid]) => sid)
|
|
316
|
+
const rows: Record<string, string>[] = []
|
|
317
|
+
parsed!.body.forEach((_, r) => {
|
|
318
|
+
const p = problems.byRow.get(r)
|
|
319
|
+
const missingRequired = targets.some((t) => t.required && map[t.id] && !cellOf(r, map[t.id]!).trim())
|
|
320
|
+
if (p && (badRows === "skip" || missingRequired)) return
|
|
321
|
+
const out: Record<string, string> = {}
|
|
322
|
+
for (const t of targets) { const sid = map[t.id]; if (!sid) continue; const v = p?.has(sid) ? "" : cellOf(r, sid); out[t.id] = t.kind === "date" && v.trim() ? (toIsoDate(v.trim()) ?? "") : v }
|
|
323
|
+
for (const sid of created) out[parsed!.sources.find((s) => s.id === sid)!.label] = cellOf(r, sid)
|
|
324
|
+
rows.push(out)
|
|
325
|
+
})
|
|
326
|
+
const report: CsvImportProblem[] = [...problems.byTarget.entries()].map(([key, count]) => {
|
|
327
|
+
const [tid, why] = key.split(":") as [string, "missing" | "invalid"]
|
|
328
|
+
const t = targets.find((x) => x.id === tid)!
|
|
329
|
+
return { field: tid, label: t.label, problem: why, rows: count, outcome: why === "missing" || badRows === "skip" ? "skipped" : "emptied" }
|
|
330
|
+
})
|
|
331
|
+
onImport({ rows, created: created.map((sid) => parsed!.sources.find((s) => s.id === sid)!.label), skipped: total - rows.length, problems: report })
|
|
332
|
+
onOpenChange(false)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const mappedCols = parsed ? [
|
|
336
|
+
...targets.filter((t) => map[t.id]).map((t) => ({ key: t.id, label: t.label, sid: map[t.id]! })),
|
|
337
|
+
...Object.entries(extra).filter(([, v]) => v === "create").map(([sid]) => ({ key: sid, label: `${parsed.sources.find((s) => s.id === sid)?.label} (new)`, sid })),
|
|
338
|
+
] : []
|
|
339
|
+
const previewRows = parsed ? (onlyProblems ? [...problems.byRow.keys()] : parsed.body.map((_, i) => i)).slice(0, 20) : []
|
|
340
|
+
const PHRASE: Record<FieldKind, string> = { email: "an invalid email", number: "a value that isn't a number", date: "a date that can't be read", url: "an invalid link", select: "a value that isn't one of the choices", text: "an invalid value" }
|
|
341
|
+
const summaryLines = [...problems.byTarget.entries()].map(([key, count]) => {
|
|
342
|
+
const [tid, why] = key.split(":")
|
|
343
|
+
const t = targets.find((x) => x.id === tid)!
|
|
344
|
+
return `${n(count)} ${count === 1 ? "row has" : "rows have"} ${why === "missing" ? `no ${t.label.toLowerCase()}` : PHRASE[t.kind]}.`
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
return (
|
|
348
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
349
|
+
<DialogContent className="max-h-9/10 grid-rows-[auto_minmax(0,1fr)_auto] sm:max-w-4xl">
|
|
350
|
+
<DialogHeader>
|
|
351
|
+
<p className="text-sm text-ink-3">Step {step} of 2</p>
|
|
352
|
+
<DialogTitle>{step === 1 ? `Import a CSV file into ${tableName}` : "Match columns and check the rows"}</DialogTitle>
|
|
353
|
+
<DialogDescription>{step === 1 ? "Rows are added at the bottom of the table. Nothing changes until you press Import." : `${file?.name} · ${n(total)} rows · ${parsed?.sources.length} columns`}</DialogDescription>
|
|
354
|
+
</DialogHeader>
|
|
355
|
+
|
|
356
|
+
<div className="-mx-6 min-h-0 overflow-y-auto px-6">
|
|
357
|
+
{step === 1 ? (
|
|
358
|
+
<div className="grid gap-4 pb-1">
|
|
359
|
+
<FileDrop id="csv-file" label="CSV file" accept="text/csv,application/vnd.ms-excel,.csv" hint="CSV, with a header row, up to 10 MB" maxBytes={10_000_000} onFile={take} error={fileError} />
|
|
360
|
+
{sample && !file && <p className="text-sm text-ink-2">No file to hand? <Button variant="link" size="xs" onClick={() => { setFile(sample); setFileError("") }}>Use the sample file</Button></p>}
|
|
361
|
+
{file && parsed && (
|
|
362
|
+
<p className="flex items-center gap-2 text-base text-ink"><FileSpreadsheet className="size-4 text-ink-3" aria-hidden="true" />{file.name}<span className="tabular text-ink-2">· {n(parsed.body.length)} rows · {parsed.sources.length} columns</span></p>
|
|
363
|
+
)}
|
|
364
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
365
|
+
<div className="grid gap-2">
|
|
366
|
+
<label htmlFor="csv-delim" className="text-base font-medium text-ink">Delimiter</label>
|
|
367
|
+
<Select value={delim} onValueChange={setDelim}>
|
|
368
|
+
<SelectTrigger id="csv-delim" className="w-full"><SelectValue /></SelectTrigger>
|
|
369
|
+
<SelectContent>{DELIMS.map(([v, l]) => <SelectItem key={v} value={v}>{l}{v === "auto" && parsed ? ` (${DELIMS.find((d) => d[0] === parsed.delimiter)?.[1].toLowerCase()})` : ""}</SelectItem>)}</SelectContent>
|
|
370
|
+
</Select>
|
|
371
|
+
</div>
|
|
372
|
+
<label className="flex items-center gap-2 self-end pb-2 text-base text-ink">
|
|
373
|
+
<Checkbox checked={header} onCheckedChange={(v) => setHeader(v === true)} />
|
|
374
|
+
First row contains column names
|
|
375
|
+
</label>
|
|
376
|
+
</div>
|
|
377
|
+
</div>
|
|
378
|
+
) : parsed && (
|
|
379
|
+
<div className="grid gap-5 pb-1">
|
|
380
|
+
<div ref={summary} tabIndex={-1} className="outline-none">
|
|
381
|
+
{Object.keys(errors).length > 0 ? (
|
|
382
|
+
<Alert variant="destructive">
|
|
383
|
+
<CircleAlert />
|
|
384
|
+
<AlertTitle>{Object.keys(errors).length === 1 ? "1 required field has no column" : `${Object.keys(errors).length} required fields have no column`}</AlertTitle>
|
|
385
|
+
<AlertDescription>Choose a column for {Object.keys(errors).map((k) => targets.find((t) => t.id === k)?.label).join(" and ")} below, then import.</AlertDescription>
|
|
386
|
+
</Alert>
|
|
387
|
+
) : problemRows > 0 ? (
|
|
388
|
+
<Alert variant="warning">
|
|
389
|
+
<TriangleAlert />
|
|
390
|
+
<AlertTitle>{summaryLines.join(" ")} {problemRows === 1 ? "Fix it in the preview, or choose what happens to it." : "Fix them in the preview, or choose what happens to them."}</AlertTitle>
|
|
391
|
+
<AlertDescription>
|
|
392
|
+
<RadioGroup value={badRows} onValueChange={(v) => setBadRows(v as "skip" | "empty")} aria-label="Rows with problems" className="mt-1 gap-2">
|
|
393
|
+
<label className="flex items-center gap-2"><RadioGroupItem value="skip" />Skip {n(problemRows)} {problemRows === 1 ? "row" : "rows"} with problems</label>
|
|
394
|
+
<label className="flex items-center gap-2"><RadioGroupItem value="empty" />Import them and leave the problem cells empty{problems.missing ? ` (${n(problems.missing)} without a required value ${problems.missing === 1 ? "is" : "are"} still skipped)` : ""}</label>
|
|
395
|
+
</RadioGroup>
|
|
396
|
+
</AlertDescription>
|
|
397
|
+
</Alert>
|
|
398
|
+
) : (
|
|
399
|
+
<Alert><AlertTitle>All {n(total)} rows look right.</AlertTitle><AlertDescription>Check the preview, then import.</AlertDescription></Alert>
|
|
400
|
+
)}
|
|
401
|
+
</div>
|
|
402
|
+
<FieldMapping targets={targets} sources={parsed.sources} value={map} onChange={(m) => { setMap(m); setErrors({}) }} extra={extra} onExtraChange={setExtra} errors={errors} />
|
|
403
|
+
<section aria-labelledby="csv-preview" className="grid gap-2">
|
|
404
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
405
|
+
<h3 id="csv-preview" className="flex-1 text-sm font-medium text-ink-2">Preview · {onlyProblems ? `${n(Math.min(20, problemRows))} of ${n(problemRows)} rows with problems` : `first ${n(previewRows.length)} of ${n(total)} rows`}</h3>
|
|
406
|
+
{problemRows > 0 && <label className="flex items-center gap-2 text-sm text-ink"><Switch checked={onlyProblems} onCheckedChange={setOnlyProblems} />Only rows with problems</label>}
|
|
407
|
+
</div>
|
|
408
|
+
<div className="overflow-x-auto rounded-sm border border-line">
|
|
409
|
+
<table className="w-max min-w-full border-separate border-spacing-0 text-base">
|
|
410
|
+
<thead>
|
|
411
|
+
<tr>
|
|
412
|
+
<th scope="col" className="sticky left-0 border-b border-line bg-surface-2 px-2 py-2 text-right text-sm font-medium text-ink-3">Row</th>
|
|
413
|
+
{mappedCols.map((c) => <th key={c.key} scope="col" className="border-b border-line bg-surface-2 px-3 py-2 text-left text-sm font-medium whitespace-nowrap text-ink-2">{c.label}</th>)}
|
|
414
|
+
</tr>
|
|
415
|
+
</thead>
|
|
416
|
+
<tbody>
|
|
417
|
+
{previewRows.map((r) => (
|
|
418
|
+
<tr key={r}>
|
|
419
|
+
<th scope="row" className="sticky left-0 border-b border-line bg-surface-2 px-2 text-right text-sm font-normal text-ink-3 tabular">{r + (header ? 2 : 1)}</th>
|
|
420
|
+
{mappedCols.map((c) => {
|
|
421
|
+
const p = problems.byRow.get(r)?.get(c.sid)
|
|
422
|
+
const k = `${r}:${c.sid}`
|
|
423
|
+
return (
|
|
424
|
+
<td key={c.key} className={cn("h-9 max-w-64 border-b border-line px-3 whitespace-nowrap", p ? "bg-bad-bg" : "text-ink")}>
|
|
425
|
+
{fixing === k ? (
|
|
426
|
+
<Input
|
|
427
|
+
autoFocus
|
|
428
|
+
defaultValue={cellOf(r, c.sid)}
|
|
429
|
+
aria-label={`Fix ${c.label} in row ${r + (header ? 2 : 1)}`}
|
|
430
|
+
onKeyDown={(e) => {
|
|
431
|
+
if (e.key === "Enter") { e.preventDefault(); setEdits((x) => ({ ...x, [k]: e.currentTarget.value })); setFixing(null) }
|
|
432
|
+
if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); setFixing(null) }
|
|
433
|
+
}}
|
|
434
|
+
onBlur={(e) => { setEdits((x) => ({ ...x, [k]: e.currentTarget.value })); setFixing(null) }}
|
|
435
|
+
/>
|
|
436
|
+
) : p ? (
|
|
437
|
+
<span className="flex items-center gap-control">
|
|
438
|
+
<CircleAlert className="size-3.5 shrink-0 text-bad" aria-hidden="true" />
|
|
439
|
+
<span className="text-sm font-medium text-bad">{p}</span>
|
|
440
|
+
<span className="min-w-0 truncate text-ink-2">{cellOf(r, c.sid)}</span>
|
|
441
|
+
<Button variant="ghost" size="xs" onClick={() => setFixing(k)} aria-label={`Fix ${c.label} in row ${r + (header ? 2 : 1)}`}>Fix</Button>
|
|
442
|
+
</span>
|
|
443
|
+
) : <span className="block truncate">{cellOf(r, c.sid)}</span>}
|
|
444
|
+
</td>
|
|
445
|
+
)
|
|
446
|
+
})}
|
|
447
|
+
</tr>
|
|
448
|
+
))}
|
|
449
|
+
</tbody>
|
|
450
|
+
</table>
|
|
451
|
+
</div>
|
|
452
|
+
</section>
|
|
453
|
+
</div>
|
|
454
|
+
)}
|
|
455
|
+
</div>
|
|
456
|
+
|
|
457
|
+
<DialogFooter>
|
|
458
|
+
{step === 2 && <Button variant="outline" className="sm:mr-auto" onClick={() => setStep(1)}>Back</Button>}
|
|
459
|
+
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
|
460
|
+
{step === 1 ? <Button onClick={next}>Continue</Button> : <Button onClick={submit}>Import {n(importing)} {importing === 1 ? "row" : "rows"}</Button>}
|
|
461
|
+
</DialogFooter>
|
|
462
|
+
</DialogContent>
|
|
463
|
+
</Dialog>
|
|
464
|
+
)
|
|
465
|
+
}
|