diff --git a/src-tauri/src/preview.rs b/src-tauri/src/preview.rs index acddb1ad..1c6466a7 100644 --- a/src-tauri/src/preview.rs +++ b/src-tauri/src/preview.rs @@ -55,9 +55,7 @@ fn http_host_allowed(host: &str) -> bool { } match host.parse::() { Ok(ip) => match ip { - std::net::IpAddr::V4(v4) => { - v4.is_loopback() || v4.is_private() || v4.is_link_local() - } + std::net::IpAddr::V4(v4) => v4.is_loopback() || v4.is_private() || v4.is_link_local(), std::net::IpAddr::V6(v6) => v6.is_loopback(), }, Err(_) => false, @@ -95,7 +93,9 @@ fn build_url(host: &str, path: &str) -> Result { "http" => { let h = url.host_str().unwrap_or_default(); if !http_host_allowed(h) { - return Err(format!("plain http is only allowed for local hosts, not {h}")); + return Err(format!( + "plain http is only allowed for local hosts, not {h}" + )); } } s => return Err(format!("unsupported scheme: {s}")), @@ -162,7 +162,9 @@ pub async fn fetch_labelary_preview( return Ok(PreviewFetchResult::Network); } if !status.is_success() { - return Ok(PreviewFetchResult::Api { status: status.as_u16() }); + return Ok(PreviewFetchResult::Api { + status: status.as_u16(), + }); } if res.content_length().is_some_and(|l| l > MAX_BYTES as u64) { return Ok(PreviewFetchResult::TooLarge); @@ -232,12 +234,20 @@ mod tests { #[test] fn accepts_the_labelary_print_path() { - assert!(build_url("https://api.labelary.com", "/v1/printers/8dpmm/labels/3.937x1.969/0/").is_ok()); + assert!(build_url( + "https://api.labelary.com", + "/v1/printers/8dpmm/labels/3.937x1.969/0/" + ) + .is_ok()); } #[test] fn rejects_foreign_paths_and_schemes() { - assert!(build_url("https://api.labelary.com", "/v1/printers/8dpmm/labels/1x1/0/../../steal").is_err()); + assert!(build_url( + "https://api.labelary.com", + "/v1/printers/8dpmm/labels/1x1/0/../../steal" + ) + .is_err()); assert!(build_url("https://api.labelary.com", "/anything").is_err()); assert!(build_url("ftp://api.labelary.com", "/v1/printers/8dpmm/labels/1x1/0/").is_err()); } @@ -253,7 +263,10 @@ mod tests { #[test] fn normalize_host_trims_slash_space_and_case() { - assert_eq!(normalize_host(" https://API.Labelary.com/ "), "https://api.labelary.com"); + assert_eq!( + normalize_host(" https://API.Labelary.com/ "), + "https://api.labelary.com" + ); } #[test] @@ -262,8 +275,16 @@ mod tests { assert!(host_allowed("http://192.168.1.5", "192.168.1.5", false)); assert!(host_allowed("http://localhost:9090", "localhost", false)); // Custom remote host only with a bound key; bare relay is refused. - assert!(!host_allowed("https://attacker.example", "attacker.example", false)); - assert!(host_allowed("https://custom.example.com", "custom.example.com", true)); + assert!(!host_allowed( + "https://attacker.example", + "attacker.example", + false + )); + assert!(host_allowed( + "https://custom.example.com", + "custom.example.com", + true + )); } #[test] @@ -271,7 +292,11 @@ mod tests { assert!(build_url("http://127.0.0.1:8080", "/v1/printers/8dpmm/labels/1x1/0/").is_ok()); assert!(build_url("http://192.168.1.20", "/v1/printers/8dpmm/labels/1x1/0/").is_ok()); assert!(build_url("http://localhost:9090", "/v1/printers/8dpmm/labels/1x1/0/").is_ok()); - assert!(build_url("http://api.labelary.com", "/v1/printers/8dpmm/labels/1x1/0/").is_err()); + assert!(build_url( + "http://api.labelary.com", + "/v1/printers/8dpmm/labels/1x1/0/" + ) + .is_err()); assert!(build_url("http://8.8.8.8", "/v1/printers/8dpmm/labels/1x1/0/").is_err()); } } diff --git a/src/components/Variables/MappingEditor.test.tsx b/src/components/Variables/MappingEditor.test.tsx new file mode 100644 index 00000000..d967bd94 --- /dev/null +++ b/src/components/Variables/MappingEditor.test.tsx @@ -0,0 +1,74 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach } from 'vitest'; +import { render, cleanup, act, fireEvent } from '@testing-library/react'; +import { VariableMappingModal } from './VariableMappingModal'; +import { useLabelStore } from '../../store/labelStore'; +import { fallbackTranslations as en } from '../../locales'; + +afterEach(cleanup); + +// A db dataset binds directly against fetched headers/rows (no raw-text cache), +// so it exercises the extracted mapping core without the CSV decode path. +const DB_SOURCE = { + kind: 'db' as const, + profileId: 'p1', + profileName: 'Local', + table: 't', + fetchedAt: '2026-01-01T00:00:00Z', + truncated: false, + rowCount: 2, +}; + +function seed() { + act(() => { + useLabelStore.setState({ + variables: [{ id: 'v1', name: 'sku', fnNumber: 1, defaultValue: '' }], + columnMapping: null, + dataset: { + headers: ['sku', 'price'], + rows: [ + ['A1', '9.99'], + ['B2', '4.50'], + ], + source: DB_SOURCE, + activeRowIndex: 0, + }, + } as never); + }); +} + +describe('MappingEditor via VariableMappingModal', () => { + it('renders the draft table and auto-suggests the matching column', () => { + seed(); + const { container, getByText } = render( + undefined} onImportCsv={() => undefined} />, + ); + // The variable row shows its name and the sample from the active row. + expect(container.querySelector('input[value="sku"]')).not.toBeNull(); + getByText('A1'); + }); + + it('applies the draft mapping to the store on confirm', () => { + seed(); + const { getByText } = render( + undefined} onImportCsv={() => undefined} />, + ); + act(() => { + fireEvent.click(getByText(en.variables.csvApply)); + }); + // The name-matched column auto-suggests, so confirm commits sku -> "sku". + const mapping = useLabelStore.getState().columnMapping; + expect(mapping?.bindings.v1).toBe('sku'); + expect(mapping?.headerSnapshot).toEqual(['sku', 'price']); + }); + + it('shows the import-first shell when there is no dataset', () => { + act(() => { + useLabelStore.setState({ variables: [], columnMapping: null, dataset: null } as never); + }); + const { getByText } = render( + undefined} onImportCsv={() => undefined} />, + ); + getByText(en.variables.csvNoCsvLoaded); + }); +}); diff --git a/src/components/Variables/MappingEditor.tsx b/src/components/Variables/MappingEditor.tsx new file mode 100644 index 00000000..3783eb5f --- /dev/null +++ b/src/components/Variables/MappingEditor.tsx @@ -0,0 +1,194 @@ +import { PlusIcon } from '@heroicons/react/16/solid'; +import { useT } from '../../hooks/useT'; +import { CollapsibleSection } from '../ui/CollapsibleSection'; +import { inputCls } from '../Properties/styles'; +import { Select } from '../ui/Select'; +import { Tooltip } from '../ui/Tooltip'; +import { MappingRow } from './MappingRow'; +import type { DraftOptions, MappingDraft } from './useMappingDraft'; + +/** Chrome (the modal, later the wizard step) wraps this and drives its footer + * from `draft.canApply` / `draft.apply`. */ +export function MappingEditor({ draft }: { draft: MappingDraft }) { + const tv = useT().variables; + const { + csvSource, + draftVariables, + draftOptions, + setDraftOptions, + draftRow, + setDraftRow, + virtualRows, + allSlotsTaken, + showMismatchWarning, + parseError, + addVariable, + } = draft; + + return ( +
+

{tv.csvMappingHint}

+ + {showMismatchWarning && ( +

+ {tv.csvHeaderMismatchWarning} +

+ )} + + {parseError &&

{tv.csvParseError}

} + +
+
+ + + + + + + + + + {draftVariables.length === 0 ? ( + + + + ) : ( + draftVariables.map((v) => ) + )} + +
{tv.csvVariableHeader}{tv.csvColumnHeader}{tv.csvSampleHeader}
+ {tv.csvNoVariables} +
+
+
+ +
+
+ + {virtualRows.length > 0 && ( + +
+ + { + const n = parseInt(e.target.value, 10); + if (!Number.isNaN(n)) { + setDraftRow(Math.max(0, Math.min(n - 1, virtualRows.length - 1))); + } + }} + /> + + {tv.csvActiveRowOf} {virtualRows.length} + +
+
+ )} + + {csvSource && ( + + + + )} +
+ ); +} + +function CsvOptionsEditor({ + value, + onChange, +}: { + value: DraftOptions; + onChange: (next: DraftOptions) => void; +}) { + const tv = useT().variables; + return ( +
+
+ + + value={value.delimiter} + onChange={(delimiter) => onChange({ ...value, delimiter })} + groups={[ + { + options: [ + { value: '', label: tv.csvDelimiterAuto }, + { value: ',', label: tv.csvDelimiterComma }, + { value: ';', label: tv.csvDelimiterSemicolon }, + { value: '\t', label: tv.csvDelimiterTab }, + ], + }, + ]} + /> +
+ + + +
+ + { + const n = parseInt(e.target.value, 10); + onChange({ ...value, skipRows: Math.max(0, Number.isNaN(n) ? 0 : n) }); + }} + /> +
+ +
+ + + value={value.encoding} + onChange={(encoding) => onChange({ ...value, encoding })} + groups={[ + { + options: [ + { value: 'utf-8', label: tv.csvEncodingUtf8 }, + { value: 'windows-1252', label: tv.csvEncodingWin1252 }, + { value: 'iso-8859-1', label: tv.csvEncodingIso88591 }, + { value: 'utf-16le', label: tv.csvEncodingUtf16le }, + ], + }, + ]} + /> +
+
+ ); +} diff --git a/src/components/Variables/MappingRow.tsx b/src/components/Variables/MappingRow.tsx new file mode 100644 index 00000000..c2c9d81c --- /dev/null +++ b/src/components/Variables/MappingRow.tsx @@ -0,0 +1,121 @@ +import { XMarkIcon } from '@heroicons/react/16/solid'; +import type { Variable } from '@zplab/core/types/Variable'; +import { getVariableSource } from '@zplab/core/lib/variableBinding'; +import { useT } from '../../hooks/useT'; +import { inputCls } from '../Properties/styles'; +import { Select } from '../ui/Select'; +import { Tooltip } from '../ui/Tooltip'; +import { VariableSourceBadge } from './VariableSourceBadge'; +import type { MappingDraft } from './useMappingDraft'; + +export function MappingRow({ draft, variable }: { draft: MappingDraft; variable: Variable }) { + const tv = useT().variables; + const { + draftBindings, + virtualHeaders, + nameErrors, + initialVariableIds, + duplicateHeaders, + setDraftVariableName, + changeBinding, + removeDraftVariable, + } = draft; + + const nameError = nameErrors[variable.id]; + const isNew = !initialVariableIds.has(variable.id); + const boundHeader = draftBindings[variable.id]; + const isDuplicate = boundHeader !== undefined && duplicateHeaders.has(boundHeader); + // Classify against the draft (not the committed store state) so the badge + // reflects live binding edits before Apply. + const draftSource = getVariableSource( + variable, + { headers: virtualHeaders }, + { bindings: draftBindings, headerSnapshot: virtualHeaders }, + ); + + return ( + + +
+ + setDraftVariableName(variable.id, e.target.value)} + /> + {isNew && ( + + + + )} +
+ {nameError ? ( +

{nameError}

+ ) : isNew ? ( +

{tv.csvWillBeCreated}

+ ) : null} + + +
+ + value={boundHeader ?? ''} + onChange={(value) => changeBinding(variable.id, value)} + groups={[ + { + options: [ + { value: '', label: tv.csvIgnoreOption }, + ...virtualHeaders.map((h) => ({ value: h, label: h })), + ], + }, + ]} + /> +
+ {isDuplicate && ( +

{tv.csvDuplicateColumn}

+ )} + + + + + + ); +} + +function SampleCell({ + draft, + variable, + boundHeader, +}: { + draft: MappingDraft; + variable: Variable; + boundHeader: string | undefined; +}) { + const tv = useT().variables; + const { virtualHeaders, virtualRows, draftRow } = draft; + + if (boundHeader !== undefined) { + const colIdx = virtualHeaders.indexOf(boundHeader); + const cell = colIdx >= 0 ? virtualRows[draftRow]?.[colIdx] ?? '' : ''; + return cell === '' ? ( + {tv.csvSampleEmpty} + ) : ( + + {cell} + + ); + } + return ( + + {variable.defaultValue || tv.csvSamplePlaceholder} + + ); +} diff --git a/src/components/Variables/VariableMappingModal.tsx b/src/components/Variables/VariableMappingModal.tsx index 6cf41918..91bdcd3b 100644 --- a/src/components/Variables/VariableMappingModal.tsx +++ b/src/components/Variables/VariableMappingModal.tsx @@ -1,217 +1,24 @@ -import { useEffect, useMemo, useState } from 'react'; -import { PlusIcon, TableCellsIcon, XMarkIcon } from '@heroicons/react/16/solid'; -import { useLabelStore } from '../../store/labelStore'; +import { TableCellsIcon, XMarkIcon } from '@heroicons/react/16/solid'; import { useT } from '../../hooks/useT'; -import { - nextDefaultVariableName, - nextFreeFnNumber, - suggestColumnMapping, - isValidVariableName, - isMappingCompatibleWith, - dbExcelParseOptions, - type ColumnMapping, - type CsvParseOptionsPersisted, - type Variable, -} from '@zplab/core/types/Variable'; -import type { DatasetInput } from '@zplab/core/types/DataSource'; -import { - decodeImportedText, - parseCsvText, -} from '../../lib/csvImport'; import { DialogShell } from '../ui/DialogShell'; -import { CollapsibleSection } from '../ui/CollapsibleSection'; -import { inputCls } from '../Properties/styles'; -import { Select } from '../ui/Select'; -import { getVariableSource } from '@zplab/core/lib/variableBinding'; -import { VariableSourceBadge } from './VariableSourceBadge'; -import { Tooltip } from '../ui/Tooltip'; +import { MappingEditor } from './MappingEditor'; +import { useMappingDraft } from './useMappingDraft'; - -import { newId } from "@zplab/core/lib/ids"; interface Props { onClose: () => void; /** Opens the CSV file picker (the same one as the File menu's import). */ onImportCsv: () => void; } -interface DraftOptions { - /** Stored as PapaParse delimiter string. '' means auto-detect. */ - delimiter: string; - hasHeaderRow: boolean; - skipRows: number; - /** TextDecoder label. 'utf-8' is the default; common alternatives - * cover German Excel exports (windows-1252) and legacy Latin - * files. The dropdown is curated; arbitrary TextDecoder labels - * would also work but aren't surfaced. */ - encoding: string; -} - -/** Modal for editing the Variable → CSV-column mapping and the - * associated CSV parse options. Full draft pattern: variable list, - * bindings, active row and parse options are cloned on open; Apply - * commits the whole bundle atomically; Cancel discards everything. - * Live re-parse of the cached raw text drives the table whenever - * options change, so the user sees the effect immediately. */ +/** Dialog chrome around the shared MappingEditor. Falls back to a close-only + * shell when there is no editable dataset (e.g. the raw-text cache was lost on + * a mid-session reload). */ export function VariableMappingModal({ onClose, onImportCsv }: Props) { const t = useT(); const tv = t.variables; - const variables = useLabelStore((s) => s.variables); - const columnMapping = useLabelStore((s) => s.columnMapping); - const dataset = useLabelStore((s) => s.dataset); - const applyMappingDraft = useLabelStore((s) => s.applyMappingDraft); - // A db dataset is already tabular: no raw-text cache, no re-parse, no CSV - // options; the draft binds directly against the fetched headers/rows. - const csvSource = dataset === null || dataset.source.kind === 'csv'; - const csvMeta = dataset !== null && dataset.source.kind === 'csv' ? dataset.source : null; - - // Draft state, initialised once at modal-open. The init-from-prop - // pattern is the React-blessed way to seed local state from props - // without re-running on every render. - const [draftVariables, setDraftVariables] = useState(() => [ - ...variables, - ]); - // Snapshot of variable IDs that were already in the store at - // modal-open. Used to flag "will be created" on rows that exist - // only in the draft (added inline via the + Add variable button) - // so the user understands they haven't committed yet. - const [initialVariableIds] = useState>( - () => new Set(variables.map((v) => v.id)), - ); - const [draftOptions, setDraftOptions] = useState(() => ({ - // Seed from the persisted mapping first (so a reopen reflects the - // last Apply), then fall back to the dataset's source metadata - // (the values active at import time), then to library defaults. - delimiter: - columnMapping?.parseOptions?.delimiter ?? - csvMeta?.delimiter ?? - '', - hasHeaderRow: columnMapping?.parseOptions?.hasHeaderRow ?? true, - skipRows: columnMapping?.parseOptions?.skipRows ?? 0, - encoding: - columnMapping?.parseOptions?.encoding ?? - csvMeta?.encoding ?? - 'utf-8', - })); - - // Always re-decode the cached raw bytes for the chosen encoding, including - // utf-8: reusing the import-time text would keep a prior wrong-encoding - // decode, so switching back to utf-8 couldn't rescue a mis-decoded file. - const rawText = useMemo(() => { - if (!csvSource) return null; - return decodeImportedText(draftOptions.encoding); - }, [csvSource, draftOptions.encoding]); - const [draftRow, setDraftRow] = useState( - dataset?.activeRowIndex ?? 0, - ); - const [addError, setAddError] = useState(null); - - // Live re-parse from the (possibly re-decoded) raw text whenever - // options change. Synchronous + memoised so option-tweaks feel - // instant. - const draftParse = useMemo(() => { - if (!rawText) return null; - return parseCsvText(rawText, { - delimiter: draftOptions.delimiter || undefined, - hasHeaderRow: draftOptions.hasHeaderRow, - skipRows: draftOptions.skipRows, - encoding: draftOptions.encoding, - filename: csvMeta?.filename, - }); - }, [rawText, draftOptions, csvMeta?.filename]); - - // Memoise so the useEffect deps below stay reference-stable across - // renders that didn't change the underlying parse. - const virtualHeaders = useMemo( - () => (draftParse?.ok ? draftParse.value.headers : dataset?.headers ?? []), - [draftParse, dataset?.headers], - ); - const virtualRows = useMemo( - () => (draftParse?.ok ? draftParse.value.rows : dataset?.rows ?? []), - [draftParse, dataset?.rows], - ); - - // Bindings draft. Seeded from existing mapping (only entries whose - // header still exists in the virtual parse), then auto-suggest fills - // the rest. Re-derived when virtualHeaders change so newly-vanished - // headers drop out and newly-appeared ones can be auto-suggested. - const [draftBindings, setDraftBindings] = useState>( - () => buildInitialBindings(columnMapping, draftVariables, virtualHeaders), - ); - // Variables the user explicitly set to (unmapped): auto-suggest must not - // re-attach a column they just deliberately removed. - const [explicitlyUnmapped, setExplicitlyUnmapped] = useState>( - () => new Set(), - ); - useEffect(() => { - setDraftBindings((prev) => { - const headerSet = new Set(virtualHeaders); - const filtered: Record = {}; - let changed = false; - for (const [varId, header] of Object.entries(prev)) { - if (headerSet.has(header)) filtered[varId] = header; - else changed = true; - } - // Inline-added drafts and explicitly-unmapped rows are excluded from - // auto-suggest, so a freshly added row's default name can't silently - // attach to a fuzzy-matching header. - const unboundVars = draftVariables.filter( - (v) => initialVariableIds.has(v.id) && !(v.id in filtered) && !explicitlyUnmapped.has(v.id), - ); - const usedHeaders = new Set(Object.values(filtered)); - const freeHeaders = virtualHeaders.filter((h) => !usedHeaders.has(h)); - const suggested = suggestColumnMapping(unboundVars, freeHeaders); - const merged = { ...filtered, ...suggested }; - if (!changed && Object.keys(suggested).length === 0) return prev; - return merged; - }); - }, [virtualHeaders, draftVariables, initialVariableIds, explicitlyUnmapped]); - - // Clamp active-row to virtual rows length (option-change may have - // shrunk the dataset). - useEffect(() => { - if (virtualRows.length === 0) return; - setDraftRow((r) => Math.min(r, virtualRows.length - 1)); - }, [virtualRows.length]); - - // Headers that are bound by more than one variable. Almost always a - // mistake (the same column would feed two slots and produce confusing - // labels); flagged inline so the user notices before Apply. - const duplicateHeaders = useMemo(() => { - const counts = new Map(); - for (const h of Object.values(draftBindings)) { - counts.set(h, (counts.get(h) ?? 0) + 1); - } - const dups = new Set(); - for (const [h, n] of counts) if (n > 1) dups.add(h); - return dups; - }, [draftBindings]); - - // Compute name validity per row. Empty-name is always invalid; - // duplicate-name is invalid for every row sharing the same trimmed - // value. Duplicates are computed against trimmed text so trailing - // whitespace doesn't accidentally "fix" the collision. Computed - // before the defensive early-return so the hook order is stable. - const nameErrors = useMemo(() => { - const counts = new Map(); - for (const v of draftVariables) { - const t = v.name.trim(); - counts.set(t, (counts.get(t) ?? 0) + 1); - } - const errors: Record = {}; - for (const v of draftVariables) { - const t = v.name.trim(); - if (t === '') errors[v.id] = tv.csvNameEmpty; - else if ((counts.get(t) ?? 0) > 1) errors[v.id] = tv.csvNameDuplicate; - else if (!isValidVariableName(t)) errors[v.id] = tv.nameInvalid; - } - return errors; - }, [draftVariables, tv.csvNameEmpty, tv.csvNameDuplicate, tv.nameInvalid]); - const hasNameError = Object.keys(nameErrors).length > 0; + const draft = useMappingDraft(); - if (!dataset || (csvSource && !rawText)) { - // Defensive: trigger paths gate on dataset, but if the cache is - // empty (e.g. user reloaded the page mid-session) show a friendly - // close-only shell. + if (!draft.hasEditableDataset) { return ( (value: string) => { - // Track the explicit (unmapped) so the auto-suggest effect leaves it be. - setExplicitlyUnmapped((prev) => { - const next = new Set(prev); - if (value === '') next.add(variableId); - else next.delete(variableId); - return next; - }); - setDraftBindings((prev) => { - if (value === '') { - if (!(variableId in prev)) return prev; - const { [variableId]: _drop, ...next } = prev; - void _drop; - return next; - } - return { ...prev, [variableId]: value }; - }); - }; - - const handleRemoveDraftVariable = (id: string) => { - setDraftVariables((prev) => prev.filter((v) => v.id !== id)); - setDraftBindings((prev) => { - if (!(id in prev)) return prev; - const { [id]: _drop, ...rest } = prev; - void _drop; - return rest; - }); - }; - - const handleAddVariable = () => { - // Eligibility check first against the current snapshot so the - // error message doesn't depend on closure mutation from inside - // setDraftVariables (StrictMode runs updaters twice, concurrent - // rendering may defer them). The updater itself re-checks against - // prev so chained adds compute slot/name from the up-to-date - // list and don't collide. Residual edge case: two clicks in one - // batch both pass the outer check, but only the first commits a - // new row; no error surfaces for the swallowed second. - if (nextFreeFnNumber(draftVariables.map((v) => v.fnNumber)) === null) { - setAddError(tv.noSlotsLeft); - return; - } - setDraftVariables((prev) => { - const fn = nextFreeFnNumber(prev.map((v) => v.fnNumber)); - if (fn === null) return prev; - const newVar: Variable = { - id: newId(), - name: nextDefaultVariableName(prev), - fnNumber: fn, - defaultValue: '', - }; - return [...prev, newVar]; - }); - setAddError(null); - }; - - const handleConfirm = () => { - // CSV commits the freshly-parsed rows; db/excel commit the already-loaded - // dataset. dbExcelParseOptions keeps the carried options safe for re-import. - let ds: DatasetInput; - let parseOptions: CsvParseOptionsPersisted | undefined; - if (csvSource) { - if (!draftParse?.ok) return; - ds = draftParse.value; - parseOptions = persistableParseOptions(draftOptions); - } else { - ds = dataset; - parseOptions = dbExcelParseOptions(columnMapping?.parseOptions); - } - applyMappingDraft({ - variables: draftVariables, - dataset: ds, - mapping: { bindings: draftBindings, headerSnapshot: ds.headers, parseOptions }, - activeRowIndex: draftRow, - }); + const confirm = () => { + draft.apply(); onClose(); }; - // Warn only when the mapping actually stops fitting, not on a pure column - // reorder (name-based mappings are order-independent, per isMappingCompatibleWith). - const showMismatchWarning = - columnMapping !== null && !isMappingCompatibleWith(columnMapping, virtualHeaders); - - const allSlotsTaken = - nextFreeFnNumber(draftVariables.map((v) => v.fnNumber)) === null; - - const parseError = draftParse && !draftParse.ok; - return ( -
-

- {tv.csvMappingHint} -

- - {showMismatchWarning && ( -

- {tv.csvHeaderMismatchWarning} -

- )} - - {parseError && ( -

- {tv.csvParseError} -

- )} - -
-
- - - - - - - - - - {draftVariables.length === 0 ? ( - - - - ) : ( - draftVariables.map((v) => { - const nameError = nameErrors[v.id]; - const isNew = !initialVariableIds.has(v.id); - const boundHeader = draftBindings[v.id]; - const isDuplicate = - boundHeader !== undefined && duplicateHeaders.has(boundHeader); - // Classify against the draft (not the committed store - // state) so the badge reflects live binding edits before - // Apply. Both inputs synthesised here have the minimal - // shape getVariableSource needs. - const draftSource = getVariableSource( - v, - { headers: virtualHeaders }, - { bindings: draftBindings, headerSnapshot: virtualHeaders as string[] }, - ); - return ( - - - - - - ); - }) - )} - -
{tv.csvVariableHeader}{tv.csvColumnHeader}{tv.csvSampleHeader}
- {tv.csvNoVariables} -
-
- - { - const newName = e.target.value; - setDraftVariables((prev) => - prev.map((x) => (x.id === v.id ? { ...x, name: newName } : x)), - ); - }} - /> - {isNew && ( - - - - )} -
- {nameError ? ( -

- {nameError} -

- ) : isNew ? ( -

- {tv.csvWillBeCreated} -

- ) : null} -
-
- - value={boundHeader ?? ''} - onChange={handleChangeBinding(v.id)} - groups={[ - { - options: [ - { value: '', label: tv.csvIgnoreOption }, - ...virtualHeaders.map((h) => ({ value: h, label: h })), - ], - }, - ]} - /> -
- {isDuplicate && ( -

- {tv.csvDuplicateColumn} -

- )} -
- {(() => { - // Sample value for the active preview row. When - // bound + header present in current parse → - // cell from virtualRows[draftRow]. Otherwise - // show the variable's default (or empty marker) - // so the user always knows what would print. - if (boundHeader !== undefined) { - const colIdx = virtualHeaders.indexOf(boundHeader); - const cell = - colIdx >= 0 - ? virtualRows[draftRow]?.[colIdx] ?? '' - : ''; - return cell === '' ? ( - - {tv.csvSampleEmpty} - - ) : ( - - {cell} - - ); - } - return ( - - {v.defaultValue || tv.csvSamplePlaceholder} - - ); - })()} -
-
-
- - {addError && ( -

{addError}

- )} -
-
- - {virtualRows.length > 0 && ( - -
- - { - const n = parseInt(e.target.value, 10); - if (!Number.isNaN(n)) { - setDraftRow(Math.max(0, Math.min(n - 1, virtualRows.length - 1))); - } - }} - /> - - {tv.csvActiveRowOf} {virtualRows.length} - -
-
- )} - - {csvSource && ( - - - - )} +
+
@@ -570,8 +86,8 @@ export function VariableMappingModal({ onClose, onImportCsv }: Props) { {tv.cancel}