From ebd88a2df889e4a0dcfc6c1bc9397103bd9275fd Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Tue, 14 Apr 2026 21:31:44 -0400 Subject: [PATCH] Source setup UX, Pivot page, and import/view fixes - Fix stale import_records in sources.sql that referenced deleted generate_constraint_key - Auto-transform after import, auto-generate view after create - New source form matches existing source layout (In view, Seq, type dropdown) - Sample data table (50 rows) shown below field config in both new and existing source views - Import sample CSV on create (checked by default) - Sortable column headers on field table - Choose CSV styled as a button showing filename - + button in sidebar opens new source form - Records tab shows error message when view cast fails instead of blank - Pivot page with Perspective viewer, per-source saved layouts Co-Authored-By: Claude Sonnet 4.6 --- api/routes/sources.js | 11 +- database/queries/sources.sql | 139 --------------------- ui/src/App.jsx | 5 +- ui/src/pages/Mappings.jsx | 169 ++++++++++++++++++++++++- ui/src/pages/Pivot.jsx | 155 +++++++++++++++++++++++ ui/src/pages/Records.jsx | 8 +- ui/src/pages/Sources.jsx | 234 ++++++++++++++++++++++++++++------- 7 files changed, 528 insertions(+), 193 deletions(-) create mode 100644 ui/src/pages/Pivot.jsx diff --git a/api/routes/sources.js b/api/routes/sources.js index 690ffda..587c56e 100644 --- a/api/routes/sources.js +++ b/api/routes/sources.js @@ -52,19 +52,22 @@ module.exports = (pool) => { const records = parse(req.file.buffer, { columns: true, skip_empty_lines: true, trim: true }); if (records.length === 0) return res.status(400).json({ error: 'CSV file is empty' }); + const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(T[\d:.Z+-]+)?$/; const sample = records[0]; + const sampleRows = records.slice(0, 50); + const fields = Object.keys(sample).map(key => { - const val = sample[key]; + const vals = sampleRows.map(r => r[key]).filter(v => v !== '' && v != null); let type = 'text'; - if (!isNaN(parseFloat(val)) && isFinite(val) && val.charAt(0) !== '0') { + if (vals.length > 0 && vals.every(v => !isNaN(parseFloat(v)) && isFinite(v) && String(v).charAt(0) !== '0')) { type = 'numeric'; - } else if (Date.parse(val) > Date.parse('1950-01-01') && Date.parse(val) < Date.parse('2050-01-01')) { + } else if (vals.length > 0 && vals.every(v => ISO_DATE_RE.test(String(v)))) { type = 'date'; } return { name: key, type }; }); - res.json({ name: '', constraint_fields: [], fields }); + res.json({ name: '', constraint_fields: [], fields, sampleRows }); } catch (err) { next(err); } diff --git a/database/queries/sources.sql b/database/queries/sources.sql index 9b6f354..f357a58 100644 --- a/database/queries/sources.sql +++ b/database/queries/sources.sql @@ -144,145 +144,6 @@ BEGIN END; $$ LANGUAGE plpgsql STABLE; --- ── Import (uniqueness constraint) ──────────────────────────────────────────── - -CREATE OR REPLACE FUNCTION import_records(p_source_name TEXT, p_data JSONB) -RETURNS JSON AS $$ -DECLARE - v_constraint_fields TEXT[]; - v_record JSONB; - v_constraint_key TEXT; - v_inserted INTEGER := 0; - v_duplicates INTEGER := 0; - v_log_id INTEGER; -BEGIN - SELECT constraint_fields INTO v_constraint_fields - FROM dataflow.sources WHERE name = p_source_name; - - IF v_constraint_fields IS NULL THEN - RETURN json_build_object('success', false, 'error', 'Source not found: ' || p_source_name); - END IF; - - FOR v_record IN SELECT * FROM jsonb_array_elements(p_data) LOOP - v_constraint_key := dataflow.generate_constraint_key(v_record, v_constraint_fields); - BEGIN - INSERT INTO dataflow.records (source_name, data, constraint_key) - VALUES (p_source_name, v_record, v_constraint_key); - v_inserted := v_inserted + 1; - EXCEPTION WHEN unique_violation THEN - v_duplicates := v_duplicates + 1; - END; - END LOOP; - - INSERT INTO dataflow.import_log (source_name, records_imported, records_duplicate) - VALUES (p_source_name, v_inserted, v_duplicates) - RETURNING id INTO v_log_id; - - RETURN json_build_object('success', true, 'imported', v_inserted, 'duplicates', v_duplicates, 'log_id', v_log_id); -END; -$$ LANGUAGE plpgsql; - --- ── Transformations ─────────────────────────────────────────────────────────── - -CREATE OR REPLACE FUNCTION dataflow.jsonb_merge(a JSONB, b JSONB) -RETURNS JSONB AS $$ - SELECT COALESCE(a, '{}') || COALESCE(b, '{}') -$$ LANGUAGE sql IMMUTABLE; - -DROP AGGREGATE IF EXISTS dataflow.jsonb_concat_obj(JSONB); -CREATE AGGREGATE dataflow.jsonb_concat_obj(JSONB) ( - sfunc = dataflow.jsonb_merge, - stype = JSONB, - initcond = '{}' -); - -DROP FUNCTION IF EXISTS apply_transformations(TEXT, INTEGER[]); -CREATE OR REPLACE FUNCTION apply_transformations( - p_source_name TEXT, - p_record_ids INTEGER[] DEFAULT NULL, - p_overwrite BOOLEAN DEFAULT FALSE -) RETURNS JSON AS $$ -WITH -qualifying AS ( - SELECT id, data FROM dataflow.records - WHERE source_name = p_source_name - AND (p_overwrite OR transformed IS NULL) - AND (p_record_ids IS NULL OR id = ANY(p_record_ids)) -), -rx AS ( - SELECT - q.id, - r.name AS rule_name, - r.sequence, - r.output_field, - r.retain, - r.function_type, - COALESCE(mt.rn, rp.rn, 1) AS result_number, - CASE WHEN array_length(mt.mt, 1) = 1 THEN to_jsonb(mt.mt[1]) ELSE to_jsonb(mt.mt) END AS match_val, - to_jsonb(rp.rp) AS replace_val - FROM dataflow.rules r - INNER JOIN qualifying q ON q.data ? r.field - LEFT JOIN LATERAL regexp_matches(q.data ->> r.field, r.pattern, r.flags) - WITH ORDINALITY AS mt(mt, rn) ON r.function_type = 'extract' - LEFT JOIN LATERAL regexp_replace(q.data ->> r.field, r.pattern, r.replace_value, r.flags) - WITH ORDINALITY AS rp(rp, rn) ON r.function_type = 'replace' - WHERE r.source_name = p_source_name AND r.enabled = true -), -agg_matches AS ( - SELECT - id, rule_name, sequence, output_field, retain, function_type, - CASE function_type - WHEN 'replace' THEN jsonb_agg(replace_val) -> 0 - ELSE - CASE WHEN max(result_number) = 1 - THEN jsonb_agg(match_val ORDER BY result_number) -> 0 - ELSE jsonb_agg(match_val ORDER BY result_number) - END - END AS extracted - FROM rx - GROUP BY id, rule_name, sequence, output_field, retain, function_type -), -linked AS ( - SELECT - a.id, a.sequence, a.output_field, a.retain, a.extracted, m.output AS mapped - FROM agg_matches a - LEFT JOIN dataflow.mappings m ON - m.source_name = p_source_name - AND m.rule_name = a.rule_name - AND m.input_value = a.extracted - WHERE a.extracted IS NOT NULL -), -rule_output AS ( - SELECT - id, sequence, - CASE - WHEN mapped IS NOT NULL THEN - mapped || CASE WHEN retain THEN jsonb_build_object(output_field, extracted) ELSE '{}'::jsonb END - ELSE jsonb_build_object(output_field, extracted) - END AS output - FROM linked -), -record_additions AS ( - SELECT id, dataflow.jsonb_concat_obj(output ORDER BY sequence) AS additions - FROM rule_output GROUP BY id -), -updated AS ( - UPDATE dataflow.records rec - SET transformed = rec.data || COALESCE(ra.additions, '{}'::jsonb), - transformed_at = CURRENT_TIMESTAMP - FROM qualifying q - LEFT JOIN record_additions ra ON ra.id = q.id - WHERE rec.id = q.id - RETURNING rec.id -) -SELECT json_build_object('success', true, 'transformed', count(*)) FROM updated -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION reprocess_records(p_source_name TEXT) -RETURNS JSON AS $$ - SELECT dataflow.apply_transformations(p_source_name, NULL, TRUE) -$$ LANGUAGE sql; - -- ── View generation ─────────────────────────────────────────────────────────── CREATE OR REPLACE FUNCTION generate_source_view(p_source_name TEXT) diff --git a/ui/src/App.jsx b/ui/src/App.jsx index 6ca7bd4..716572c 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -8,6 +8,7 @@ import Rules from './pages/Rules' import Mappings from './pages/Mappings' import Records from './pages/Records' import Log from './pages/Log' +import Pivot from './pages/Pivot' const NAV = [ { to: '/sources', label: 'Sources' }, @@ -15,6 +16,7 @@ const NAV = [ { to: '/rules', label: 'Rules' }, { to: '/mappings', label: 'Mappings' }, { to: '/records', label: 'Records' }, + { to: '/pivot', label: 'Pivot' }, { to: '/log', label: 'Log' }, ] @@ -77,7 +79,7 @@ export default function App() {
- setSidebarOpen(false)}>+ + setSidebarOpen(false)}>+
setRowFilter(e.target.value)} + /> + {rowFilter && !rowFilterError && ( + + {filteredRows.length} + + )} +
+ )} + {dirtyCount > 0 && ( + + + )} + {existingCols.map(col => ( @@ -402,9 +523,29 @@ export default function Mappings({ source }) { {displayRows.map(row => { const k = valueKey(row.extracted_value) + const rowIdx = displayRows.indexOf(row) const isSaving = saving[k] + const isSelected = selected.has(k) const hasDraft = !!(drafts[k] && Object.keys(drafts[k]).length > 0) - const rowBg = hasDraft ? 'bg-blue-50' : row.is_mapped ? '' : 'bg-yellow-50' + const rowBg = isSelected ? 'bg-blue-50' : hasDraft ? 'bg-blue-50' : row.is_mapped ? '' : 'bg-yellow-50' + + function handleRowClick(e) { + if (e.target.closest('input,button,a,select')) return + setSelected(s => { const n = new Set(s); n.has(k) ? n.delete(k) : n.add(k); return n }) + setCursorKey(k) + } + + function handleRowKeyDown(e) { + if (!e.shiftKey || (e.key !== 'ArrowDown' && e.key !== 'ArrowUp')) return + e.preventDefault() + const delta = e.key === 'ArrowDown' ? 1 : -1 + const curIdx = cursorKey ? displayRows.findIndex(r => valueKey(r.extracted_value) === cursorKey) : rowIdx + const nextIdx = Math.max(0, Math.min(displayRows.length - 1, curIdx + delta)) + const nextKey = valueKey(displayRows[nextIdx].extracted_value) + setSelected(s => new Set([...s, nextKey])) + setCursorKey(nextKey) + rowRefs.current[nextKey]?.focus() + } const samples = row.sample ? (Array.isArray(row.sample) ? row.sample : [row.sample]) : [] @@ -417,7 +558,25 @@ export default function Mappings({ source }) { return ( <> - + rowRefs.current[k] = el} + tabIndex={0} + className={`border-t border-gray-50 hover:bg-gray-50 cursor-pointer outline-none ${rowBg}`} + onClick={handleRowClick} + onKeyDown={handleRowKeyDown} + > + {cols.map(col => ( @@ -467,7 +626,7 @@ export default function Mappings({ source }) { const sampleCols = [...new Set(samples.flatMap(r => Object.keys(r)))] return ( -
+ 0 && displayRows.every(r => selected.has(valueKey(r.extracted_value)))} + onChange={e => { + if (e.target.checked) setSelected(new Set(displayRows.map(r => valueKey(r.extracted_value)))) + else setSelected(new Set()) + }} + /> +
+ { + setSelected(s => { const n = new Set(s); n.has(k) ? n.delete(k) : n.add(k); return n }) + setCursorKey(k) + }} + /> + {displayValue(row.extracted_value)} {row.record_count}
+ diff --git a/ui/src/pages/Pivot.jsx b/ui/src/pages/Pivot.jsx new file mode 100644 index 0000000..02fe9f2 --- /dev/null +++ b/ui/src/pages/Pivot.jsx @@ -0,0 +1,155 @@ +import { useEffect, useRef, useState } from 'react' +import { api } from '../api' + +// Fetch all rows for a source (no pagination limit) +async function fetchAllRows(source) { + const res = await api.getViewData(source, 100000, 0) + return res.rows || [] +} + +let perspectiveLoaded = false +let perspectivePromise = null + +function loadPerspective() { + if (perspectivePromise) return perspectivePromise + perspectivePromise = (async () => { + // Inject theme CSS once + if (!document.getElementById('psp-theme')) { + const link = document.createElement('link') + link.id = 'psp-theme' + link.rel = 'stylesheet' + link.crossOrigin = 'anonymous' + link.href = 'https://cdn.jsdelivr.net/npm/@perspective-dev/viewer/dist/css/themes.css' + document.head.appendChild(link) + } + const [{ default: perspective }] = await Promise.all([ + import(/* @vite-ignore */ 'https://cdn.jsdelivr.net/npm/@perspective-dev/client@4.4.0/dist/cdn/perspective.js'), + import(/* @vite-ignore */ 'https://cdn.jsdelivr.net/npm/@perspective-dev/viewer@4.4.0/dist/cdn/perspective-viewer.js'), + import(/* @vite-ignore */ 'https://cdn.jsdelivr.net/npm/@perspective-dev/viewer-datagrid@4.4.0/dist/cdn/perspective-viewer-datagrid.js'), + import(/* @vite-ignore */ 'https://cdn.jsdelivr.net/npm/@perspective-dev/viewer-d3fc@4.4.0/dist/cdn/perspective-viewer-d3fc.js'), + ]) + perspectiveLoaded = true + return perspective + })() + return perspectivePromise +} + +const LAYOUT_KEY = (source) => `psp_layout_${source}` + +export default function Pivot({ source }) { + const viewerRef = useRef() + const workerRef = useRef() + const [status, setStatus] = useState('idle') + const [error, setError] = useState('') + const [saved, setSaved] = useState(false) + + useEffect(() => { + if (!source) return + let cancelled = false + setSaved(false) + + async function init() { + setStatus('loading') + setError('') + + try { + const [perspective, rows] = await Promise.all([ + loadPerspective(), + fetchAllRows(source), + ]) + if (cancelled) return + + if (!rows.length) { + setStatus('noview') + return + } + + if (workerRef.current) { + try { workerRef.current.terminate() } catch {} + } + + const worker = await perspective.worker() + if (cancelled) { worker.terminate(); return } + workerRef.current = worker + + await worker.table(rows, { name: source }) + if (cancelled) return + + const viewer = viewerRef.current + await viewer.load(worker) + + const saved = localStorage.getItem(LAYOUT_KEY(source)) + if (saved) { + await viewer.restore(JSON.parse(saved)) + } else { + await viewer.restore({ table: source, settings: true }) + } + setStatus('ready') + } catch (err) { + if (!cancelled) { setStatus('error'); setError(err.message) } + } + } + + init() + return () => { cancelled = true } + }, [source]) + + async function saveLayout() { + const viewer = viewerRef.current + if (!viewer) return + const layout = await viewer.save() + localStorage.setItem(LAYOUT_KEY(source), JSON.stringify(layout)) + setSaved(true) + setTimeout(() => setSaved(false), 2000) + } + + function clearLayout() { + localStorage.removeItem(LAYOUT_KEY(source)) + const viewer = viewerRef.current + if (viewer) viewer.restore({ table: source, settings: true }) + } + + if (!source) return
Select a source first.
+ + return ( +
+ {status === 'ready' && ( +
+ + {localStorage.getItem(LAYOUT_KEY(source)) && ( + + )} +
+ )} + {status === 'loading' && ( +
+

Loading…

+
+ )} + {status === 'error' && ( +
+

Error: {error}

+
+ )} + {status === 'noview' && ( +
+

No view data — generate a view and transform records first.

+
+ )} + +
+ ) +} diff --git a/ui/src/pages/Records.jsx b/ui/src/pages/Records.jsx index 34bcb67..8f1476e 100644 --- a/ui/src/pages/Records.jsx +++ b/ui/src/pages/Records.jsx @@ -24,6 +24,7 @@ export default function Records({ source }) { const [exists, setExists] = useState(null) const [offset, setOffset] = useState(0) const [loading, setLoading] = useState(false) + const [viewError, setViewError] = useState(null) const [sort, setSort] = useState({ col: null, dir: 'asc' }) const [filters, setFilters] = useState([]) const debounceRef = useRef(null) @@ -34,6 +35,7 @@ export default function Records({ source }) { setOffset(0) setSort({ col: null, dir: 'asc' }) setFilters([]) + setViewError(null) load(0, null, 'asc', []) }, [source]) @@ -47,7 +49,7 @@ export default function Records({ source }) { if (res.rows.length > 0 && cols.length === 0) setCols(Object.keys(res.rows[0])) else if (res.rows.length > 0) setCols(Object.keys(res.rows[0])) } catch (err) { - console.error(err) + setViewError(err.message) } finally { setLoading(false) } @@ -145,6 +147,10 @@ export default function Records({ source }) { {loading &&

Loading…

} + {!loading && viewError && ( +

View error: {viewError} — check field types in Sources.

+ )} + {!loading && exists === false && (

No view generated yet. Go to Sources, check fields as In view, then click Generate view. diff --git a/ui/src/pages/Sources.jsx b/ui/src/pages/Sources.jsx index 5a63f15..37785b6 100644 --- a/ui/src/pages/Sources.jsx +++ b/ui/src/pages/Sources.jsx @@ -1,13 +1,42 @@ import { useState, useEffect, useRef } from 'react' +import { useSearchParams } from 'react-router-dom' import { api } from '../api' const FIELD_TYPES = ['text', 'numeric', 'date'] +function SampleTable({ rows }) { + if (!rows || rows.length === 0) return null + const cols = Object.keys(rows[0]) + return ( +

+
+ + + {cols.map(c => )} + + + + {rows.map((row, i) => ( + + {cols.map(c => ( + + ))} + + ))} + +
{c}
+ {row[c] == null ? : String(row[c])} +
+ + ) +} + export default function Sources({ source, sources, setSources, setSource }) { const [constraintFields, setConstraintFields] = useState('') const [globalPicklist, setGlobalPicklist] = useState(true) const [schemaFields, setSchemaFields] = useState([]) const [stats, setStats] = useState(null) + const [sampleRows, setSampleRows] = useState([]) const [saving, setSaving] = useState(false) const [reprocessing, setReprocessing] = useState(false) const [generating, setGenerating] = useState(false) @@ -15,14 +44,24 @@ export default function Sources({ source, sources, setSources, setSource }) { const [error, setError] = useState('') const [viewName, setViewName] = useState('') const [availableFields, setAvailableFields] = useState([]) + const [fieldSort, setFieldSort] = useState({ col: 'key', dir: 'asc' }) const [creating, setCreating] = useState(false) - const [form, setForm] = useState({ name: '', constraint_fields: '', fields: [], schema: [] }) + const [form, setForm] = useState({ name: '', constraint_fields: '', fields: [], schema: [], importSample: true }) const [createError, setCreateError] = useState('') const [createLoading, setCreateLoading] = useState(false) + const [csvFileName, setCsvFileName] = useState('') const fileRef = useRef() + const [searchParams, setSearchParams] = useSearchParams() const sourceObj = sources.find(s => s.name === source) + useEffect(() => { + if (searchParams.get('new') === '1') { + setCreating(true) + setSearchParams({}) + } + }, [searchParams]) + useEffect(() => { if (!sourceObj) return setConstraintFields(sourceObj.constraint_fields?.join(', ') || '') @@ -33,8 +72,10 @@ export default function Sources({ source, sources, setSources, setSource }) { setError('') setStats(null) setAvailableFields([]) + setSampleRows([]) api.getStats(sourceObj.name).then(setStats).catch(() => {}) api.getFields(sourceObj.name).then(setAvailableFields).catch(() => {}) + api.getRecords(sourceObj.name, 50).then(rows => setSampleRows(rows.map(r => r.data).filter(Boolean))).catch(() => {}) }, [source, sourceObj?.name]) async function handleSave(e) { @@ -46,6 +87,10 @@ export default function Sources({ source, sources, setSources, setSource }) { const fields = [...schemaFields.filter(f => f.name)].sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0)) const config = { ...(sourceObj.config || {}), fields } await api.updateSource(sourceObj.name, { constraint_fields, config, global_picklist: globalPicklist }) + if (fields.length > 0) { + const res = await api.generateView(sourceObj.name) + if (res.success) setViewName(res.view) + } const updated = await api.getSources() setSources(updated) setResult('Saved.') @@ -111,13 +156,15 @@ export default function Sources({ source, sources, setSources, setSource }) { async function handleSuggest(e) { const file = e.target.files[0] if (!file) return + setCsvFileName(file.name) try { const suggestion = await api.suggestSource(file) setForm(f => ({ ...f, fields: suggestion.fields, constraint_fields: '', - schema: suggestion.fields.map(f => ({ name: f.name, type: f.type })) + schema: suggestion.fields.map(f => ({ name: f.name, type: f.type, seq: suggestion.fields.indexOf(f) + 1 })), + sampleRows: suggestion.sampleRows || [] })) } catch (err) { setCreateError(err.message) @@ -136,10 +183,16 @@ export default function Sources({ source, sources, setSources, setSource }) { try { const config = form.schema.length > 0 ? { fields: form.schema } : {} await api.createSource({ name: form.name, constraint_fields: constraintArr, config, global_picklist: form.global_picklist !== false }) + if (form.schema.length > 0) { + await api.generateView(form.name) + } + if (form.importSample && fileRef.current?.files[0]) { + await api.importCSV(form.name, fileRef.current.files[0]) + } const updated = await api.getSources() setSources(updated) setSource(form.name) - setForm({ name: '', constraint_fields: '', fields: [], schema: [], global_picklist: true }) + setForm({ name: '', constraint_fields: '', fields: [], schema: [], importSample: true }) setCreating(false) } catch (err) { setCreateError(err.message) @@ -149,7 +202,7 @@ export default function Sources({ source, sources, setSources, setSource }) { } return ( -
+

{sourceObj ? sourceObj.name : 'Sources'} @@ -185,16 +238,43 @@ export default function Sources({ source, sources, setSources, setSource }) { - - - - - - + {[ + { col: 'key', label: 'Key' }, + { col: 'origin', label: 'Origin' }, + { col: 'type', label: 'Type' }, + { col: 'constraint', label: 'Constraint', center: true }, + { col: 'inview', label: 'In view', center: true }, + { col: 'seq', label: 'Seq', center: true }, + ].map(({ col, label, center }) => ( + + ))} - {availableFields.map(f => { + {[...availableFields].sort((a, b) => { + const constraintList = constraintFields.split(',').map(s => s.trim()) + const aSchema = schemaFields.find(sf => sf.name === a.key) + const bSchema = schemaFields.find(sf => sf.name === b.key) + let av, bv + if (fieldSort.col === 'key') { av = a.key; bv = b.key } + else if (fieldSort.col === 'origin') { av = a.origins.join(','); bv = b.origins.join(',') } + else if (fieldSort.col === 'type') { av = aSchema?.type || ''; bv = bSchema?.type || '' } + else if (fieldSort.col === 'constraint') { av = constraintList.includes(a.key) ? 0 : 1; bv = constraintList.includes(b.key) ? 0 : 1 } + else if (fieldSort.col === 'inview') { av = aSchema ? 0 : 1; bv = bSchema ? 0 : 1 } + else if (fieldSort.col === 'seq') { av = aSchema?.seq ?? 999; bv = bSchema?.seq ?? 999 } + if (av < bv) return fieldSort.dir === 'asc' ? -1 : 1 + if (av > bv) return fieldSort.dir === 'asc' ? 1 : -1 + return 0 + }).map(f => { const isRaw = f.origins.includes('raw') const constraintChecked = constraintFields.split(',').map(s => s.trim()).includes(f.key) const schemaEntry = schemaFields.find(sf => sf.name === f.key) @@ -301,6 +381,7 @@ export default function Sources({ source, sources, setSources, setSource }) { )} + )} @@ -349,8 +430,14 @@ export default function Sources({ source, sources, setSources, setSource }) {

New source

- - + +
@@ -365,38 +452,87 @@ export default function Sources({ source, sources, setSources, setSource }) { {form.fields.length > 0 && ( -
- +
KeyOriginTypeConstraintIn viewSeq setFieldSort(s => ({ col, dir: s.col === col && s.dir === 'asc' ? 'desc' : 'asc' }))} + className={`pb-1 font-medium cursor-pointer select-none hover:text-gray-600 ${center ? 'text-center' : ''}`} + > + {label} + + {fieldSort.col === col ? (fieldSort.dir === 'asc' ? '▲' : '▼') : '⇅'} + +
- + + + - {form.fields.map(f => ( - - - - - - ))} + {form.fields.map(f => { + const schemaEntry = form.schema.find(s => s.name === f.name) + const inView = !!schemaEntry + const currentType = schemaEntry?.type || f.type + return ( + + + + + + + + ) + })}
FieldKey Type ConstraintIn viewSeq
{f.name}{f.type} - s.trim()).includes(f.name)} - onChange={e => { - const current = form.constraint_fields.split(',').map(s => s.trim()).filter(Boolean) - const next = e.target.checked - ? [...current, f.name] - : current.filter(n => n !== f.name) - setForm(ff => ({ ...ff, constraint_fields: next.join(', ') })) - }} - /> -
{f.name} + {inView && ( + + )} + + s.trim()).includes(f.name)} + onChange={e => { + const current = form.constraint_fields.split(',').map(s => s.trim()).filter(Boolean) + const next = e.target.checked + ? [...current, f.name] + : current.filter(n => n !== f.name) + setForm(ff => ({ ...ff, constraint_fields: next.join(', ') })) + }} + /> + + { + if (e.target.checked) { + const nextSeq = form.schema.length > 0 + ? Math.max(...form.schema.map(s => s.seq ?? 0)) + 1 + : 1 + setForm(ff => ({ ...ff, schema: [...ff.schema, { name: f.name, type: f.type, seq: nextSeq }] })) + } else { + setForm(ff => ({ ...ff, schema: ff.schema.filter(s => s.name !== f.name) })) + } + }} + /> + + {inView && ( + setForm(ff => ({ + ...ff, + schema: ff.schema.map(s => s.name === f.name ? { ...s, seq: parseInt(e.target.value) || 0 } : s) + }))} + /> + )} +
+

)} @@ -412,14 +548,26 @@ export default function Sources({ source, sources, setSources, setSource }) {
)} - +
+ + {form.fields.length > 0 && ( + + )} +
{createError &&

{createError}

}