Make a pivot layout a thing people own and publish
Named layouts lived in localStorage: invisible to anyone else, gone on the next machine, and nothing to publish from. The one server-side layout was pf.source.default_layout -- a single anonymous blob any account could overwrite for every account, which is a published layout with no owner. pf.layout replaces both. A layout is named, owned, and either private or published; published ones are listed by everyone on the forecast and writable only by their owner or an admin, the same rule pf.log already uses for its entries. Scope is the version, since that is the entry point, with version_id NULL for the source-wide default a new version inherits. Applying is never restricted -- Save is withheld on a layout that is not yours, Save as forks it -- because the guarantee wanted is that a published layout cannot be changed out from under people, not that it cannot be adapted. The toolbar's flat chip row becomes one Layout menu: Published and Mine, rename/publish/default/delete shown only where they would be allowed, and a dirty dot computed by comparing the live config against the one the pivot was applied from, since restore() fires the change event itself. Existing localStorage lists are lifted into pf.layout on first load. PUT /sources/:id/default-layout is removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7217518cfd
commit
d2ba944d41
55
CLAUDE.md
55
CLAUDE.md
@ -30,6 +30,7 @@ routes/
|
|||||||
versions.js Version CRUD, baseline/reference load, data stream
|
versions.js Version CRUD, baseline/reference load, data stream
|
||||||
operations.js scale, recode, clone, undo — the core forecast ops
|
operations.js scale, recode, clone, undo — the core forecast ops
|
||||||
log.js GET /api/versions/:id/log, DELETE /api/log/:logid
|
log.js GET /api/versions/:id/log, DELETE /api/log/:logid
|
||||||
|
layouts.js Named pivot layouts — list per version, create, patch, delete
|
||||||
lib/
|
lib/
|
||||||
sql_generator.js buildFilterClause, token substitution helpers
|
sql_generator.js buildFilterClause, token substitution helpers
|
||||||
auth.js scrypt hash/verify, requireAuth, sessionUser; `node lib/auth.js hash` CLI
|
auth.js scrypt hash/verify, requireAuth, sessionUser; `node lib/auth.js hash` CLI
|
||||||
@ -45,6 +46,7 @@ ui/src/
|
|||||||
Baseline.jsx Version management, baseline workbench, reference load
|
Baseline.jsx Version management, baseline workbench, reference load
|
||||||
Forecast.jsx Perspective pivot, selection handling, operation dispatch
|
Forecast.jsx Perspective pivot, selection handling, operation dispatch
|
||||||
components/
|
components/
|
||||||
|
LayoutMenu.jsx The Layout ▾ control — Published / Mine, with the write actions
|
||||||
OperationPanel.jsx The adjustment workbench — ledger + scale/recode/clone forms
|
OperationPanel.jsx The adjustment workbench — ledger + scale/recode/clone forms
|
||||||
BridgeView.jsx Baseline → current waterfall by tag (exports buildSteps/layoutSteps)
|
BridgeView.jsx Baseline → current waterfall by tag (exports buildSteps/layoutSteps)
|
||||||
Sidebar.jsx 3-step collapsible nav
|
Sidebar.jsx 3-step collapsible nav
|
||||||
@ -61,6 +63,7 @@ ui/src/
|
|||||||
- **`pf.version`** — named forecast scenarios; `exclude_iters` (default `["reference"]`) blocks those iter values from all operations
|
- **`pf.version`** — named forecast scenarios; `exclude_iters` (default `["reference"]`) blocks those iter values from all operations
|
||||||
- **`pf.fc_{tname}_{version_id}`** — one forecast table per version; contains both operational rows (`pf_iter = baseline|scale|recode|clone`) and reference rows (`pf_iter = reference`). Indexed on `pf_logid`, which is how undo, the change-log aggregate and the grain key all find their rows — without it each is a sequential scan of the whole table. Tables created before that index was added do not have it.
|
- **`pf.fc_{tname}_{version_id}`** — one forecast table per version; contains both operational rows (`pf_iter = baseline|scale|recode|clone`) and reference rows (`pf_iter = reference`). Indexed on `pf_logid`, which is how undo, the change-log aggregate and the grain key all find their rows — without it each is a sequential scan of the whole table. Tables created before that index was added do not have it.
|
||||||
- **`pf.log`** — audit log; every write gets one entry; `slice` + `params` stored as jsonb
|
- **`pf.log`** — audit log; every write gets one entry; `slice` + `params` stored as jsonb
|
||||||
|
- **`pf.layout`** — named Perspective view configs; see §Pivot layouts
|
||||||
- **`pf.sql`** — generated SQL templates per source/operation; tokens substituted at request time
|
- **`pf.sql`** — generated SQL templates per source/operation; tokens substituted at request time
|
||||||
- **`pf.app_user`** — login accounts; scrypt `pass_hash`, `is_active`, `last_login_at`
|
- **`pf.app_user`** — login accounts; scrypt `pass_hash`, `is_active`, `last_login_at`
|
||||||
- **`pf.session`** — express-session store (connect-pg-simple layout)
|
- **`pf.session`** — express-session store (connect-pg-simple layout)
|
||||||
@ -313,6 +316,58 @@ together. `columns` selects which *measures* appear, not individual split
|
|||||||
combinations.
|
combinations.
|
||||||
|
|
||||||
|
|
||||||
|
## Pivot layouts
|
||||||
|
|
||||||
|
A layout is a named `ViewConfig`, stored in **`pf.layout`** and owned by an
|
||||||
|
account. Two kinds, one table:
|
||||||
|
|
||||||
|
- **published** — everyone on the forecast lists it and can apply it; only its
|
||||||
|
owner or an admin may change it
|
||||||
|
- **private** — yours, nobody else lists it
|
||||||
|
|
||||||
|
Scope is the **version** by default, because that is where people enter the app.
|
||||||
|
`version_id IS NULL` means the layout applies to every version of the source;
|
||||||
|
that is where the old source default went, and what a brand-new version picks up
|
||||||
|
before anyone has published anything for it. `is_default` (at most one per scope,
|
||||||
|
by partial unique index) is what `initViewer()` restores on a first load — a
|
||||||
|
version-scoped default beating a source-wide one, the narrower answer winning.
|
||||||
|
|
||||||
|
**Permissions are the `pf.log` rule verbatim** — your own, or an admin's
|
||||||
|
override, and the UI greys out the rest rather than offering a click that answers
|
||||||
|
403. `can_edit` rides on every row so the menu knows which. Applying is never
|
||||||
|
restricted: the guarantee is that a published layout cannot be *changed* out from
|
||||||
|
under people, not that it cannot be adapted — Save is withheld on a layout that
|
||||||
|
isn't yours, Save as… forks it into your own.
|
||||||
|
|
||||||
|
**No territory clause.** A layout is display config, and territory restricts rows,
|
||||||
|
not columns; `cleanLayout()` already drops anything the live schema lacks.
|
||||||
|
|
||||||
|
**What is still local.** `LAYOUT_KEY` (`pf_layout_v{vid}`) — the unnamed
|
||||||
|
last-used config — stays in `localStorage`, because it is per-browser session
|
||||||
|
continuity rather than a thing anyone names or shares. `LAYOUTS_KEY`
|
||||||
|
(`pf_layouts_v{vid}`) is the old named list; `loadLayouts()` lifts it into
|
||||||
|
`pf.layout` as private rows once per version and then clears the key.
|
||||||
|
|
||||||
|
**The dirty dot is a comparison, not a flag.** `restore()` itself fires
|
||||||
|
`perspective-config-update`, so anything set unconditionally in that handler
|
||||||
|
would light up the moment a layout was applied. `activeConfigRef` holds what the
|
||||||
|
pivot last matched and `sameConfig()` compares against it, ignoring `table`
|
||||||
|
(the per-load table name, different on every refresh).
|
||||||
|
|
||||||
|
**What this replaced.** Named layouts lived only in `localStorage` — invisible to
|
||||||
|
anyone else, gone on another machine. The one server-side layout was
|
||||||
|
`pf.source.default_layout`, a single anonymous blob that `PUT
|
||||||
|
/sources/:id/default-layout` let *any* account overwrite for *every* account: a
|
||||||
|
published layout with no owner. That route is gone; the column remains, migrated
|
||||||
|
and read by nothing.
|
||||||
|
|
||||||
|
**Known gap:** `cleanLayout()` still does not guard `aggregates`
|
||||||
|
(PERSPECTIVE.md §5). Harmless while `save()` emits `aggregates: {}`, but a
|
||||||
|
published layout that adopts the weighted-mean pattern and then loses a column
|
||||||
|
would break the restore for everyone rather than for one browser.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Slice mechanics
|
## Slice mechanics
|
||||||
|
|
||||||
When the user clicks a pivot cell, `perspective-click` fires. The handler in `Forecast.jsx` extracts `[col, '==', value]` filters from `detail.config.filter` — only `role = dimension` and `role = date` columns are kept as the slice. A plain click replaces the selection; ctrl/⌘/shift-click toggles a slice in or out of it, so the panel holds a **list** of slices sent as `slices` in operation POST bodies (the single `slice` object is still accepted server-side).
|
When the user clicks a pivot cell, `perspective-click` fires. The handler in `Forecast.jsx` extracts `[col, '==', value]` filters from `detail.config.filter` — only `role = dimension` and `role = date` columns are kept as the slice. A plain click replaces the selection; ctrl/⌘/shift-click toggles a slice in or out of it, so the panel holds a **list** of slices sent as `slices` in operation POST bodies (the single `slice` object is still accepted server-side).
|
||||||
|
|||||||
193
routes/layouts.js
Normal file
193
routes/layouts.js
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { sessionUser } = require('../lib/auth');
|
||||||
|
|
||||||
|
// Named Perspective view configs. Two kinds, one table:
|
||||||
|
//
|
||||||
|
// private — yours, nobody else lists it
|
||||||
|
// published — everyone on the version sees it; only the owner or an admin
|
||||||
|
// may change it, the same rule pf.log uses for its entries
|
||||||
|
//
|
||||||
|
// Scope is the version by default, because that is where people enter the app.
|
||||||
|
// A layout with version_id NULL applies to every version of the source, which
|
||||||
|
// is where the old pf.source.default_layout went and what a brand-new version
|
||||||
|
// picks up before anyone has published anything for it.
|
||||||
|
module.exports = function(pool) {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const SELECT_COLS = `id, source_id, version_id, name, config, owner,
|
||||||
|
visibility, is_default, created_at, updated_at`;
|
||||||
|
|
||||||
|
// What the client needs to know about a row it cannot write, so the UI can
|
||||||
|
// grey the control out rather than offer a click that answers 403.
|
||||||
|
const decorate = (row, req) => ({
|
||||||
|
...row,
|
||||||
|
scope: row.version_id == null ? 'source' : 'version',
|
||||||
|
can_edit: !!req.session?.user?.is_admin || row.owner === sessionUser(req)
|
||||||
|
});
|
||||||
|
|
||||||
|
// The owner/admin gate, shared by every write. Returns the row, or sends the
|
||||||
|
// response itself and returns null.
|
||||||
|
async function ownedLayout(req, res) {
|
||||||
|
const id = parseInt(req.params.lid);
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT ${SELECT_COLS} FROM pf.layout WHERE id = $1`, [id]
|
||||||
|
);
|
||||||
|
if (!rows.length) { res.status(404).json({ error: 'Layout not found' }); return null; }
|
||||||
|
const row = rows[0];
|
||||||
|
if (!req.session?.user?.is_admin && row.owner !== sessionUser(req)) {
|
||||||
|
res.status(403).json({
|
||||||
|
error: `“${row.name}” belongs to ${row.owner} — only they or an administrator can change it`
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything applicable to a version: its own published layouts, the
|
||||||
|
// source-wide published ones, and the caller's own private layouts at either
|
||||||
|
// scope. Deliberately not territory-filtered — a layout is display config,
|
||||||
|
// and territory restricts rows, not columns.
|
||||||
|
router.get('/versions/:id/layouts', async (req, res) => {
|
||||||
|
const versionId = parseInt(req.params.id);
|
||||||
|
try {
|
||||||
|
const ver = await pool.query(
|
||||||
|
`SELECT source_id FROM pf.version WHERE id = $1`, [versionId]
|
||||||
|
);
|
||||||
|
if (!ver.rows.length) return res.status(404).json({ error: 'Version not found' });
|
||||||
|
const sourceId = ver.rows[0].source_id;
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT ${SELECT_COLS}
|
||||||
|
FROM pf.layout
|
||||||
|
WHERE TRUE
|
||||||
|
AND source_id = $1
|
||||||
|
AND (version_id = $2 OR version_id IS NULL)
|
||||||
|
AND (visibility = 'published' OR owner = $3)
|
||||||
|
ORDER BY visibility DESC, is_default DESC, lower(name)`,
|
||||||
|
[sourceId, versionId, sessionUser(req)]
|
||||||
|
);
|
||||||
|
res.json(rows.map(r => decorate(r, req)));
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create. scope 'source' saves it against every version of the source;
|
||||||
|
// anything else is this version.
|
||||||
|
router.post('/versions/:id/layouts', async (req, res) => {
|
||||||
|
const versionId = parseInt(req.params.id);
|
||||||
|
const { name, config, visibility = 'private', scope = 'version' } = req.body || {};
|
||||||
|
if (!name || !String(name).trim()) return res.status(400).json({ error: 'Name is required' });
|
||||||
|
if (!config) return res.status(400).json({ error: 'Config is required' });
|
||||||
|
if (!['private', 'published'].includes(visibility)) {
|
||||||
|
return res.status(400).json({ error: 'visibility must be private or published' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const ver = await pool.query(
|
||||||
|
`SELECT source_id FROM pf.version WHERE id = $1`, [versionId]
|
||||||
|
);
|
||||||
|
if (!ver.rows.length) return res.status(404).json({ error: 'Version not found' });
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`INSERT INTO pf.layout (source_id, version_id, name, config, owner, visibility)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING ${SELECT_COLS}`,
|
||||||
|
[ver.rows[0].source_id, scope === 'source' ? null : versionId,
|
||||||
|
String(name).trim(), config, sessionUser(req), visibility]
|
||||||
|
);
|
||||||
|
res.json(decorate(rows[0], req));
|
||||||
|
} catch (err) {
|
||||||
|
// the partial unique indexes, reported in the terms the user typed
|
||||||
|
if (err.code === '23505') {
|
||||||
|
return res.status(409).json({ error: `A layout named “${name}” already exists here` });
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update any of name / config / visibility / is_default. Each is optional;
|
||||||
|
// a missing key leaves the column alone, which is why the flag-and-value
|
||||||
|
// pair is used rather than COALESCE on the value.
|
||||||
|
router.patch('/layouts/:lid', async (req, res) => {
|
||||||
|
const { name, config, visibility, is_default, scope } = req.body || {};
|
||||||
|
if (name === undefined && config === undefined && visibility === undefined
|
||||||
|
&& is_default === undefined && scope === undefined) {
|
||||||
|
return res.status(400).json({ error: 'Nothing to update' });
|
||||||
|
}
|
||||||
|
if (visibility !== undefined && !['private', 'published'].includes(visibility)) {
|
||||||
|
return res.status(400).json({ error: 'visibility must be private or published' });
|
||||||
|
}
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
const row = await ownedLayout(req, res);
|
||||||
|
if (!row) return;
|
||||||
|
|
||||||
|
// Unpublishing a default would leave a default nobody can see, which
|
||||||
|
// the table's CHECK refuses; clear the flag with it rather than
|
||||||
|
// failing on a constraint the user never mentioned.
|
||||||
|
const clearsDefault = visibility === 'private';
|
||||||
|
|
||||||
|
// One default per scope, enforced by a partial unique index. Stand
|
||||||
|
// the others down first rather than letting the update collide --
|
||||||
|
// and in one transaction, or a name clash on the second statement
|
||||||
|
// leaves the version with no default at all.
|
||||||
|
const target_version = scope === undefined ? row.version_id
|
||||||
|
: (scope === 'source' ? null : row.version_id);
|
||||||
|
await client.query('BEGIN');
|
||||||
|
if (is_default === true) {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE pf.layout SET is_default = false
|
||||||
|
WHERE TRUE
|
||||||
|
AND source_id = $1
|
||||||
|
AND COALESCE(version_id, 0) = COALESCE($2::int, 0)
|
||||||
|
AND id <> $3
|
||||||
|
AND is_default`,
|
||||||
|
[row.source_id, target_version, row.id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows } = await client.query(
|
||||||
|
`UPDATE pf.layout SET
|
||||||
|
name = CASE WHEN $2::bool THEN $3::text ELSE name END,
|
||||||
|
config = CASE WHEN $4::bool THEN $5::jsonb ELSE config END,
|
||||||
|
visibility = CASE WHEN $6::bool THEN $7::text ELSE visibility END,
|
||||||
|
is_default = CASE WHEN $10::bool THEN false
|
||||||
|
WHEN $8::bool THEN $9::bool ELSE is_default END,
|
||||||
|
version_id = CASE WHEN $11::bool THEN $12::int ELSE version_id END,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING ${SELECT_COLS}`,
|
||||||
|
[row.id,
|
||||||
|
name !== undefined, name !== undefined ? String(name).trim() : null,
|
||||||
|
config !== undefined, config !== undefined ? config : null,
|
||||||
|
visibility !== undefined, visibility !== undefined ? visibility : null,
|
||||||
|
is_default !== undefined, is_default === true,
|
||||||
|
clearsDefault,
|
||||||
|
scope !== undefined, target_version]
|
||||||
|
);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
res.json(decorate(rows[0], req));
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK').catch(() => {});
|
||||||
|
if (err.code === '23505') {
|
||||||
|
return res.status(409).json({ error: 'A layout with that name already exists here' });
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/layouts/:lid', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const row = await ownedLayout(req, res);
|
||||||
|
if (!row) return;
|
||||||
|
await pool.query(`DELETE FROM pf.layout WHERE id = $1`, [row.id]);
|
||||||
|
res.json({ deleted: row.id });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
};
|
||||||
@ -449,23 +449,12 @@ module.exports = function(pool) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// set or clear the default Perspective layout for a source.
|
// PUT /sources/:id/default-layout is gone. It wrote pf.source.default_layout,
|
||||||
// Body: a Perspective view config (group_by, split_by, columns, plugin_config, …).
|
// one anonymous blob per source that any account could overwrite for every
|
||||||
// Pass null or {} to clear.
|
// other account -- a published layout with no owner. Its successor is
|
||||||
router.put('/sources/:id/default-layout', async (req, res) => {
|
// pf.layout: named, owned, and writable only by its owner or an admin. The
|
||||||
try {
|
// old column is left in place, already migrated into pf.layout by
|
||||||
const layout = req.body && Object.keys(req.body).length > 0 ? req.body : null;
|
// setup_sql/01_schema.sql, and read by nothing.
|
||||||
const result = await pool.query(
|
|
||||||
`UPDATE pf.source SET default_layout = $1 WHERE id = $2 RETURNING *`,
|
|
||||||
[layout, req.params.id]
|
|
||||||
);
|
|
||||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Source not found' });
|
|
||||||
res.json(result.rows[0]);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// deregister a source — does not drop existing forecast tables
|
// deregister a source — does not drop existing forecast tables
|
||||||
router.get('/dim-period/cols', async (req, res) => {
|
router.get('/dim-period/cols', async (req, res) => {
|
||||||
|
|||||||
@ -80,6 +80,7 @@ app.use('/api', require('./routes/sources')(pool));
|
|||||||
app.use('/api', require('./routes/versions')(pool));
|
app.use('/api', require('./routes/versions')(pool));
|
||||||
app.use('/api', require('./routes/operations')(pool));
|
app.use('/api', require('./routes/operations')(pool));
|
||||||
app.use('/api', require('./routes/log')(pool));
|
app.use('/api', require('./routes/log')(pool));
|
||||||
|
app.use('/api', require('./routes/layouts')(pool));
|
||||||
|
|
||||||
|
|
||||||
const port = process.env.PORT || 3010;
|
const port = process.env.PORT || 3010;
|
||||||
|
|||||||
@ -203,3 +203,61 @@ CREATE TABLE IF NOT EXISTS pf.sql (
|
|||||||
generated_at timestamptz NOT NULL DEFAULT now(),
|
generated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
UNIQUE (source_id, operation)
|
UNIQUE (source_id, operation)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- pf.layout: named Perspective view configs.
|
||||||
|
--
|
||||||
|
-- Replaces the per-browser localStorage lists (pf_layouts_v*) and the single
|
||||||
|
-- anonymous pf.source.default_layout. A layout is either `private` -- visible
|
||||||
|
-- only to its owner -- or `published`, visible to everyone on the version and
|
||||||
|
-- writable only by its owner or an admin, the same rule pf.log already uses.
|
||||||
|
CREATE TABLE IF NOT EXISTS pf.layout (
|
||||||
|
id serial PRIMARY KEY,
|
||||||
|
source_id integer NOT NULL REFERENCES pf.source(id) ON DELETE CASCADE,
|
||||||
|
-- null = applies to every version of the source; that is where the old
|
||||||
|
-- source default lives, and what a new version picks up before anyone has
|
||||||
|
-- published a layout of its own.
|
||||||
|
version_id integer REFERENCES pf.version(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL,
|
||||||
|
config jsonb NOT NULL,
|
||||||
|
owner text NOT NULL,
|
||||||
|
visibility text NOT NULL DEFAULT 'private', -- private | published
|
||||||
|
is_default boolean NOT NULL DEFAULT false, -- applied on first load
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CHECK (visibility IN ('private', 'published')),
|
||||||
|
-- only a published layout can be the default: a private one would be a
|
||||||
|
-- default nobody else could see.
|
||||||
|
CHECK (NOT is_default OR visibility = 'published')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- COALESCE rather than the bare column: version_id is nullable, and in a unique
|
||||||
|
-- index NULLs are distinct, so source-wide rows would not be constrained at all.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS layout_private_name
|
||||||
|
ON pf.layout (source_id, COALESCE(version_id, 0), owner, lower(name))
|
||||||
|
WHERE visibility = 'private';
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS layout_published_name
|
||||||
|
ON pf.layout (source_id, COALESCE(version_id, 0), lower(name))
|
||||||
|
WHERE visibility = 'published';
|
||||||
|
|
||||||
|
-- one default per scope
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS layout_one_default
|
||||||
|
ON pf.layout (source_id, COALESCE(version_id, 0))
|
||||||
|
WHERE is_default;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS layout_lookup ON pf.layout (source_id, version_id);
|
||||||
|
|
||||||
|
-- Carry the old single source default across as a published, source-wide row.
|
||||||
|
-- Idempotent: skipped once a default exists for that source.
|
||||||
|
INSERT INTO pf.layout (source_id, version_id, name, config, owner, visibility, is_default)
|
||||||
|
SELECT s.id, NULL, 'Source default', s.default_layout, COALESCE(s.created_by, 'admin'), 'published', true
|
||||||
|
FROM pf.source s
|
||||||
|
WHERE TRUE
|
||||||
|
AND s.default_layout IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM pf.layout l
|
||||||
|
WHERE TRUE
|
||||||
|
AND l.source_id = s.id
|
||||||
|
AND l.version_id IS NULL
|
||||||
|
AND l.is_default
|
||||||
|
);
|
||||||
|
|||||||
190
ui/src/components/LayoutMenu.jsx
Normal file
190
ui/src/components/LayoutMenu.jsx
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
// The pivot layout, as one control rather than a row of equal chips.
|
||||||
|
//
|
||||||
|
// Two lists, because a layout is one of two different things: Published, which
|
||||||
|
// everyone on this forecast sees and only its owner or an admin may change, and
|
||||||
|
// Mine, which nobody else lists. Everything you cannot write is still there to
|
||||||
|
// apply -- it is only the write controls that are withheld, so a click never
|
||||||
|
// answers 403.
|
||||||
|
export default function LayoutMenu({
|
||||||
|
layouts = [], activeLayoutId, dirty,
|
||||||
|
onApply, onSave, onSaveAs, onSetVisibility, onSetDefault, onRename, onDelete, onReset
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [saveName, setSaveName] = useState('')
|
||||||
|
const [savePublic, setSavePublic] = useState(false)
|
||||||
|
const [renaming, setRenaming] = useState(null)
|
||||||
|
const [renameTo, setRenameTo] = useState('')
|
||||||
|
const boxRef = useRef(null)
|
||||||
|
|
||||||
|
// Close on an outside click or Escape. Capture phase: the pivot lives in a
|
||||||
|
// shadow root and retargets its events, so a bubbled listener on document
|
||||||
|
// sees the host rather than the real target and cannot tell inside from out.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const onDown = (e) => { if (!boxRef.current?.contains(e.target)) setOpen(false) }
|
||||||
|
const onKey = (e) => { if (e.key === 'Escape') setOpen(false) }
|
||||||
|
document.addEventListener('mousedown', onDown, true)
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onDown, true)
|
||||||
|
window.removeEventListener('keydown', onKey)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const active = layouts.find(l => l.id === activeLayoutId)
|
||||||
|
const published = layouts.filter(l => l.visibility === 'published')
|
||||||
|
const mine = layouts.filter(l => l.visibility === 'private')
|
||||||
|
|
||||||
|
const submitSaveAs = () => {
|
||||||
|
const name = saveName.trim()
|
||||||
|
if (!name) return
|
||||||
|
onSaveAs(name, savePublic ? 'published' : 'private')
|
||||||
|
setSaveName('')
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitRename = (l) => {
|
||||||
|
const name = renameTo.trim()
|
||||||
|
if (name && name !== l.name) onRename(l, name)
|
||||||
|
setRenaming(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconBtn = 'px-1 rounded text-gray-400 hover:text-gray-700 hover:bg-gray-100 leading-none'
|
||||||
|
|
||||||
|
const row = (l) => (
|
||||||
|
<div key={l.id}
|
||||||
|
className={`group flex items-center gap-1 px-2 py-1 rounded cursor-pointer
|
||||||
|
${l.id === activeLayoutId ? 'bg-blue-50 text-blue-700' : 'hover:bg-gray-50 text-gray-700'}`}
|
||||||
|
onClick={() => { if (renaming !== l.id) { onApply(l); setOpen(false) } }}>
|
||||||
|
|
||||||
|
{renaming === l.id ? (
|
||||||
|
<input autoFocus value={renameTo} onClick={e => e.stopPropagation()}
|
||||||
|
onChange={e => setRenameTo(e.target.value)}
|
||||||
|
onBlur={() => submitRename(l)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter') submitRename(l)
|
||||||
|
if (e.key === 'Escape') setRenaming(null)
|
||||||
|
}}
|
||||||
|
className="flex-1 min-w-0 border border-blue-300 rounded px-1 py-0 outline-none bg-white" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="flex-1 min-w-0 truncate">{l.name}</span>
|
||||||
|
{l.is_default && (
|
||||||
|
<span title="Applied when this forecast is opened for the first time"
|
||||||
|
className="text-amber-500 shrink-0">★</span>
|
||||||
|
)}
|
||||||
|
{l.scope === 'source' && (
|
||||||
|
<span title="Applies to every forecast of this source"
|
||||||
|
className="text-gray-300 shrink-0" style={{fontSize:'9px'}}>ALL</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Withheld rather than offered-and-refused: the server allows a write only
|
||||||
|
to the owner or an admin, and can_edit says which this is. */}
|
||||||
|
{l.can_edit && renaming !== l.id && (
|
||||||
|
<span className="hidden group-hover:flex items-center gap-0.5 shrink-0"
|
||||||
|
onClick={e => e.stopPropagation()}>
|
||||||
|
<button className={iconBtn} title="Rename"
|
||||||
|
onClick={() => { setRenaming(l.id); setRenameTo(l.name) }}>✎</button>
|
||||||
|
{l.visibility === 'private' ? (
|
||||||
|
<button className={iconBtn} title="Publish — everyone on this forecast sees it"
|
||||||
|
onClick={() => onSetVisibility(l, 'published')}>↑</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{!l.is_default && (
|
||||||
|
<button className={iconBtn} title="Apply this when the forecast is first opened"
|
||||||
|
onClick={() => onSetDefault(l)}>★</button>
|
||||||
|
)}
|
||||||
|
<button className={iconBtn} title="Unpublish — keep it, but only for you"
|
||||||
|
onClick={() => onSetVisibility(l, 'private')}>↓</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button className={`${iconBtn} hover:text-red-500`} title="Delete"
|
||||||
|
onClick={() => onDelete(l)}>×</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!l.can_edit && (
|
||||||
|
<span className="hidden group-hover:inline text-gray-300 shrink-0 truncate"
|
||||||
|
style={{fontSize:'9px'}} title={`Published by ${l.owner}`}>{l.owner}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={boxRef} className="relative flex items-center gap-1.5">
|
||||||
|
<span className="text-gray-400 uppercase tracking-wide" style={{fontSize:'10px'}}>Layout</span>
|
||||||
|
|
||||||
|
<button onClick={() => setOpen(o => !o)}
|
||||||
|
className={`flex items-center gap-1 border rounded px-2 py-0.5 max-w-[14rem] transition-colors
|
||||||
|
${open ? 'border-blue-300 text-blue-700 bg-blue-50'
|
||||||
|
: 'border-gray-200 text-gray-600 hover:border-gray-400'}`}>
|
||||||
|
<span className="truncate">{active ? active.name : 'Unsaved'}</span>
|
||||||
|
{dirty && <span title="Changed since it was saved" className="text-amber-500 leading-none">•</span>}
|
||||||
|
<span className="text-gray-400 leading-none">▾</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Save is the common action and stays outside the menu, but only when
|
||||||
|
there is something to save it to and it is yours to overwrite. */}
|
||||||
|
{active?.can_edit && dirty && (
|
||||||
|
<button onClick={() => onSave(active)}
|
||||||
|
className="border border-blue-200 text-blue-600 hover:text-blue-800 rounded px-2 py-0.5">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="absolute top-full left-0 mt-1 z-50 w-72 bg-white border border-gray-200
|
||||||
|
rounded shadow-lg py-1 text-xs">
|
||||||
|
|
||||||
|
{published.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="px-2 pt-1 pb-0.5 text-gray-400 uppercase tracking-wide"
|
||||||
|
style={{fontSize:'9px'}}>Published</div>
|
||||||
|
<div className="px-1">{published.map(row)}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mine.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="px-2 pt-2 pb-0.5 text-gray-400 uppercase tracking-wide"
|
||||||
|
style={{fontSize:'9px'}}>Mine</div>
|
||||||
|
<div className="px-1">{mine.map(row)}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!layouts.length && (
|
||||||
|
<div className="px-3 py-2 text-gray-400">No saved layouts yet</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="border-t border-gray-100 mt-1 pt-1 px-2">
|
||||||
|
<div className="flex items-center gap-1 py-1">
|
||||||
|
<input value={saveName} onChange={e => setSaveName(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') submitSaveAs() }}
|
||||||
|
placeholder="Save current view as…"
|
||||||
|
className="flex-1 min-w-0 border border-gray-300 rounded px-1.5 py-0.5
|
||||||
|
outline-none focus:border-blue-400" />
|
||||||
|
<button onClick={submitSaveAs} disabled={!saveName.trim()}
|
||||||
|
className="text-blue-600 hover:text-blue-800 px-1 disabled:opacity-30">Save</button>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-1.5 py-0.5 text-gray-500 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={savePublic}
|
||||||
|
onChange={e => setSavePublic(e.target.checked)} />
|
||||||
|
Publish to everyone on this forecast
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-gray-100 mt-1 pt-1 px-2 pb-0.5">
|
||||||
|
<button onClick={() => { onReset(); setOpen(false) }}
|
||||||
|
className="text-gray-400 hover:text-gray-700 py-0.5">
|
||||||
|
Reset to a blank pivot
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import useTheme from '../theme.jsx'
|
import useTheme from '../theme.jsx'
|
||||||
|
import LayoutMenu from '../components/LayoutMenu.jsx'
|
||||||
import useAuth from '../auth.jsx'
|
import useAuth from '../auth.jsx'
|
||||||
import OperationPanel from '../components/OperationPanel.jsx'
|
import OperationPanel from '../components/OperationPanel.jsx'
|
||||||
import BridgeView from '../components/BridgeView.jsx'
|
import BridgeView from '../components/BridgeView.jsx'
|
||||||
@ -18,8 +19,11 @@ import '@perspective-dev/viewer/themes'
|
|||||||
// to log ids server-side. Mirrors COMPUTED_SLICE_COLS in lib/sql_generator.js.
|
// to log ids server-side. Mirrors COMPUTED_SLICE_COLS in lib/sql_generator.js.
|
||||||
const COMPUTED_SLICE_COLS = new Set(['pf_segment', 'pf_bucket'])
|
const COMPUTED_SLICE_COLS = new Set(['pf_segment', 'pf_bucket'])
|
||||||
|
|
||||||
|
// The unnamed last-used config stays in localStorage: it is per-browser session
|
||||||
|
// continuity, not a thing anyone names or shares. LAYOUTS_KEY is the old named
|
||||||
|
// list, read once per version and lifted into pf.layout (see loadLayouts).
|
||||||
const LAYOUT_KEY = (vid) => `pf_layout_v${vid}` // last-used layout (auto restore)
|
const LAYOUT_KEY = (vid) => `pf_layout_v${vid}` // last-used layout (auto restore)
|
||||||
const LAYOUTS_KEY = (vid) => `pf_layouts_v${vid}` // named layout list
|
const LAYOUTS_KEY = (vid) => `pf_layouts_v${vid}` // legacy named list, migrated away
|
||||||
|
|
||||||
function cleanLayout(cfg, validCols) {
|
function cleanLayout(cfg, validCols) {
|
||||||
if (!cfg) return cfg
|
if (!cfg) return cfg
|
||||||
@ -88,8 +92,11 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
// layouts
|
// layouts
|
||||||
const [layouts, setLayouts] = useState([])
|
const [layouts, setLayouts] = useState([])
|
||||||
const [activeLayoutId, setActiveLayoutId] = useState(null)
|
const [activeLayoutId, setActiveLayoutId] = useState(null)
|
||||||
const [showSaveAs, setShowSaveAs] = useState(false)
|
// Whether the pivot has drifted from the layout it was applied from. Compared
|
||||||
const [saveAsName, setSaveAsName] = useState('')
|
// rather than flagged: every restore fires perspective-config-update, so a
|
||||||
|
// flag set by the event alone would read dirty the moment a layout was applied.
|
||||||
|
const [layoutDirty, setLayoutDirty] = useState(false)
|
||||||
|
const activeConfigRef = useRef(null)
|
||||||
|
|
||||||
// operation panel — a selection is a LIST of slices; one entry is the common case
|
// operation panel — a selection is a LIST of slices; one entry is the common case
|
||||||
// The column hierarchy and how many of its levels are showing. Mirrored into
|
// The column hierarchy and how many of its levels are showing. Mirrored into
|
||||||
@ -406,8 +413,12 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!versionId || !sourceId) return
|
if (!versionId || !sourceId) return
|
||||||
loadLayouts(versionId)
|
// The layouts decide which config the first load restores, so they are
|
||||||
initViewer(versionId, sourceId)
|
// fetched before the viewer rather than beside it.
|
||||||
|
;(async () => {
|
||||||
|
const list = await loadLayouts(versionId)
|
||||||
|
initViewer(versionId, sourceId, list)
|
||||||
|
})()
|
||||||
}, [versionId, sourceId])
|
}, [versionId, sourceId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -713,13 +724,58 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
return { buffer: merged.buffer, rowCount }
|
return { buffer: merged.buffer, rowCount }
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadLayouts(vid) {
|
// Named layouts live in pf.layout, not localStorage: a published one has to
|
||||||
const stored = localStorage.getItem(LAYOUTS_KEY(vid))
|
// reach everyone on the forecast, and a private one has to survive a new
|
||||||
setLayouts(stored ? JSON.parse(stored) : [])
|
// browser.
|
||||||
|
async function loadLayouts(vid) {
|
||||||
|
try {
|
||||||
|
// One-time lift of whatever this browser accumulated under the old scheme.
|
||||||
|
// A name that already exists comes back 409 and is simply already
|
||||||
|
// migrated, so the key is cleared either way -- the alternative is
|
||||||
|
// re-offering the same failed migration on every load forever.
|
||||||
|
const legacy = localStorage.getItem(LAYOUTS_KEY(vid))
|
||||||
|
if (legacy) {
|
||||||
|
for (const l of (JSON.parse(legacy) || [])) {
|
||||||
|
if (!l?.name || !l?.config) continue
|
||||||
|
try {
|
||||||
|
await fetch(`/api/versions/${vid}/layouts`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: l.name, config: l.config, visibility: 'private' })
|
||||||
|
})
|
||||||
|
} catch { /* keep going: one bad row must not strand the rest */ }
|
||||||
|
}
|
||||||
|
localStorage.removeItem(LAYOUTS_KEY(vid))
|
||||||
|
}
|
||||||
|
const list = await fetch(`/api/versions/${vid}/layouts`).then(r => r.ok ? r.json() : [])
|
||||||
|
setLayouts(list)
|
||||||
setActiveLayoutId(null)
|
setActiveLayoutId(null)
|
||||||
|
setLayoutDirty(false)
|
||||||
|
return list
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[loadLayouts]', err)
|
||||||
|
setLayouts([])
|
||||||
|
return []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initViewer(vid, sid) {
|
// Config equality for the dirty dot. `table` is the per-load table name and
|
||||||
|
// differs on every refresh, so it is never a real difference.
|
||||||
|
function sameConfig(a, b) {
|
||||||
|
if (!a || !b) return false
|
||||||
|
const strip = ({ table, ...rest }) => JSON.stringify(rest)
|
||||||
|
return strip(a) === strip(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remember what the pivot currently matches, so the dot can be computed
|
||||||
|
// against it rather than guessed from events.
|
||||||
|
function markClean(cfg, id) {
|
||||||
|
activeConfigRef.current = cfg || null
|
||||||
|
if (id !== undefined) setActiveLayoutId(id)
|
||||||
|
setLayoutDirty(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initViewer(vid, sid, layoutList = []) {
|
||||||
const viewer = viewerRef.current
|
const viewer = viewerRef.current
|
||||||
if (!viewer) return
|
if (!viewer) return
|
||||||
const myId = ++initIdRef.current
|
const myId = ++initIdRef.current
|
||||||
@ -859,7 +915,12 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
if (cfg.group_by_depth != null) setExpandDepth(cfg.group_by_depth - 1)
|
if (cfg.group_by_depth != null) setExpandDepth(cfg.group_by_depth - 1)
|
||||||
else if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth)
|
else if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth)
|
||||||
} else {
|
} else {
|
||||||
const sourceDefault = sources.find(s => String(s.id) === String(sid))?.default_layout
|
// The default is a published layout flagged is_default. A version-scoped
|
||||||
|
// one wins over a source-wide one: the narrower answer is the one
|
||||||
|
// somebody chose for this forecast specifically.
|
||||||
|
const defaults = layoutList.filter(l => l.is_default)
|
||||||
|
const defaultRow = defaults.find(l => l.version_id != null) || defaults[0]
|
||||||
|
const sourceDefault = defaultRow?.config
|
||||||
let cfg
|
let cfg
|
||||||
if (sourceDefault && Object.keys(sourceDefault).length > 0) {
|
if (sourceDefault && Object.keys(sourceDefault).length > 0) {
|
||||||
const { table: _t, ...rest } = cleanLayout(sourceDefault, validCols)
|
const { table: _t, ...rest } = cleanLayout(sourceDefault, validCols)
|
||||||
@ -875,6 +936,11 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
}
|
}
|
||||||
await viewer.restore(cfg)
|
await viewer.restore(cfg)
|
||||||
adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by, (cfg.split_by || []).length)
|
adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by, (cfg.split_by || []).length)
|
||||||
|
// Name what we landed on, so the menu says which layout this is and the
|
||||||
|
// dirty dot has something to compare against. Read back rather than
|
||||||
|
// reusing cfg: restore() normalises, so the live config is what a later
|
||||||
|
// save() will be compared with.
|
||||||
|
if (defaultRow) markClean(await viewer.save(), defaultRow.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Its own try: the restore above has one, but this sits outside it, and a
|
// Its own try: the restore above has one, but this sits outside it, and a
|
||||||
@ -898,6 +964,10 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
hideSplitTotal()
|
hideSplitTotal()
|
||||||
const cfg = await captureConfig()
|
const cfg = await captureConfig()
|
||||||
if (cfg) await persistLayout(vid, cfg)
|
if (cfg) await persistLayout(vid, cfg)
|
||||||
|
// Dirty is a comparison, not a flag: restore() itself fires this
|
||||||
|
// event, so anything set unconditionally here would light up the
|
||||||
|
// moment a layout was applied.
|
||||||
|
if (cfg) setLayoutDirty(!!activeConfigRef.current && !sameConfig(cfg, activeConfigRef.current))
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
viewer.addEventListener('perspective-config-update', viewer._pspUpdate)
|
viewer.addEventListener('perspective-config-update', viewer._pspUpdate)
|
||||||
@ -1363,48 +1433,66 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
localStorage.setItem(LAYOUT_KEY(vid), JSON.stringify(cfg))
|
localStorage.setItem(LAYOUT_KEY(vid), JSON.stringify(cfg))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSaveAs() {
|
// One place every layout write goes through, so the 403 the server returns for
|
||||||
const name = saveAsName.trim()
|
// somebody else's layout is reported the same way everywhere.
|
||||||
if (!name) return
|
async function layoutFetch(url, opts) {
|
||||||
const cfg = await captureConfig()
|
const res = await fetch(url, opts)
|
||||||
if (!cfg) return
|
const data = await res.json().catch(() => ({}))
|
||||||
const id = Date.now()
|
if (!res.ok) throw new Error(data.error || 'Failed')
|
||||||
const updated = [...layouts, { id, name, config: cfg }]
|
return data
|
||||||
localStorage.setItem(LAYOUTS_KEY(versionId), JSON.stringify(updated))
|
|
||||||
await persistLayout(versionId, cfg)
|
|
||||||
setLayouts(updated)
|
|
||||||
setActiveLayoutId(id)
|
|
||||||
setShowSaveAs(false)
|
|
||||||
setSaveAsName('')
|
|
||||||
flash('Saved')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveAsSourceDefault() {
|
async function handleSaveAs(name, visibility) {
|
||||||
const cfg = await captureConfig()
|
const cfg = await captureConfig()
|
||||||
if (!cfg) return
|
if (!cfg) return
|
||||||
const { table, ...rest } = cfg
|
const { table: _t, ...rest } = cfg
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/sources/${sourceId}/default-layout`, {
|
const saved = await layoutFetch(`/api/versions/${versionId}/layouts`, {
|
||||||
method: 'PUT',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(rest)
|
body: JSON.stringify({ name, config: rest, visibility })
|
||||||
})
|
})
|
||||||
if (!res.ok) { const data = await res.json(); flash(data.error || 'Failed', 'error'); return }
|
setLayouts(ls => [...ls, saved])
|
||||||
if (refreshSources) await refreshSources()
|
markClean(cfg, saved.id)
|
||||||
flash('Saved as source default')
|
await persistLayout(versionId, cfg)
|
||||||
|
flash(visibility === 'published' ? `Published “${saved.name}”` : 'Saved')
|
||||||
} catch (err) { flash(err.message, 'error') }
|
} catch (err) { flash(err.message, 'error') }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSaveOver() {
|
async function handleSaveOver(layout) {
|
||||||
const layout = layouts.find(l => l.id === activeLayoutId)
|
const target = layout || layouts.find(l => l.id === activeLayoutId)
|
||||||
if (!layout) return
|
if (!target) return
|
||||||
const cfg = await captureConfig()
|
const cfg = await captureConfig()
|
||||||
if (!cfg) return
|
if (!cfg) return
|
||||||
const updated = layouts.map(l => l.id === activeLayoutId ? { ...l, config: cfg } : l)
|
const { table: _t, ...rest } = cfg
|
||||||
localStorage.setItem(LAYOUTS_KEY(versionId), JSON.stringify(updated))
|
try {
|
||||||
|
const saved = await layoutFetch(`/api/layouts/${target.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ config: rest })
|
||||||
|
})
|
||||||
|
setLayouts(ls => ls.map(l => l.id === saved.id ? saved : l))
|
||||||
|
markClean(cfg, saved.id)
|
||||||
await persistLayout(versionId, cfg)
|
await persistLayout(versionId, cfg)
|
||||||
setLayouts(updated)
|
|
||||||
flash('Saved')
|
flash('Saved')
|
||||||
|
} catch (err) { flash(err.message, 'error') }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish / unpublish / rename / make default are all the same PATCH; the
|
||||||
|
// server decides whether this account may make it.
|
||||||
|
async function patchLayout(layout, body, ok) {
|
||||||
|
try {
|
||||||
|
const saved = await layoutFetch(`/api/layouts/${layout.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
})
|
||||||
|
// is_default is exclusive per scope, so the row the server stood down is
|
||||||
|
// stale here too -- reload rather than patching one row into the list.
|
||||||
|
if (body.is_default) await loadLayouts(versionId).then(() => setActiveLayoutId(saved.id))
|
||||||
|
else setLayouts(ls => ls.map(l => l.id === saved.id ? saved : l))
|
||||||
|
flash(ok)
|
||||||
|
} catch (err) { flash(err.message, 'error') }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyLayout(layout) {
|
async function applyLayout(layout) {
|
||||||
@ -1421,24 +1509,28 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
|
|
||||||
await ensureRowLabelWidth()
|
await ensureRowLabelWidth()
|
||||||
|
|
||||||
setActiveLayoutId(layout.id)
|
|
||||||
// The persisted copy is taken after the restore, so it carries whatever
|
// The persisted copy is taken after the restore, so it carries whatever
|
||||||
// cleanLayout dropped on the way in rather than the stale original.
|
// cleanLayout dropped on the way in rather than the stale original -- and
|
||||||
|
// that same copy is what the dirty dot compares against, so applying a
|
||||||
|
// layout whose config named a dropped column does not read as changed.
|
||||||
const merged = await captureConfig()
|
const merged = await captureConfig()
|
||||||
|
markClean(merged || cfg, layout.id)
|
||||||
await persistLayout(versionId, merged || cfg)
|
await persistLayout(versionId, merged || cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteLayout(id, e) {
|
async function deleteLayout(layout) {
|
||||||
e.stopPropagation()
|
if (layout.visibility === 'published'
|
||||||
const updated = layouts.filter(l => l.id !== id)
|
&& !window.confirm(`Delete “${layout.name}”? Everyone on this forecast loses it.`)) return
|
||||||
localStorage.setItem(LAYOUTS_KEY(versionId), JSON.stringify(updated))
|
try {
|
||||||
setLayouts(updated)
|
await layoutFetch(`/api/layouts/${layout.id}`, { method: 'DELETE' })
|
||||||
if (activeLayoutId === id) setActiveLayoutId(null)
|
setLayouts(ls => ls.filter(l => l.id !== layout.id))
|
||||||
|
if (activeLayoutId === layout.id) markClean(null, null)
|
||||||
|
} catch (err) { flash(err.message, 'error') }
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetLayout() {
|
function resetLayout() {
|
||||||
localStorage.removeItem(LAYOUT_KEY(versionId))
|
localStorage.removeItem(LAYOUT_KEY(versionId))
|
||||||
setActiveLayoutId(null)
|
markClean(null, null)
|
||||||
const viewer = viewerRef.current
|
const viewer = viewerRef.current
|
||||||
if (viewer) viewer.restore({ settings: true })
|
if (viewer) viewer.restore({ settings: true })
|
||||||
}
|
}
|
||||||
@ -1796,44 +1888,20 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="px-3 py-1.5 border-b border-gray-200 bg-white flex items-center gap-3 shrink-0 flex-wrap text-xs">
|
<div className="px-3 py-1.5 border-b border-gray-200 bg-white flex items-center gap-3 shrink-0 flex-wrap text-xs">
|
||||||
|
|
||||||
{/* Layout group */}
|
<LayoutMenu
|
||||||
<div className="flex items-center gap-1.5">
|
layouts={layouts}
|
||||||
<span className="text-gray-400 uppercase tracking-wide" style={{fontSize:'10px'}}>Layout</span>
|
activeLayoutId={activeLayoutId}
|
||||||
{layouts.map(l => (
|
dirty={layoutDirty}
|
||||||
<div key={l.id} onClick={() => applyLayout(l)}
|
onApply={applyLayout}
|
||||||
className={`flex items-center gap-1 rounded px-2 py-0.5 cursor-pointer border transition-colors
|
onSave={handleSaveOver}
|
||||||
${activeLayoutId === l.id ? 'bg-blue-50 border-blue-300 text-blue-700' : 'bg-white border-gray-200 text-gray-600 hover:border-gray-400'}`}>
|
onSaveAs={handleSaveAs}
|
||||||
{l.name}
|
onSetVisibility={(l, v) => patchLayout(l, { visibility: v },
|
||||||
<button onClick={e => deleteLayout(l.id, e)} className="text-gray-300 hover:text-red-400 text-sm leading-none ml-0.5">×</button>
|
v === 'published' ? `Published “${l.name}”` : `“${l.name}” is private again`)}
|
||||||
</div>
|
onSetDefault={l => patchLayout(l, { is_default: true },
|
||||||
))}
|
`“${l.name}” opens this forecast`)}
|
||||||
{showSaveAs ? (
|
onRename={(l, name) => patchLayout(l, { name }, 'Renamed')}
|
||||||
<div className="flex items-center gap-1">
|
onDelete={deleteLayout}
|
||||||
<input autoFocus value={saveAsName} onChange={e => setSaveAsName(e.target.value)}
|
onReset={resetLayout} />
|
||||||
onKeyDown={e => { if (e.key === 'Enter') handleSaveAs(); if (e.key === 'Escape') { setShowSaveAs(false); setSaveAsName('') } }}
|
|
||||||
placeholder="Layout name…" className="border border-gray-300 rounded px-2 py-0.5 w-32 focus:outline-none focus:border-blue-400" />
|
|
||||||
<button onClick={handleSaveAs} className="text-blue-600 hover:text-blue-800 px-1">Save</button>
|
|
||||||
<button onClick={() => { setShowSaveAs(false); setSaveAsName('') }} className="text-gray-400 px-1">Cancel</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{activeLayoutId !== null && (
|
|
||||||
<button onClick={handleSaveOver} className="border border-blue-200 text-blue-500 hover:text-blue-700 rounded px-2 py-0.5">Save</button>
|
|
||||||
)}
|
|
||||||
<button onClick={() => setShowSaveAs(true)} className="border border-dashed border-gray-200 text-gray-400 hover:text-gray-600 rounded px-2 py-0.5">
|
|
||||||
Save as…
|
|
||||||
</button>
|
|
||||||
<button onClick={saveAsSourceDefault} disabled={!sourceId}
|
|
||||||
className="border border-dashed border-gray-200 text-gray-400 hover:text-gray-600 rounded px-2 py-0.5 disabled:opacity-40"
|
|
||||||
title="Use this layout as the default for new versions of this source">
|
|
||||||
Set source default
|
|
||||||
</button>
|
|
||||||
{activeLayoutId !== null && (
|
|
||||||
<button onClick={resetLayout} className="text-gray-300 hover:text-red-400">Reset</button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-px h-4 bg-gray-200 shrink-0" />
|
<div className="w-px h-4 bg-gray-200 shrink-0" />
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user