Lift stack state to App; merge Records panel; fix Pivot theme on load
- Stack selection lifted to App.jsx: stacks fetched on login, selectedStack state shared via StatusBar (pills) and Pivot (view switching); Stacks page calls onStacksChange to keep list fresh - Pivot: derive selectedView/viewType from props, remove local stack state; toolbar replaced with dedicated layouts sub-bar (h-9, layouts only) - Records panel: merge read-only and override sections into single field list; known cols seeded from record's transformed fields; rule-derived fields (transformed minus data) will be editable in follow-up refactor - Pivot theme: setAttribute moved to after flush() so restore() can't reset it Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9e0fa4aa7e
commit
1baadaca61
@ -18,7 +18,9 @@ export default function App() {
|
||||
const [authed, setAuthed] = useState(false)
|
||||
const [loginUser, setLoginUser] = useState('')
|
||||
const [sources, setSources] = useState([])
|
||||
const [stacks, setStacks] = useState([])
|
||||
const [source, setSource] = useState(() => localStorage.getItem('selectedSource') || '')
|
||||
const [selectedStack, setSelectedStack] = useState(null)
|
||||
const [sidebarExpanded, setSidebarExpanded] = useState(() => localStorage.getItem('df_sidebar') !== 'collapsed')
|
||||
// Sets of names whose dfv view is out of sync with current definitions
|
||||
const [staleSources, setStaleSources] = useState(new Set())
|
||||
@ -28,14 +30,14 @@ export default function App() {
|
||||
|
||||
async function handleLogin(user, pass) {
|
||||
setCredentials(user, pass)
|
||||
await api.getSources().then(s => {
|
||||
sessionStorage.setItem('df_user', user)
|
||||
sessionStorage.setItem('df_pass', pass)
|
||||
setSources(s)
|
||||
if (!source && s.length > 0) setSource(s[0].name)
|
||||
setAuthed(true)
|
||||
setLoginUser(user)
|
||||
})
|
||||
const s = await api.getSources()
|
||||
sessionStorage.setItem('df_user', user)
|
||||
sessionStorage.setItem('df_pass', pass)
|
||||
setSources(s)
|
||||
if (!source && s.length > 0) setSource(s[0].name)
|
||||
setAuthed(true)
|
||||
setLoginUser(user)
|
||||
api.getStacks().then(setStacks).catch(() => {})
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
@ -45,11 +47,17 @@ export default function App() {
|
||||
setAuthed(false)
|
||||
setLoginUser('')
|
||||
setSources([])
|
||||
setStacks([])
|
||||
setSelectedStack(null)
|
||||
setStaleSources(new Set())
|
||||
setStaleStacks(new Set())
|
||||
setReprocessSources(new Set())
|
||||
}
|
||||
|
||||
function refreshStacks() {
|
||||
api.getStacks().then(setStacks).catch(() => {})
|
||||
}
|
||||
|
||||
// Load initial stale state from DB once on login
|
||||
useEffect(() => {
|
||||
if (!authed) return
|
||||
@ -128,7 +136,10 @@ export default function App() {
|
||||
|
||||
{/* Main */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col min-w-0">
|
||||
<StatusBar sources={sources} source={source} setSource={setSource} />
|
||||
<StatusBar
|
||||
sources={sources} source={source} setSource={setSource}
|
||||
stacks={stacks} selectedStack={selectedStack} setSelectedStack={setSelectedStack}
|
||||
/>
|
||||
|
||||
{(staleSources.size > 0 || staleStacks.size > 0) && (
|
||||
<div className="bg-amber-50 border-b border-amber-200 px-4 py-1.5 text-xs text-amber-800 flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
@ -187,8 +198,8 @@ export default function App() {
|
||||
<Route path="/mappings" element={<Mappings source={source} onNeedsReprocess={markNeedsReprocess} />} />
|
||||
<Route path="/remap" element={<Remap />} />
|
||||
<Route path="/records" element={<Records source={source} />} />
|
||||
<Route path="/pivot" element={<Pivot source={source} />} />
|
||||
<Route path="/stacks" element={<Stacks sources={sources} onStackStale={markStackStale} onStackViewGenerated={clearStackStale} />} />
|
||||
<Route path="/pivot" element={<Pivot source={source} selectedStack={selectedStack} setSelectedStack={setSelectedStack} />} />
|
||||
<Route path="/stacks" element={<Stacks sources={sources} onStackStale={markStackStale} onStackViewGenerated={clearStackStale} onStacksChange={refreshStacks} />} />
|
||||
<Route path="/log" element={<Log />} />
|
||||
</Routes>
|
||||
</div>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import useTheme from '../theme.jsx'
|
||||
|
||||
export default function StatusBar({ sources = [], source, setSource }) {
|
||||
export default function StatusBar({ sources = [], source, setSource, stacks = [], selectedStack, setSelectedStack }) {
|
||||
const { dark, setDark } = useTheme()
|
||||
|
||||
return (
|
||||
@ -23,6 +23,26 @@ export default function StatusBar({ sources = [], source, setSource }) {
|
||||
title="New source"
|
||||
>+</NavLink>
|
||||
|
||||
{stacks.length > 0 && (
|
||||
<>
|
||||
<span className="text-gray-200">|</span>
|
||||
<span className="text-gray-400">Stacks</span>
|
||||
{stacks.map(s => (
|
||||
<button
|
||||
key={s.name}
|
||||
onClick={() => setSelectedStack(n => n === s.name ? null : s.name)}
|
||||
className={`rounded px-2 py-0.5 border transition-colors ${
|
||||
selectedStack === s.name
|
||||
? 'bg-purple-50 border-purple-300 text-purple-700'
|
||||
: 'bg-white border-gray-200 text-gray-500 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
{s.name}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="ml-auto">
|
||||
<button
|
||||
onClick={() => setDark(d => !d)}
|
||||
|
||||
@ -81,7 +81,7 @@ const LAYOUT_KEY = (source) => `psp_layout_${source}`
|
||||
const DEFAULT_PLUGIN_CONFIG = { edit_mode: 'SELECT_REGION' }
|
||||
|
||||
|
||||
export default function Pivot({ source }) {
|
||||
export default function Pivot({ source, selectedStack, setSelectedStack }) {
|
||||
const { dark } = useTheme()
|
||||
const viewerRef = useRef()
|
||||
const workerRef = useRef()
|
||||
@ -99,25 +99,13 @@ export default function Pivot({ source }) {
|
||||
const [sortCol, setSortCol] = useState(null)
|
||||
const [sortDir, setSortDir] = useState('asc')
|
||||
|
||||
// View selector: source or a stack
|
||||
const [stacks, setStacks] = useState([])
|
||||
const [selectedView, setSelectedView] = useState(source) // name of active dfv view
|
||||
const [viewType, setViewType] = useState('source') // 'source' | 'stack'
|
||||
|
||||
useEffect(() => { api.getStacks().then(setStacks).catch(() => {}) }, [])
|
||||
const selectedView = selectedStack ?? source
|
||||
const viewType = selectedStack ? 'stack' : 'source'
|
||||
|
||||
useEffect(() => {
|
||||
if (viewerRef.current) viewerRef.current.setAttribute('theme', dark ? 'Pro Dark' : 'Pro Light')
|
||||
}, [dark])
|
||||
|
||||
// When sidebar source changes, reset to that source
|
||||
useEffect(() => {
|
||||
if (viewType === 'source') setSelectedView(source)
|
||||
}, [source])
|
||||
|
||||
function selectSource() { setViewType('source'); setSelectedView(source) }
|
||||
function selectStack(name) { setViewType('stack'); setSelectedView(name) }
|
||||
|
||||
// Named layouts — stacks use localStorage only (no server FK to sources)
|
||||
const [layouts, setLayouts] = useState([])
|
||||
const [activeLayoutId, setActiveLayoutId] = useState(null)
|
||||
@ -138,7 +126,7 @@ export default function Pivot({ source }) {
|
||||
: await api.getStackPivotLayouts(selectedView)
|
||||
setLayouts(rows)
|
||||
} catch {}
|
||||
}, [selectedView, viewType])
|
||||
}, [selectedView])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedView) return
|
||||
@ -442,29 +430,12 @@ export default function Pivot({ source }) {
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col">
|
||||
|
||||
{/* Layout toolbar */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-white border-b border-gray-200 flex-shrink-0">
|
||||
|
||||
{/* View selector */}
|
||||
<div className="flex items-center gap-1 mr-2 border-r border-gray-200 pr-3">
|
||||
<button
|
||||
onClick={selectSource}
|
||||
className={`text-xs rounded px-2 py-0.5 border transition-colors ${viewType === 'source' ? 'bg-blue-50 border-blue-300 text-blue-700' : 'bg-white border-gray-200 text-gray-500 hover:border-gray-400'}`}
|
||||
>{source}</button>
|
||||
{stacks.map(s => (
|
||||
<button key={s.name}
|
||||
onClick={() => selectStack(s.name)}
|
||||
className={`text-xs rounded px-2 py-0.5 border transition-colors ${viewType === 'stack' && selectedView === s.name ? 'bg-purple-50 border-purple-300 text-purple-700' : 'bg-white border-gray-200 text-gray-500 hover:border-gray-400'}`}
|
||||
>{s.name}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-gray-400 uppercase tracking-wide mr-1">Layouts</span>
|
||||
|
||||
{/* Layouts sub-bar */}
|
||||
<div className="flex items-center gap-2 px-3 h-9 bg-white border-b border-gray-200 shrink-0 text-xs">
|
||||
{layouts.map(l => (
|
||||
<div key={l.id}
|
||||
onClick={() => applyLayout(l)}
|
||||
className={`flex items-center gap-1 text-xs rounded px-2 py-0.5 cursor-pointer border transition-colors
|
||||
className={`flex items-center gap-1 rounded px-2 py-0.5 cursor-pointer border transition-colors
|
||||
${activeLayoutId === l.id
|
||||
? 'bg-blue-50 border-blue-300 text-blue-700'
|
||||
: 'bg-white border-gray-200 text-gray-600 hover:border-gray-400'}`}>
|
||||
@ -477,7 +448,7 @@ export default function Pivot({ source }) {
|
||||
|
||||
{activeLayoutId !== null && !showSaveAs && (
|
||||
<button onClick={handleSaveOver}
|
||||
className="text-xs text-blue-500 hover:text-blue-700 border border-blue-200 rounded px-2 py-0.5">
|
||||
className="text-blue-500 hover:text-blue-700 border border-blue-200 rounded px-2 py-0.5">
|
||||
Save
|
||||
</button>
|
||||
)}
|
||||
@ -490,30 +461,27 @@ export default function Pivot({ source }) {
|
||||
onChange={e => setSaveAsName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleSaveAs(); if (e.key === 'Escape') { setShowSaveAs(false); setSaveAsName('') } }}
|
||||
placeholder="Layout name…"
|
||||
className="text-xs border border-gray-300 rounded px-2 py-0.5 w-36 focus:outline-none focus:border-blue-400"
|
||||
className="border border-gray-300 rounded px-2 py-0.5 w-36 focus:outline-none focus:border-blue-400"
|
||||
/>
|
||||
<button onClick={handleSaveAs} className="text-xs text-blue-600 hover:text-blue-800 px-1">Save</button>
|
||||
<button onClick={() => { setShowSaveAs(false); setSaveAsName('') }} className="text-xs text-gray-400 hover:text-gray-600 px-1">Cancel</button>
|
||||
<button onClick={handleSaveAs} className="text-blue-600 hover:text-blue-800 px-1">Save</button>
|
||||
<button onClick={() => { setShowSaveAs(false); setSaveAsName('') }} className="text-gray-400 hover:text-gray-600 px-1">Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowSaveAs(true)}
|
||||
className="text-xs text-gray-400 hover:text-gray-600 border border-dashed border-gray-200 rounded px-2 py-0.5">
|
||||
className="text-gray-400 hover:text-gray-600 border border-dashed border-gray-200 rounded px-2 py-0.5">
|
||||
+ Save as…
|
||||
</button>
|
||||
)}
|
||||
|
||||
{activeLayoutId !== null && (
|
||||
<button onClick={handleResetToDefault}
|
||||
className="text-xs text-gray-300 hover:text-gray-500 ml-1">
|
||||
reset
|
||||
</button>
|
||||
<button onClick={handleResetToDefault} className="text-gray-300 hover:text-gray-500 ml-1">reset</button>
|
||||
)}
|
||||
|
||||
{layoutMsg && <span className="text-xs text-green-600 ml-1">{layoutMsg}</span>}
|
||||
{layoutMsg && <span className="text-green-600 ml-1">{layoutMsg}</span>}
|
||||
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<span className="text-xs text-gray-400">depth:</span>
|
||||
<span className="text-gray-400">depth:</span>
|
||||
{[0, 1, 2, 3].map(d => (
|
||||
<button key={d} onClick={async () => {
|
||||
const v = viewerRef.current; if (!v) return
|
||||
@ -522,7 +490,7 @@ export default function Pivot({ source }) {
|
||||
const p = await v.getPlugin()
|
||||
await p.draw(view)
|
||||
expandDepthRef.current = d
|
||||
}} className="text-xs border border-gray-200 rounded px-1.5 py-0.5 text-gray-500 hover:border-gray-400">
|
||||
}} className="border border-gray-200 rounded px-1.5 py-0.5 text-gray-500 hover:border-gray-400">
|
||||
{d}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@ -271,8 +271,11 @@ export default function Records({ source }) {
|
||||
const displayCols = (rows.length > 0 ? Object.keys(rows[0]) : cols).filter(c => !HIDDEN_COLS.has(c))
|
||||
const visCols = cols.filter(c => !HIDDEN_COLS.has(c))
|
||||
|
||||
// All override cols: known from DB + new ones added this session
|
||||
// For bulk bar: only established override keys (not all transformed cols)
|
||||
const allOverrideCols = [...new Set([...overrideCols, ...extraCols])]
|
||||
// For the single-record panel: all transformed fields + any override keys + draft keys
|
||||
const recordTransformedCols = Object.keys(selectedRecord?.transformed || {}).filter(c => !HIDDEN_COLS.has(c))
|
||||
const knownCols = [...new Set([...overrideCols, ...recordTransformedCols, ...Object.keys(overrideDraft)])]
|
||||
|
||||
const savedOverrides = selectedRecord?.overrides || {}
|
||||
const isDirty = Object.values(overrideDraft).some(v => String(v).trim())
|
||||
@ -493,59 +496,74 @@ export default function Records({ source }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Read-only transformed fields */}
|
||||
<div className="border-b border-gray-100">
|
||||
{Object.entries(selectedRecord.transformed || {}).map(([field, val]) => (
|
||||
<div key={field} className="flex items-baseline gap-2 px-3 py-1 border-t border-gray-50 first:border-t-0">
|
||||
<span className="text-xs font-mono text-gray-400 w-28 shrink-0 truncate">{field}</span>
|
||||
<span className="text-xs font-mono text-gray-600 truncate">{formatVal(val) ?? <span className="text-gray-300">—</span>}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center justify-between px-3 py-1.5 bg-gray-50 border-b border-gray-100 shrink-0">
|
||||
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">Fields</span>
|
||||
<button
|
||||
onClick={() => setExtraCols(ec => [...ec, ''])}
|
||||
className="text-gray-400 hover:text-gray-700 font-medium text-sm leading-none"
|
||||
title="Add field">+</button>
|
||||
</div>
|
||||
|
||||
{/* Override cols — Mappings-style */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between px-3 py-1.5 bg-gray-50 border-b border-gray-100">
|
||||
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">Override</span>
|
||||
<button
|
||||
onClick={() => setExtraCols(ec => [...ec, ''])}
|
||||
className="text-gray-400 hover:text-gray-700 font-medium text-sm leading-none"
|
||||
title="Add column">+</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<table className="w-full text-xs">
|
||||
<tbody>
|
||||
{allOverrideCols.map((col, idx) => {
|
||||
const isExtra = idx >= overrideCols.length
|
||||
const suggestions = [...(globalValues[col] || [])].sort()
|
||||
{knownCols.map(col => {
|
||||
const val = overrideDraft[col] ?? ''
|
||||
const placeholder = formatVal(selectedRecord.transformed?.[col]) ?? ''
|
||||
const suggestions = [...(globalValues[col] || [])].sort()
|
||||
return (
|
||||
<tr key={col || `extra-${idx}`} className="border-t border-gray-50">
|
||||
<td className="px-3 py-1 w-28 shrink-0">
|
||||
{isExtra ? (
|
||||
<input
|
||||
className="w-full text-xs font-mono border border-gray-200 rounded px-1 py-0.5 focus:outline-none focus:border-blue-400"
|
||||
value={col}
|
||||
placeholder="field name"
|
||||
onChange={e => {
|
||||
const newName = e.target.value
|
||||
setExtraCols(ec => { const c = [...ec]; c[idx - overrideCols.length] = newName; return c })
|
||||
if (val) setOverrideDraft(d => {
|
||||
const n = { ...d }
|
||||
delete n[col]
|
||||
if (newName) n[newName] = val
|
||||
return n
|
||||
})
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="font-mono text-gray-500 truncate block">{col}</span>
|
||||
)}
|
||||
<tr key={col} className="border-t border-gray-50">
|
||||
<td className="px-3 py-1.5 w-28 shrink-0">
|
||||
<span className="font-mono text-gray-500 truncate block">{col}</span>
|
||||
</td>
|
||||
<td className="px-1 py-1">
|
||||
<td className="px-1 py-1.5">
|
||||
<AutocompleteInput
|
||||
className={`w-full text-xs font-mono px-2 py-0.5 rounded border focus:outline-none ${
|
||||
val ? 'border-amber-300 bg-amber-50 text-amber-800' : 'border-gray-200 text-gray-700'
|
||||
val ? 'border-amber-300 bg-amber-50 text-amber-800' : 'border-gray-200 text-gray-600'
|
||||
}`}
|
||||
value={val}
|
||||
placeholder={placeholder}
|
||||
onChange={v => setOverrideDraft(d => ({ ...d, [col]: v }))}
|
||||
onEnter={handleSaveOverrides}
|
||||
suggestions={suggestions}
|
||||
/>
|
||||
</td>
|
||||
<td className="pr-2 text-center w-6">
|
||||
{val && (
|
||||
<button
|
||||
onClick={() => setOverrideDraft(d => { const n = { ...d }; delete n[col]; return n })}
|
||||
className="text-gray-300 hover:text-red-400 leading-none text-base">×</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{extraCols.map((col, i) => {
|
||||
const val = overrideDraft[col] ?? ''
|
||||
const suggestions = [...(globalValues[col] || [])].sort()
|
||||
return (
|
||||
<tr key={`extra-${i}`} className="border-t border-gray-50">
|
||||
<td className="px-3 py-1.5 w-28 shrink-0">
|
||||
<input
|
||||
className="w-full text-xs font-mono border border-gray-200 rounded px-1 py-0.5 focus:outline-none focus:border-blue-400"
|
||||
value={col}
|
||||
placeholder="field name"
|
||||
onChange={e => {
|
||||
const newName = e.target.value
|
||||
setExtraCols(ec => { const c = [...ec]; c[i] = newName; return c })
|
||||
if (val) setOverrideDraft(d => {
|
||||
const n = { ...d }
|
||||
delete n[col]
|
||||
if (newName) n[newName] = val
|
||||
return n
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-1 py-1.5">
|
||||
<AutocompleteInput
|
||||
className={`w-full text-xs font-mono px-2 py-0.5 rounded border focus:outline-none ${
|
||||
val ? 'border-amber-300 bg-amber-50 text-amber-800' : 'border-gray-200 text-gray-600'
|
||||
}`}
|
||||
value={val}
|
||||
onChange={v => setOverrideDraft(d => ({ ...d, [col]: v }))}
|
||||
@ -553,7 +571,7 @@ export default function Records({ source }) {
|
||||
suggestions={suggestions}
|
||||
/>
|
||||
</td>
|
||||
<td className="pr-2 text-center">
|
||||
<td className="pr-2 text-center w-6">
|
||||
{val && (
|
||||
<button
|
||||
onClick={() => setOverrideDraft(d => { const n = { ...d }; delete n[col]; return n })}
|
||||
@ -567,7 +585,7 @@ export default function Records({ source }) {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 px-3 py-2 border-t border-gray-100">
|
||||
<div className="flex gap-2 px-3 py-2 border-t border-gray-100 shrink-0">
|
||||
<button
|
||||
onClick={handleSaveOverrides}
|
||||
disabled={panelSaving || !isDirty}
|
||||
|
||||
@ -675,7 +675,7 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql
|
||||
|
||||
// ── Main page ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function Stacks({ sources, onStackStale, onStackViewGenerated }) {
|
||||
export default function Stacks({ sources, onStackStale, onStackViewGenerated, onStacksChange }) {
|
||||
const [stacks, setStacks] = useState([])
|
||||
const [selected, setSelected] = useState(null)
|
||||
const [stackDetail, setStackDetail] = useState(null)
|
||||
@ -716,6 +716,7 @@ export default function Stacks({ sources, onStackStale, onStackViewGenerated })
|
||||
await api.createStack({ name: newName, fields: [] })
|
||||
setNewName(''); setCreating(false)
|
||||
await load()
|
||||
onStacksChange?.()
|
||||
loadDetail(newName)
|
||||
} catch (e) { setError(e.message) }
|
||||
}
|
||||
@ -725,6 +726,7 @@ export default function Stacks({ sources, onStackStale, onStackViewGenerated })
|
||||
await api.deleteStack(name)
|
||||
if (selected === name) { setSelected(null); setStackDetail(null); setSqlDraft(''); setSqlResult(null) }
|
||||
load()
|
||||
onStacksChange?.()
|
||||
}
|
||||
|
||||
async function runSql() {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user