dataflow/ui/src/pages/Rules.jsx
Paul Trowbridge f59908aaa3 Add retain flag to rules for preserving extracted values alongside mappings
Mirrors TPS's retain: y behaviour — when a mapping is applied, the extracted
value is also written to output_field so both the raw extraction and the
mapped result are available in transformed data.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 20:48:52 -04:00

395 lines
16 KiB
JavaScript

import { useState, useEffect, useRef } from 'react'
import { api } from '../api'
const EMPTY_FORM = { name: '', field: '', pattern: '', output_field: '', function_type: 'extract', flags: '', replace_value: '', retain: false, sequence: 0 }
function PreviewModal({ rows, onClose }) {
const matched = rows.filter(r => r.extracted_value != null).length
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-white rounded-lg shadow-xl w-3/4 max-w-3xl max-h-[80vh] flex flex-col"
onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-100">
<span className="text-sm font-medium text-gray-700">
Pattern results <span className="text-gray-500 font-normal">{matched}/{rows.length} matched</span>
</span>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none"></button>
</div>
<div className="overflow-auto flex-1 px-5 py-3">
<table className="w-full text-xs">
<thead>
<tr className="text-left text-gray-400 border-b border-gray-100">
<th className="pb-2 font-medium w-1/2 pr-4">Raw value</th>
<th className="pb-2 font-medium">Result</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i} className="border-t border-gray-50">
<td className="py-1 font-mono text-gray-400 pr-4 break-all">{r.raw_value}</td>
<td className={`py-1 font-mono break-all ${r.extracted_value != null ? 'text-gray-800' : 'text-gray-300'}`}>
{r.extracted_value != null
? (Array.isArray(r.extracted_value) ? r.extracted_value.join(' · ') : String(r.extracted_value))
: '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}
function FormPanel({ form, setForm, editing, error, loading, fields, source, onSubmit, onCancel }) {
const [preview, setPreview] = useState([])
const [previewing, setPreviewing] = useState(false)
const [modalOpen, setModalOpen] = useState(false)
const debounceRef = useRef(null)
// Auto-preview when field/pattern/flags change, debounced
useEffect(() => {
if (!form.field || !form.pattern) { setPreview([]); return }
clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(async () => {
setPreviewing(true)
try {
const results = await api.previewRule(source, form.field, form.pattern, form.flags, form.function_type, form.replace_value, 50)
setPreview(results)
} catch {
setPreview([])
} finally {
setPreviewing(false)
}
}, 500)
return () => clearTimeout(debounceRef.current)
}, [form.field, form.pattern, form.flags, form.function_type, form.replace_value, source])
return (
<div className="bg-white border border-gray-200 rounded p-4 mb-4">
<h2 className="text-sm font-semibold text-gray-700 mb-3">{editing ? 'Edit rule' : 'New rule'}</h2>
<form onSubmit={onSubmit} className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-gray-500 block mb-1">Rule 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. First 20"
/>
</div>
<div>
<label className="text-xs text-gray-500 block mb-1">Sequence</label>
<input
type="number"
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
value={form.sequence} onChange={e => setForm(f => ({ ...f, sequence: parseInt(e.target.value) || 0 }))}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-gray-500 block mb-1">Input field</label>
{fields.length > 0 ? (
<select
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
value={form.field} onChange={e => setForm(f => ({ ...f, field: e.target.value }))}
>
<option value=""> select field </option>
{fields.map(f => <option key={f} value={f}>{f}</option>)}
</select>
) : (
<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.field} onChange={e => setForm(f => ({ ...f, field: e.target.value }))}
placeholder="e.g. description"
/>
)}
</div>
<div>
<label className="text-xs text-gray-500 block mb-1">Output field</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.output_field} onChange={e => setForm(f => ({ ...f, output_field: e.target.value }))}
placeholder="e.g. merchant"
/>
</div>
</div>
<div>
<label className="text-xs text-gray-500 block mb-1">Pattern (regex)</label>
<input
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-blue-400"
value={form.pattern} onChange={e => setForm(f => ({ ...f, pattern: e.target.value }))}
placeholder="e.g. .{1,20}"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-gray-500 block mb-1">Function</label>
<select
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
value={form.function_type} onChange={e => setForm(f => ({ ...f, function_type: e.target.value }))}
>
<option value="extract">extract</option>
<option value="replace">replace</option>
</select>
</div>
<div>
<label className="text-xs text-gray-500 block mb-1">Flags</label>
<input
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-blue-400"
value={form.flags} onChange={e => setForm(f => ({ ...f, flags: e.target.value }))}
placeholder="e.g. i"
/>
</div>
</div>
{form.function_type === 'extract' && (
<label className="flex items-center gap-2 text-xs text-gray-600 cursor-pointer select-none">
<input
type="checkbox"
checked={!!form.retain}
onChange={e => setForm(f => ({ ...f, retain: e.target.checked }))}
/>
Retain extracted value in output field even when a mapping is applied
</label>
)}
{form.function_type === 'replace' && (
<div>
<label className="text-xs text-gray-500 block mb-1">Replacement string</label>
<input
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-blue-400"
value={form.replace_value} onChange={e => setForm(f => ({ ...f, replace_value: e.target.value }))}
placeholder="e.g. leave blank to delete the match"
/>
</div>
)}
{/* Live preview */}
{(preview.length > 0 || previewing) && (
<div className="border border-gray-100 rounded p-2 bg-gray-50">
<div className="flex items-center justify-between mb-1">
<p className="text-xs text-gray-400">
{previewing ? 'Testing…' : `${preview.filter(r => r.extracted_value != null).length}/${preview.length} matched`}
</p>
{!previewing && preview.length > 0 && (
<button type="button" onClick={() => setModalOpen(true)}
className="text-xs text-blue-400 hover:text-blue-600">expand</button>
)}
</div>
{!previewing && (
<table className="w-full text-xs">
<tbody>
{preview.slice(0, 5).map((r, i) => (
<tr key={i} className="border-t border-gray-100 first:border-0">
<td className="py-0.5 font-mono text-gray-400 truncate max-w-0 w-1/2 pr-3">{r.raw_value}</td>
<td className={`py-0.5 font-mono truncate ${r.extracted_value != null ? 'text-gray-800' : 'text-gray-300'}`}>
{r.extracted_value != null
? (Array.isArray(r.extracted_value) ? r.extracted_value.join(' · ') : String(r.extracted_value))
: '—'}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
{modalOpen && <PreviewModal rows={preview} onClose={() => setModalOpen(false)} />}
{error && <p className="text-xs text-red-500">{error}</p>}
<div className="flex gap-2">
<button type="submit" disabled={loading}
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50">
{loading ? 'Saving…' : (editing ? 'Save' : 'Create')}
</button>
<button type="button" onClick={onCancel}
className="text-sm text-gray-500 px-3 py-1.5 rounded hover:bg-gray-100">
Cancel
</button>
</div>
</form>
</div>
)
}
export default function Rules({ source }) {
const [rules, setRules] = useState([])
const [creating, setCreating] = useState(false)
const [editing, setEditing] = useState(null)
const [expanded, setExpanded] = useState(null)
const [form, setForm] = useState(EMPTY_FORM)
const [testResults, setTestResults] = useState({})
const [fields, setFields] = useState([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!source) return
api.getRules(source).then(setRules).catch(() => {})
setTestResults({})
api.getFields(source).then(f => setFields(f.map(x => x.key))).catch(() => {})
}, [source])
function startCreate() {
setForm(EMPTY_FORM)
setEditing(null)
setCreating(true)
setError('')
}
function startEdit(rule) {
setForm({
name: rule.name,
field: rule.field,
pattern: rule.pattern,
output_field: rule.output_field,
function_type: rule.function_type || 'extract',
flags: rule.flags || '',
replace_value: rule.replace_value || '',
retain: rule.retain || false,
sequence: rule.sequence,
})
setEditing(rule.id)
setCreating(false)
setError('')
}
async function handleSubmit(e, ruleId = null) {
e.preventDefault()
setError('')
setLoading(true)
const id = ruleId ?? editing
try {
if (id) {
await api.updateRule(id, { ...form, source_name: source })
} else {
await api.createRule({ ...form, source_name: source })
}
const updated = await api.getRules(source)
setRules(updated)
setCreating(false)
setEditing(null)
setExpanded(null)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
async function handleDelete(id) {
if (!confirm('Delete this rule and all its mappings?')) return
try {
await api.deleteRule(id)
setRules(r => r.filter(x => x.id !== id))
setTestResults(t => { const n = { ...t }; delete n[id]; return n })
} catch (err) {
alert(err.message)
}
}
async function handleTest(id) {
try {
const res = await api.testRule(id)
setTestResults(t => ({ ...t, [id]: res.results }))
} catch (err) {
alert(err.message)
}
}
async function handleToggle(rule) {
try {
await api.updateRule(rule.id, { enabled: !rule.enabled })
setRules(r => r.map(x => x.id === rule.id ? { ...x, enabled: !x.enabled } : x))
} catch (err) {
alert(err.message)
}
}
if (!source) return <div className="p-6 text-sm text-gray-400">Select a source first.</div>
return (
<div className="p-6 max-w-3xl">
<div className="flex items-center justify-between mb-6">
<h1 className="text-xl font-semibold text-gray-800">Rules {source}</h1>
<button onClick={startCreate}
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700">
New rule
</button>
</div>
{creating && (
<FormPanel
form={form} setForm={setForm} editing={false}
error={error} loading={loading} fields={fields} source={source}
onSubmit={handleSubmit}
onCancel={() => { setCreating(false); setError('') }}
/>
)}
{rules.length === 0 && !creating && (
<p className="text-sm text-gray-400">No rules yet. Add a regex rule to start extracting values.</p>
)}
<div className="space-y-2">
{rules.map(rule => {
const isExpanded = expanded === rule.id
return (
<div key={rule.id} className="bg-white border border-gray-200 rounded">
{/* Header — always visible, click to expand/collapse */}
<div
className="flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-gray-50 select-none"
onClick={() => {
if (isExpanded) { setExpanded(null); setEditing(null) }
else { setExpanded(rule.id); startEdit(rule) }
}}
>
<button
onClick={e => { e.stopPropagation(); handleToggle(rule) }}
className={`w-8 h-4 rounded-full flex-shrink-0 transition-colors ${rule.enabled ? 'bg-blue-500' : 'bg-gray-200'}`}
title={rule.enabled ? 'Disable' : 'Enable'}
/>
<div className="flex-1 min-w-0">
<span className="font-medium text-gray-800 text-sm">{rule.name}</span>
<span className="text-gray-400 text-xs ml-2">seq {rule.sequence}</span>
{!isExpanded && (
<div className="text-xs text-gray-400 mt-0.5 truncate">
<span className="font-mono">{rule.field}</span>
<span className="mx-1"></span>
<span className="font-mono bg-gray-50 px-1 rounded">{rule.pattern}</span>
{rule.flags && <span className="text-blue-400 ml-1">/{rule.flags}</span>}
<span className="mx-1"></span>
<span className="font-mono">{rule.output_field}</span>
{rule.function_type === 'replace' && <span className="ml-1 text-orange-400">(replace)</span>}
</div>
)}
</div>
<span className="text-xs text-gray-300 flex-shrink-0">{isExpanded ? '▲' : '▼'}</span>
</div>
{/* Expanded content */}
{isExpanded && (
<div className="border-t border-gray-100">
<div className="px-4 pt-3 pb-1 flex justify-end">
<button onClick={e => { e.stopPropagation(); handleDelete(rule.id) }}
className="text-xs text-red-400 hover:text-red-600">Delete</button>
</div>
<div className="px-4 pb-4">
<FormPanel
form={form} setForm={setForm} editing={true}
error={error} loading={loading} fields={fields} source={source}
onSubmit={e => handleSubmit(e, rule.id)}
onCancel={() => { setEditing(null); setExpanded(null) }}
/>
</div>
</div>
)}
</div>
)
})}
</div>
</div>
)
}