diff --git a/api/routes/sources.js b/api/routes/sources.js index 587c56e..271ad2c 100644 --- a/api/routes/sources.js +++ b/api/routes/sources.js @@ -230,5 +230,32 @@ module.exports = (pool) => { } }); + // Pivot layouts + router.get('/:name/layouts', async (req, res, next) => { + try { + const result = await pool.query(`SELECT * FROM list_pivot_layouts(${lit(req.params.name)})`); + res.json(result.rows); + } catch (err) { next(err); } + }); + + router.post('/:name/layouts', async (req, res, next) => { + try { + const { layout_name, config } = req.body; + if (!layout_name || !config) return res.status(400).json({ error: 'layout_name and config required' }); + const result = await pool.query( + `SELECT * FROM save_pivot_layout(${lit(req.params.name)}, ${lit(layout_name)}, ${lit(config)})` + ); + res.json(result.rows[0]); + } catch (err) { next(err); } + }); + + router.delete('/:name/layouts/:id', async (req, res, next) => { + try { + const result = await pool.query(`SELECT * FROM delete_pivot_layout(${lit(parseInt(req.params.id))})`); + if (result.rows.length === 0) return res.status(404).json({ error: 'Layout not found' }); + res.json({ success: true }); + } catch (err) { next(err); } + }); + return router; }; diff --git a/database/queries/sources.sql b/database/queries/sources.sql index f357a58..7b2cefd 100644 --- a/database/queries/sources.sql +++ b/database/queries/sources.sql @@ -196,3 +196,28 @@ BEGIN RETURN json_build_object('success', true, 'view', v_view, 'sql', v_sql); END; $$ LANGUAGE plpgsql; + +-- List saved pivot layouts for a source +CREATE OR REPLACE FUNCTION list_pivot_layouts(p_source_name TEXT) +RETURNS TABLE(id INT, source_name TEXT, layout_name TEXT, config JSONB, created_at TIMESTAMPTZ) AS $$ + SELECT id, source_name, layout_name, config, created_at + FROM dataflow.pivot_layouts + WHERE source_name = p_source_name + ORDER BY layout_name; +$$ LANGUAGE sql; + +-- Save (upsert) a named pivot layout +CREATE OR REPLACE FUNCTION save_pivot_layout(p_source_name TEXT, p_layout_name TEXT, p_config JSONB) +RETURNS TABLE(id INT, source_name TEXT, layout_name TEXT, config JSONB, created_at TIMESTAMPTZ) AS $$ + INSERT INTO dataflow.pivot_layouts (source_name, layout_name, config) + VALUES (p_source_name, p_layout_name, p_config) + ON CONFLICT (source_name, layout_name) DO UPDATE + SET config = EXCLUDED.config + RETURNING id, source_name, layout_name, config, created_at; +$$ LANGUAGE sql; + +-- Delete a named pivot layout +CREATE OR REPLACE FUNCTION delete_pivot_layout(p_id INT) +RETURNS TABLE(id INT) AS $$ + DELETE FROM dataflow.pivot_layouts WHERE id = p_id RETURNING id; +$$ LANGUAGE sql; diff --git a/database/schema.sql b/database/schema.sql index 3b97619..43ac633 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -140,10 +140,21 @@ CREATE INDEX idx_import_log_source ON import_log(source_name); CREATE INDEX idx_import_log_timestamp ON import_log(imported_at); +CREATE TABLE pivot_layouts ( + id SERIAL PRIMARY KEY, + source_name TEXT NOT NULL REFERENCES sources(name) ON DELETE CASCADE, + layout_name TEXT NOT NULL, + config JSONB NOT NULL, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + UNIQUE (source_name, layout_name) +); + +CREATE INDEX idx_pivot_layouts_source ON pivot_layouts(source_name); + ------------------------------------------------------ -- Summary ------------------------------------------------------ --- Tables: 5 (sources, records, rules, mappings, import_log) +-- Tables: 6 (sources, records, rules, mappings, import_log, pivot_layouts) -- Simple, clear structure -- JSONB for flexibility -- Deduplication via hash key diff --git a/ui/src/api.js b/ui/src/api.js index 708b5fc..f8bb359 100644 --- a/ui/src/api.js +++ b/ui/src/api.js @@ -103,6 +103,11 @@ export const api = { updateMapping: (id, body) => request('PUT', `/mappings/${id}`, body), deleteMapping: (id) => request('DELETE', `/mappings/${id}`), + // Pivot layouts + getPivotLayouts: (source) => request('GET', `/sources/${source}/layouts`), + savePivotLayout: (source, layout_name, config) => request('POST', `/sources/${source}/layouts`, { layout_name, config }), + deletePivotLayout: (source, id) => request('DELETE', `/sources/${source}/layouts/${id}`), + // Records getRecords: (source, limit = 100, offset = 0) => request('GET', `/records/source/${source}?limit=${limit}&offset=${offset}`), diff --git a/ui/src/pages/Pivot.jsx b/ui/src/pages/Pivot.jsx index 28d1ef3..137b39c 100644 --- a/ui/src/pages/Pivot.jsx +++ b/ui/src/pages/Pivot.jsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState, useCallback } from 'react' import { api } from '../api' async function fetchAllRows(source) { @@ -32,7 +32,6 @@ function loadPerspective() { function formatVal(v) { if (v == null) return null - // Perspective returns dates as ms timestamps if (typeof v === 'number' && v > 1e11 && v < 2e12) { const d = new Date(v) if (!isNaN(d)) return d.toISOString().slice(0, 10) @@ -42,15 +41,10 @@ function formatVal(v) { function normalize(v) { if (v == null) return null - // Perspective returns dates as ms timestamps — convert to ISO date string if (typeof v === 'number' && v > 1e11 && v < 2e12) return new Date(v).toISOString().slice(0, 10) return String(v).trim() } -// Apply perspective-click event filters directly to raw rows. -// Each filter is [field, operator, value] — same format Perspective uses internally. -// Filters for fields that don't exist in the raw data (e.g. Perspective computed columns) -// are skipped — they can't be matched against the source rows. function filterRowsByConfig(allRows, filters) { if (!filters || filters.length === 0) return allRows const knownFields = allRows.length > 0 ? new Set(Object.keys(allRows[0])) : new Set() @@ -88,18 +82,40 @@ export default function Pivot({ source }) { const allRowsRef = useRef([]) const [status, setStatus] = useState('idle') const [error, setError] = useState('') - const [saved, setSaved] = useState(false) const [inspectedRows, setInspectedRows] = useState(null) const [clickDetail, setClickDetail] = useState(null) + // Named layouts + const [layouts, setLayouts] = useState([]) + const [activeLayoutId, setActiveLayoutId] = useState(null) + const [saveAsName, setSaveAsName] = useState('') + const [showSaveAs, setShowSaveAs] = useState(false) + const [layoutMsg, setLayoutMsg] = useState('') + + const flashMsg = (msg) => { + setLayoutMsg(msg) + setTimeout(() => setLayoutMsg(''), 2000) + } + + const loadLayouts = useCallback(async () => { + if (!source) return + try { + const rows = await api.getPivotLayouts(source) + setLayouts(rows) + } catch {} + }, [source]) + useEffect(() => { if (!source) return let cancelled = false - setSaved(false) setInspectedRows(null) setClickDetail(null) + setActiveLayoutId(null) + setShowSaveAs(false) allRowsRef.current = [] + loadLayouts() + async function init() { setStatus('loading') setError('') @@ -128,8 +144,6 @@ export default function Pivot({ source }) { const detail = e.detail || {} const { row, column_names } = detail if (!row) return - // detail.config has the cell-specific filters (group_by + split_by values + user filters) - // viewer.save() gives us the full config including group_by/split_by field names const eventFilters = (detail.config || {}).filter || [] const config = await viewer.save() setClickDetail({ row, config, column_names, eventFilters }) @@ -155,27 +169,58 @@ export default function Pivot({ source }) { return () => { cancelled = true } }, [source]) - async function saveLayout() { + async function applyLayout(layout) { const viewer = viewerRef.current if (!viewer) return - const layout = await viewer.save() - localStorage.setItem(LAYOUT_KEY(source), JSON.stringify(layout)) - setSaved(true) - setTimeout(() => setSaved(false), 2000) + await viewer.restore(layout.config) + setActiveLayoutId(layout.id) + // also persist to localStorage so it survives refresh + localStorage.setItem(LAYOUT_KEY(source), JSON.stringify(layout.config)) } - function clearLayout() { - localStorage.removeItem(LAYOUT_KEY(source)) + async function handleSaveAs() { + const name = saveAsName.trim() + if (!name) return const viewer = viewerRef.current - if (viewer) viewer.restore({ table: source, settings: true, plugin_config: DEFAULT_PLUGIN_CONFIG }) + if (!viewer) return + const config = await viewer.save() + try { + const saved = await api.savePivotLayout(source, name, config) + localStorage.setItem(LAYOUT_KEY(source), JSON.stringify(config)) + await loadLayouts() + setActiveLayoutId(saved.id) + setShowSaveAs(false) + setSaveAsName('') + flashMsg('Saved!') + } catch (err) { + flashMsg(err.message) + } + } + + async function handleDelete(layout, e) { + e.stopPropagation() + try { + await api.deletePivotLayout(source, layout.id) + if (activeLayoutId === layout.id) setActiveLayoutId(null) + await loadLayouts() + flashMsg('Deleted') + } catch (err) { + flashMsg(err.message) + } + } + + function handleResetToDefault() { + const viewer = viewerRef.current + if (!viewer) return + localStorage.removeItem(LAYOUT_KEY(source)) + setActiveLayoutId(null) + viewer.restore({ table: source, settings: true, plugin_config: DEFAULT_PLUGIN_CONFIG }) } if (!source) return
Loading…
+ ))} + + {showSaveAs ? ( +Error: {error}
-No view data — generate a view and transform records first.
-Loading…
+Error: {error}
+No view data — generate a view and transform records first.
+| {c} | + ))} +
|---|
| + {f == null ? — : f} + | + ) + })} +
| {c} | - ))} -
|---|
| - {f == null ? — : f} - | - ) - })} -