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:
Paul Trowbridge 2026-05-03 10:35:34 -04:00
parent 9e0fa4aa7e
commit 1baadaca61
5 changed files with 127 additions and 108 deletions

View File

@ -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>

View File

@ -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)}

View File

@ -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>
))}

View File

@ -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}

View File

@ -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() {