- 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 <noreply@anthropic.com>
591 lines
26 KiB
JavaScript
591 lines
26 KiB
JavaScript
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 (
|
|
<div className="overflow-auto border border-gray-100 rounded bg-gray-50 max-h-36">
|
|
<table className="text-xs w-full">
|
|
<thead>
|
|
<tr className="text-left text-gray-400 border-b border-gray-100 bg-gray-50 sticky top-0">
|
|
{cols.map(c => <th key={c} className="px-2 py-1 font-medium whitespace-nowrap">{c}</th>)}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((row, i) => (
|
|
<tr key={i} className="border-t border-gray-100">
|
|
{cols.map(c => (
|
|
<td key={c} className="px-2 py-1 whitespace-nowrap text-gray-600 max-w-32 truncate font-mono">
|
|
{row[c] == null ? <span className="text-gray-300">—</span> : String(row[c])}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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)
|
|
const [result, setResult] = useState('')
|
|
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: [], 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(', ') || '')
|
|
setGlobalPicklist(sourceObj.global_picklist !== false)
|
|
setSchemaFields((sourceObj.config?.fields || []).map((f, i) => ({ seq: i + 1, ...f })))
|
|
setViewName(sourceObj.config?.fields?.length ? `dfv.${sourceObj.name}` : '')
|
|
setResult('')
|
|
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) {
|
|
e.preventDefault()
|
|
setSaving(true)
|
|
setError('')
|
|
try {
|
|
const constraint_fields = constraintFields.split(',').map(s => s.trim()).filter(Boolean)
|
|
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.')
|
|
} catch (err) {
|
|
setError(err.message)
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
async function handleGenerateView() {
|
|
setGenerating(true)
|
|
setResult('')
|
|
setError('')
|
|
try {
|
|
const constraint_fields = constraintFields.split(',').map(s => s.trim()).filter(Boolean)
|
|
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 })
|
|
const res = await api.generateView(sourceObj.name)
|
|
if (res.success) {
|
|
setViewName(res.view)
|
|
setResult(`View created: ${res.view}`)
|
|
} else {
|
|
setError(res.error)
|
|
}
|
|
} catch (err) {
|
|
setError(err.message)
|
|
} finally {
|
|
setGenerating(false)
|
|
}
|
|
}
|
|
|
|
async function handleReprocess() {
|
|
if (!confirm(`Reprocess all records for "${sourceObj.name}"? This will clear and reapply all transformations.`)) return
|
|
setReprocessing(true)
|
|
setResult('')
|
|
setError('')
|
|
try {
|
|
const res = await api.reprocess(sourceObj.name)
|
|
setResult(`Reprocessed ${res.transformed} records.`)
|
|
api.getStats(sourceObj.name).then(setStats).catch(() => {})
|
|
} catch (err) {
|
|
setError(err.message)
|
|
} finally {
|
|
setReprocessing(false)
|
|
}
|
|
}
|
|
|
|
async function handleDelete() {
|
|
if (!confirm(`Delete source "${sourceObj.name}" and all its data?`)) return
|
|
try {
|
|
await api.deleteSource(sourceObj.name)
|
|
const updated = await api.getSources()
|
|
setSources(updated)
|
|
if (updated.length > 0) setSource(updated[0].name)
|
|
else setSource('')
|
|
} catch (err) {
|
|
alert(err.message)
|
|
}
|
|
}
|
|
|
|
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, seq: suggestion.fields.indexOf(f) + 1 })),
|
|
sampleRows: suggestion.sampleRows || []
|
|
}))
|
|
} catch (err) {
|
|
setCreateError(err.message)
|
|
}
|
|
}
|
|
|
|
async function handleCreate(e) {
|
|
e.preventDefault()
|
|
setCreateError('')
|
|
const constraintArr = form.constraint_fields.split(',').map(s => s.trim()).filter(Boolean)
|
|
if (!form.name || constraintArr.length === 0) {
|
|
setCreateError('Name and at least one constraint field required')
|
|
return
|
|
}
|
|
setCreateLoading(true)
|
|
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: [], importSample: true })
|
|
setCreating(false)
|
|
} catch (err) {
|
|
setCreateError(err.message)
|
|
} finally {
|
|
setCreateLoading(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="p-6 max-w-5xl">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h1 className="text-xl font-semibold text-gray-800">
|
|
{sourceObj ? sourceObj.name : 'Sources'}
|
|
</h1>
|
|
<button
|
|
onClick={() => { setCreating(true); setCreateError('') }}
|
|
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700"
|
|
>
|
|
New source
|
|
</button>
|
|
</div>
|
|
|
|
{/* No source selected */}
|
|
{!sourceObj && !creating && (
|
|
<p className="text-sm text-gray-400">No sources yet. Create one to get started.</p>
|
|
)}
|
|
|
|
{/* Source detail */}
|
|
{sourceObj && !creating && (
|
|
<div className="space-y-4">
|
|
{/* Stats */}
|
|
{stats && (
|
|
<div className="flex gap-4 text-xs">
|
|
<span className="text-gray-500"><span className="font-medium text-gray-800">{stats.total_records}</span> total</span>
|
|
<span className="text-gray-500"><span className="font-medium text-gray-800">{stats.transformed_records}</span> transformed</span>
|
|
<span className="text-gray-500"><span className="font-medium text-gray-800">{stats.pending_records}</span> pending</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Unified field table */}
|
|
{availableFields.length > 0 && (
|
|
<div className="pt-2 border-t border-gray-100 space-y-2">
|
|
<table className="w-full text-xs">
|
|
<thead>
|
|
<tr className="text-left text-gray-400 border-b border-gray-100">
|
|
{[
|
|
{ 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 }) => (
|
|
<th
|
|
key={col}
|
|
onClick={() => 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}
|
|
<span className="ml-1 text-gray-300">
|
|
{fieldSort.col === col ? (fieldSort.dir === 'asc' ? '▲' : '▼') : '⇅'}
|
|
</span>
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{[...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)
|
|
const inView = !!schemaEntry
|
|
return (
|
|
<tr key={f.key} className="border-t border-gray-50">
|
|
<td className="py-1 font-mono text-gray-700">{f.key}</td>
|
|
<td className="py-1 text-gray-400">{f.origins.join(', ')}</td>
|
|
<td className="py-1">
|
|
{inView && (
|
|
<div className="flex gap-1 items-center">
|
|
<select
|
|
className="border border-gray-200 rounded px-1 py-0.5 text-xs focus:outline-none focus:border-blue-400"
|
|
value={schemaEntry.type}
|
|
onChange={e => setSchemaFields(sf =>
|
|
sf.map(s => s.name === f.key ? { ...s, type: e.target.value } : s)
|
|
)}
|
|
>
|
|
{FIELD_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
|
|
</select>
|
|
<input
|
|
className="border border-gray-200 rounded px-1 py-0.5 text-xs font-mono w-32 focus:outline-none focus:border-blue-400"
|
|
value={schemaEntry.expression || ''}
|
|
placeholder="{field} * {sign}"
|
|
onChange={e => setSchemaFields(sf =>
|
|
sf.map(s => s.name === f.key ? { ...s, expression: e.target.value || undefined } : s)
|
|
)}
|
|
/>
|
|
</div>
|
|
)}
|
|
</td>
|
|
<td className="py-1 text-center">
|
|
{isRaw && (
|
|
<input
|
|
type="checkbox"
|
|
checked={constraintChecked}
|
|
onChange={e => {
|
|
const current = constraintFields.split(',').map(s => s.trim()).filter(Boolean)
|
|
const next = e.target.checked
|
|
? [...current, f.key]
|
|
: current.filter(k => k !== f.key)
|
|
setConstraintFields(next.join(', '))
|
|
}}
|
|
/>
|
|
)}
|
|
</td>
|
|
<td className="py-1 text-center">
|
|
<input
|
|
type="checkbox"
|
|
checked={inView}
|
|
onChange={e => {
|
|
if (e.target.checked) {
|
|
const nextSeq = schemaFields.length > 0
|
|
? Math.max(...schemaFields.map(s => s.seq ?? 0)) + 1
|
|
: 1
|
|
setSchemaFields(sf => [...sf, { name: f.key, type: 'text', seq: nextSeq }])
|
|
} else {
|
|
setSchemaFields(sf => sf.filter(s => s.name !== f.key))
|
|
}
|
|
}}
|
|
/>
|
|
</td>
|
|
<td className="py-1 text-center">
|
|
{inView && (
|
|
<input
|
|
type="number"
|
|
className="w-12 border border-gray-200 rounded px-1 py-0.5 text-xs text-center focus:outline-none focus:border-blue-400"
|
|
value={schemaEntry.seq ?? ''}
|
|
onChange={e => setSchemaFields(sf =>
|
|
sf.map(s => s.name === f.key ? { ...s, seq: parseInt(e.target.value) || 0 } : s)
|
|
)}
|
|
/>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
|
|
<div className="flex items-center gap-3 pt-1 flex-wrap">
|
|
<label className="flex items-center gap-1.5 text-xs text-gray-500 cursor-pointer">
|
|
<input type="checkbox" checked={globalPicklist} onChange={e => setGlobalPicklist(e.target.checked)} />
|
|
Global picklist
|
|
</label>
|
|
<form onSubmit={handleSave}>
|
|
<button type="submit" disabled={saving}
|
|
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50">
|
|
{saving ? 'Saving…' : 'Save'}
|
|
</button>
|
|
</form>
|
|
{schemaFields.length > 0 && (
|
|
<>
|
|
<button
|
|
onClick={handleGenerateView}
|
|
disabled={generating}
|
|
className="text-xs bg-green-600 text-white px-2 py-1.5 rounded hover:bg-green-700 disabled:opacity-50"
|
|
>
|
|
{generating ? 'Generating…' : 'Generate view'}
|
|
</button>
|
|
{viewName && (
|
|
<code className="text-xs bg-gray-100 px-2 py-1 rounded text-gray-600">{viewName}</code>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
<SampleTable rows={sampleRows} />
|
|
</div>
|
|
)}
|
|
|
|
{/* Save button when no fields loaded yet */}
|
|
{availableFields.length === 0 && (
|
|
<div className="flex items-center gap-3">
|
|
<label className="flex items-center gap-1.5 text-xs text-gray-500 cursor-pointer">
|
|
<input type="checkbox" checked={globalPicklist} onChange={e => setGlobalPicklist(e.target.checked)} />
|
|
Global picklist
|
|
</label>
|
|
<form onSubmit={handleSave}>
|
|
<button type="submit" disabled={saving}
|
|
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50">
|
|
{saving ? 'Saving…' : 'Save'}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
)}
|
|
|
|
{/* Reprocess */}
|
|
<div className="flex items-center gap-3 pt-2 border-t border-gray-100">
|
|
<button
|
|
onClick={handleReprocess}
|
|
disabled={reprocessing}
|
|
className="text-sm bg-orange-500 text-white px-3 py-1.5 rounded hover:bg-orange-600 disabled:opacity-50"
|
|
>
|
|
{reprocessing ? 'Reprocessing…' : 'Reprocess all records'}
|
|
</button>
|
|
<span className="text-xs text-gray-400">Clears and reruns all transformation rules</span>
|
|
</div>
|
|
|
|
{result && <p className="text-xs text-green-600">{result}</p>}
|
|
{error && <p className="text-xs text-red-500">{error}</p>}
|
|
|
|
<div className="pt-2 border-t border-gray-100">
|
|
<button onClick={handleDelete} className="text-xs text-red-400 hover:text-red-600">
|
|
Delete source
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Create form */}
|
|
{creating && (
|
|
<div className="bg-white border border-gray-200 rounded p-4">
|
|
<h2 className="text-sm font-semibold text-gray-700 mb-3">New source</h2>
|
|
|
|
<div className="mb-4">
|
|
<input type="file" accept=".csv" ref={fileRef} onChange={handleSuggest} className="hidden" />
|
|
<button
|
|
type="button"
|
|
onClick={() => fileRef.current?.click()}
|
|
className="text-sm border border-gray-300 rounded px-3 py-1.5 text-gray-600 hover:bg-gray-50 hover:border-gray-400"
|
|
>
|
|
{csvFileName || 'Choose CSV…'}
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleCreate} className="space-y-3">
|
|
<div>
|
|
<label className="text-xs text-gray-500 block mb-1">Source name</label>
|
|
<input
|
|
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
|
|
value={form.name}
|
|
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
|
placeholder="e.g. chase, dcard"
|
|
/>
|
|
</div>
|
|
|
|
{form.fields.length > 0 && (
|
|
<div className="pt-2 border-t border-gray-100 space-y-2">
|
|
<table className="w-full text-xs">
|
|
<thead>
|
|
<tr className="text-left text-gray-400 border-b border-gray-100">
|
|
<th className="pb-1 font-medium">Key</th>
|
|
<th className="pb-1 font-medium">Type</th>
|
|
<th className="pb-1 font-medium text-center">Constraint</th>
|
|
<th className="pb-1 font-medium text-center">In view</th>
|
|
<th className="pb-1 font-medium text-center">Seq</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{form.fields.map(f => {
|
|
const schemaEntry = form.schema.find(s => s.name === f.name)
|
|
const inView = !!schemaEntry
|
|
const currentType = schemaEntry?.type || f.type
|
|
return (
|
|
<tr key={f.name} className="border-t border-gray-50">
|
|
<td className="py-1 font-mono text-gray-700">{f.name}</td>
|
|
<td className="py-1">
|
|
{inView && (
|
|
<select
|
|
className="border border-gray-200 rounded px-1 py-0.5 text-xs focus:outline-none focus:border-blue-400"
|
|
value={currentType}
|
|
onChange={e => setForm(ff => ({
|
|
...ff,
|
|
schema: ff.schema.map(s => s.name === f.name ? { ...s, type: e.target.value } : s)
|
|
}))}
|
|
>
|
|
{FIELD_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
|
|
</select>
|
|
)}
|
|
</td>
|
|
<td className="py-1 text-center">
|
|
<input
|
|
type="checkbox"
|
|
checked={form.constraint_fields.split(',').map(s => 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(', ') }))
|
|
}}
|
|
/>
|
|
</td>
|
|
<td className="py-1 text-center">
|
|
<input
|
|
type="checkbox"
|
|
checked={inView}
|
|
onChange={e => {
|
|
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) }))
|
|
}
|
|
}}
|
|
/>
|
|
</td>
|
|
<td className="py-1 text-center">
|
|
{inView && (
|
|
<input
|
|
type="number"
|
|
className="w-12 border border-gray-200 rounded px-1 py-0.5 text-xs text-center focus:outline-none focus:border-blue-400"
|
|
value={schemaEntry.seq ?? ''}
|
|
onChange={e => setForm(ff => ({
|
|
...ff,
|
|
schema: ff.schema.map(s => s.name === f.name ? { ...s, seq: parseInt(e.target.value) || 0 } : s)
|
|
}))}
|
|
/>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
<SampleTable rows={form.sampleRows || []} />
|
|
</div>
|
|
)}
|
|
|
|
{form.fields.length === 0 && (
|
|
<div>
|
|
<label className="text-xs text-gray-500 block mb-1">Constraint fields (comma-separated)</label>
|
|
<input
|
|
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
|
|
value={form.constraint_fields}
|
|
onChange={e => setForm(f => ({ ...f, constraint_fields: e.target.value }))}
|
|
placeholder="e.g. date, amount, description"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-4">
|
|
<label className="flex items-center gap-1.5 text-xs text-gray-500 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={form.global_picklist !== false}
|
|
onChange={e => setForm(f => ({ ...f, global_picklist: e.target.checked }))}
|
|
/>
|
|
Global picklist
|
|
</label>
|
|
{form.fields.length > 0 && (
|
|
<label className="flex items-center gap-1.5 text-xs text-gray-500 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={form.importSample !== false}
|
|
onChange={e => setForm(f => ({ ...f, importSample: e.target.checked }))}
|
|
/>
|
|
Import sample data
|
|
</label>
|
|
)}
|
|
</div>
|
|
|
|
{createError && <p className="text-xs text-red-500">{createError}</p>}
|
|
|
|
<div className="flex gap-2">
|
|
<button type="submit" disabled={createLoading}
|
|
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50">
|
|
{createLoading ? 'Creating…' : 'Create'}
|
|
</button>
|
|
<button type="button"
|
|
onClick={() => { setCreating(false); setCreateError(''); setForm({ name: '', constraint_fields: '', fields: [], schema: [] }) }}
|
|
className="text-sm text-gray-500 px-3 py-1.5 rounded hover:bg-gray-100">
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|