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 [authed, setAuthed] = useState(false)
|
||||||
const [loginUser, setLoginUser] = useState('')
|
const [loginUser, setLoginUser] = useState('')
|
||||||
const [sources, setSources] = useState([])
|
const [sources, setSources] = useState([])
|
||||||
|
const [stacks, setStacks] = useState([])
|
||||||
const [source, setSource] = useState(() => localStorage.getItem('selectedSource') || '')
|
const [source, setSource] = useState(() => localStorage.getItem('selectedSource') || '')
|
||||||
|
const [selectedStack, setSelectedStack] = useState(null)
|
||||||
const [sidebarExpanded, setSidebarExpanded] = useState(() => localStorage.getItem('df_sidebar') !== 'collapsed')
|
const [sidebarExpanded, setSidebarExpanded] = useState(() => localStorage.getItem('df_sidebar') !== 'collapsed')
|
||||||
// Sets of names whose dfv view is out of sync with current definitions
|
// Sets of names whose dfv view is out of sync with current definitions
|
||||||
const [staleSources, setStaleSources] = useState(new Set())
|
const [staleSources, setStaleSources] = useState(new Set())
|
||||||
@ -28,14 +30,14 @@ export default function App() {
|
|||||||
|
|
||||||
async function handleLogin(user, pass) {
|
async function handleLogin(user, pass) {
|
||||||
setCredentials(user, pass)
|
setCredentials(user, pass)
|
||||||
await api.getSources().then(s => {
|
const s = await api.getSources()
|
||||||
sessionStorage.setItem('df_user', user)
|
sessionStorage.setItem('df_user', user)
|
||||||
sessionStorage.setItem('df_pass', pass)
|
sessionStorage.setItem('df_pass', pass)
|
||||||
setSources(s)
|
setSources(s)
|
||||||
if (!source && s.length > 0) setSource(s[0].name)
|
if (!source && s.length > 0) setSource(s[0].name)
|
||||||
setAuthed(true)
|
setAuthed(true)
|
||||||
setLoginUser(user)
|
setLoginUser(user)
|
||||||
})
|
api.getStacks().then(setStacks).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
@ -45,11 +47,17 @@ export default function App() {
|
|||||||
setAuthed(false)
|
setAuthed(false)
|
||||||
setLoginUser('')
|
setLoginUser('')
|
||||||
setSources([])
|
setSources([])
|
||||||
|
setStacks([])
|
||||||
|
setSelectedStack(null)
|
||||||
setStaleSources(new Set())
|
setStaleSources(new Set())
|
||||||
setStaleStacks(new Set())
|
setStaleStacks(new Set())
|
||||||
setReprocessSources(new Set())
|
setReprocessSources(new Set())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function refreshStacks() {
|
||||||
|
api.getStacks().then(setStacks).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
// Load initial stale state from DB once on login
|
// Load initial stale state from DB once on login
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authed) return
|
if (!authed) return
|
||||||
@ -128,7 +136,10 @@ export default function App() {
|
|||||||
|
|
||||||
{/* Main */}
|
{/* Main */}
|
||||||
<div className="flex-1 overflow-hidden flex flex-col min-w-0">
|
<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) && (
|
{(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">
|
<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="/mappings" element={<Mappings source={source} onNeedsReprocess={markNeedsReprocess} />} />
|
||||||
<Route path="/remap" element={<Remap />} />
|
<Route path="/remap" element={<Remap />} />
|
||||||
<Route path="/records" element={<Records source={source} />} />
|
<Route path="/records" element={<Records source={source} />} />
|
||||||
<Route path="/pivot" element={<Pivot source={source} />} />
|
<Route path="/pivot" element={<Pivot source={source} selectedStack={selectedStack} setSelectedStack={setSelectedStack} />} />
|
||||||
<Route path="/stacks" element={<Stacks sources={sources} onStackStale={markStackStale} onStackViewGenerated={clearStackStale} />} />
|
<Route path="/stacks" element={<Stacks sources={sources} onStackStale={markStackStale} onStackViewGenerated={clearStackStale} onStacksChange={refreshStacks} />} />
|
||||||
<Route path="/log" element={<Log />} />
|
<Route path="/log" element={<Log />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { NavLink } from 'react-router-dom'
|
import { NavLink } from 'react-router-dom'
|
||||||
import useTheme from '../theme.jsx'
|
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()
|
const { dark, setDark } = useTheme()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -23,6 +23,26 @@ export default function StatusBar({ sources = [], source, setSource }) {
|
|||||||
title="New source"
|
title="New source"
|
||||||
>+</NavLink>
|
>+</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">
|
<div className="ml-auto">
|
||||||
<button
|
<button
|
||||||
onClick={() => setDark(d => !d)}
|
onClick={() => setDark(d => !d)}
|
||||||
|
|||||||
@ -81,7 +81,7 @@ const LAYOUT_KEY = (source) => `psp_layout_${source}`
|
|||||||
const DEFAULT_PLUGIN_CONFIG = { edit_mode: 'SELECT_REGION' }
|
const DEFAULT_PLUGIN_CONFIG = { edit_mode: 'SELECT_REGION' }
|
||||||
|
|
||||||
|
|
||||||
export default function Pivot({ source }) {
|
export default function Pivot({ source, selectedStack, setSelectedStack }) {
|
||||||
const { dark } = useTheme()
|
const { dark } = useTheme()
|
||||||
const viewerRef = useRef()
|
const viewerRef = useRef()
|
||||||
const workerRef = useRef()
|
const workerRef = useRef()
|
||||||
@ -99,25 +99,13 @@ export default function Pivot({ source }) {
|
|||||||
const [sortCol, setSortCol] = useState(null)
|
const [sortCol, setSortCol] = useState(null)
|
||||||
const [sortDir, setSortDir] = useState('asc')
|
const [sortDir, setSortDir] = useState('asc')
|
||||||
|
|
||||||
// View selector: source or a stack
|
const selectedView = selectedStack ?? source
|
||||||
const [stacks, setStacks] = useState([])
|
const viewType = selectedStack ? 'stack' : 'source'
|
||||||
const [selectedView, setSelectedView] = useState(source) // name of active dfv view
|
|
||||||
const [viewType, setViewType] = useState('source') // 'source' | 'stack'
|
|
||||||
|
|
||||||
useEffect(() => { api.getStacks().then(setStacks).catch(() => {}) }, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (viewerRef.current) viewerRef.current.setAttribute('theme', dark ? 'Pro Dark' : 'Pro Light')
|
if (viewerRef.current) viewerRef.current.setAttribute('theme', dark ? 'Pro Dark' : 'Pro Light')
|
||||||
}, [dark])
|
}, [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)
|
// Named layouts — stacks use localStorage only (no server FK to sources)
|
||||||
const [layouts, setLayouts] = useState([])
|
const [layouts, setLayouts] = useState([])
|
||||||
const [activeLayoutId, setActiveLayoutId] = useState(null)
|
const [activeLayoutId, setActiveLayoutId] = useState(null)
|
||||||
@ -138,7 +126,7 @@ export default function Pivot({ source }) {
|
|||||||
: await api.getStackPivotLayouts(selectedView)
|
: await api.getStackPivotLayouts(selectedView)
|
||||||
setLayouts(rows)
|
setLayouts(rows)
|
||||||
} catch {}
|
} catch {}
|
||||||
}, [selectedView, viewType])
|
}, [selectedView])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedView) return
|
if (!selectedView) return
|
||||||
@ -442,29 +430,12 @@ export default function Pivot({ source }) {
|
|||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex flex-col">
|
<div className="w-full h-full flex flex-col">
|
||||||
|
|
||||||
{/* Layout toolbar */}
|
{/* Layouts sub-bar */}
|
||||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-white border-b border-gray-200 flex-shrink-0">
|
<div className="flex items-center gap-2 px-3 h-9 bg-white border-b border-gray-200 shrink-0 text-xs">
|
||||||
|
|
||||||
{/* 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.map(l => (
|
{layouts.map(l => (
|
||||||
<div key={l.id}
|
<div key={l.id}
|
||||||
onClick={() => applyLayout(l)}
|
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
|
${activeLayoutId === l.id
|
||||||
? 'bg-blue-50 border-blue-300 text-blue-700'
|
? 'bg-blue-50 border-blue-300 text-blue-700'
|
||||||
: 'bg-white border-gray-200 text-gray-600 hover:border-gray-400'}`}>
|
: 'bg-white border-gray-200 text-gray-600 hover:border-gray-400'}`}>
|
||||||
@ -477,7 +448,7 @@ export default function Pivot({ source }) {
|
|||||||
|
|
||||||
{activeLayoutId !== null && !showSaveAs && (
|
{activeLayoutId !== null && !showSaveAs && (
|
||||||
<button onClick={handleSaveOver}
|
<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
|
Save
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@ -490,30 +461,27 @@ export default function Pivot({ source }) {
|
|||||||
onChange={e => setSaveAsName(e.target.value)}
|
onChange={e => setSaveAsName(e.target.value)}
|
||||||
onKeyDown={e => { if (e.key === 'Enter') handleSaveAs(); if (e.key === 'Escape') { setShowSaveAs(false); setSaveAsName('') } }}
|
onKeyDown={e => { if (e.key === 'Enter') handleSaveAs(); if (e.key === 'Escape') { setShowSaveAs(false); setSaveAsName('') } }}
|
||||||
placeholder="Layout name…"
|
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={handleSaveAs} className="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={() => { setShowSaveAs(false); setSaveAsName('') }} className="text-gray-400 hover:text-gray-600 px-1">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowSaveAs(true)}
|
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…
|
+ Save as…
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeLayoutId !== null && (
|
{activeLayoutId !== null && (
|
||||||
<button onClick={handleResetToDefault}
|
<button onClick={handleResetToDefault} className="text-gray-300 hover:text-gray-500 ml-1">reset</button>
|
||||||
className="text-xs 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">
|
<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 => (
|
{[0, 1, 2, 3].map(d => (
|
||||||
<button key={d} onClick={async () => {
|
<button key={d} onClick={async () => {
|
||||||
const v = viewerRef.current; if (!v) return
|
const v = viewerRef.current; if (!v) return
|
||||||
@ -522,7 +490,7 @@ export default function Pivot({ source }) {
|
|||||||
const p = await v.getPlugin()
|
const p = await v.getPlugin()
|
||||||
await p.draw(view)
|
await p.draw(view)
|
||||||
expandDepthRef.current = d
|
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}
|
{d}
|
||||||
</button>
|
</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 displayCols = (rows.length > 0 ? Object.keys(rows[0]) : cols).filter(c => !HIDDEN_COLS.has(c))
|
||||||
const visCols = 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])]
|
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 savedOverrides = selectedRecord?.overrides || {}
|
||||||
const isDirty = Object.values(overrideDraft).some(v => String(v).trim())
|
const isDirty = Object.values(overrideDraft).some(v => String(v).trim())
|
||||||
@ -493,43 +496,61 @@ export default function Records({ source }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Read-only transformed fields */}
|
<div className="flex items-center justify-between px-3 py-1.5 bg-gray-50 border-b border-gray-100 shrink-0">
|
||||||
<div className="border-b border-gray-100">
|
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">Fields</span>
|
||||||
{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>
|
|
||||||
|
|
||||||
{/* 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
|
<button
|
||||||
onClick={() => setExtraCols(ec => [...ec, ''])}
|
onClick={() => setExtraCols(ec => [...ec, ''])}
|
||||||
className="text-gray-400 hover:text-gray-700 font-medium text-sm leading-none"
|
className="text-gray-400 hover:text-gray-700 font-medium text-sm leading-none"
|
||||||
title="Add column">+</button>
|
title="Add field">+</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
<table className="w-full text-xs">
|
<table className="w-full text-xs">
|
||||||
<tbody>
|
<tbody>
|
||||||
{allOverrideCols.map((col, idx) => {
|
{knownCols.map(col => {
|
||||||
const isExtra = idx >= overrideCols.length
|
|
||||||
const suggestions = [...(globalValues[col] || [])].sort()
|
|
||||||
const val = overrideDraft[col] ?? ''
|
const val = overrideDraft[col] ?? ''
|
||||||
|
const placeholder = formatVal(selectedRecord.transformed?.[col]) ?? ''
|
||||||
|
const suggestions = [...(globalValues[col] || [])].sort()
|
||||||
return (
|
return (
|
||||||
<tr key={col || `extra-${idx}`} className="border-t border-gray-50">
|
<tr key={col} className="border-t border-gray-50">
|
||||||
<td className="px-3 py-1 w-28 shrink-0">
|
<td className="px-3 py-1.5 w-28 shrink-0">
|
||||||
{isExtra ? (
|
<span className="font-mono text-gray-500 truncate block">{col}</span>
|
||||||
|
</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}
|
||||||
|
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
|
<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"
|
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}
|
value={col}
|
||||||
placeholder="field name"
|
placeholder="field name"
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
const newName = e.target.value
|
const newName = e.target.value
|
||||||
setExtraCols(ec => { const c = [...ec]; c[idx - overrideCols.length] = newName; return c })
|
setExtraCols(ec => { const c = [...ec]; c[i] = newName; return c })
|
||||||
if (val) setOverrideDraft(d => {
|
if (val) setOverrideDraft(d => {
|
||||||
const n = { ...d }
|
const n = { ...d }
|
||||||
delete n[col]
|
delete n[col]
|
||||||
@ -538,14 +559,11 @@ export default function Records({ source }) {
|
|||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<span className="font-mono text-gray-500 truncate block">{col}</span>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-1 py-1">
|
<td className="px-1 py-1.5">
|
||||||
<AutocompleteInput
|
<AutocompleteInput
|
||||||
className={`w-full text-xs font-mono px-2 py-0.5 rounded border focus:outline-none ${
|
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}
|
value={val}
|
||||||
onChange={v => setOverrideDraft(d => ({ ...d, [col]: v }))}
|
onChange={v => setOverrideDraft(d => ({ ...d, [col]: v }))}
|
||||||
@ -553,7 +571,7 @@ export default function Records({ source }) {
|
|||||||
suggestions={suggestions}
|
suggestions={suggestions}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="pr-2 text-center">
|
<td className="pr-2 text-center w-6">
|
||||||
{val && (
|
{val && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setOverrideDraft(d => { const n = { ...d }; delete n[col]; return n })}
|
onClick={() => setOverrideDraft(d => { const n = { ...d }; delete n[col]; return n })}
|
||||||
@ -567,7 +585,7 @@ export default function Records({ source }) {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</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
|
<button
|
||||||
onClick={handleSaveOverrides}
|
onClick={handleSaveOverrides}
|
||||||
disabled={panelSaving || !isDirty}
|
disabled={panelSaving || !isDirty}
|
||||||
|
|||||||
@ -675,7 +675,7 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql
|
|||||||
|
|
||||||
// ── Main page ──────────────────────────────────────────────────────────────────
|
// ── Main page ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function Stacks({ sources, onStackStale, onStackViewGenerated }) {
|
export default function Stacks({ sources, onStackStale, onStackViewGenerated, onStacksChange }) {
|
||||||
const [stacks, setStacks] = useState([])
|
const [stacks, setStacks] = useState([])
|
||||||
const [selected, setSelected] = useState(null)
|
const [selected, setSelected] = useState(null)
|
||||||
const [stackDetail, setStackDetail] = useState(null)
|
const [stackDetail, setStackDetail] = useState(null)
|
||||||
@ -716,6 +716,7 @@ export default function Stacks({ sources, onStackStale, onStackViewGenerated })
|
|||||||
await api.createStack({ name: newName, fields: [] })
|
await api.createStack({ name: newName, fields: [] })
|
||||||
setNewName(''); setCreating(false)
|
setNewName(''); setCreating(false)
|
||||||
await load()
|
await load()
|
||||||
|
onStacksChange?.()
|
||||||
loadDetail(newName)
|
loadDetail(newName)
|
||||||
} catch (e) { setError(e.message) }
|
} catch (e) { setError(e.message) }
|
||||||
}
|
}
|
||||||
@ -725,6 +726,7 @@ export default function Stacks({ sources, onStackStale, onStackViewGenerated })
|
|||||||
await api.deleteStack(name)
|
await api.deleteStack(name)
|
||||||
if (selected === name) { setSelected(null); setStackDetail(null); setSqlDraft(''); setSqlResult(null) }
|
if (selected === name) { setSelected(null); setStackDetail(null); setSqlDraft(''); setSqlResult(null) }
|
||||||
load()
|
load()
|
||||||
|
onStacksChange?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runSql() {
|
async function runSql() {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user