Skip to content

Commit 7a3371f

Browse files
authored
Merge pull request #330 from Point72/tkp/perspective-theme-layout-actions
Fix Perspective themes and layout actions
2 parents 1d2f7bf + 5cd725b commit 7a3371f

14 files changed

Lines changed: 515 additions & 41 deletions

File tree

TODO_SPADAY.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Spaday follow-ups
2+
3+
## Parallelize Perspective panel theme restores
4+
5+
`spaday-perspective` 0.4.3 has the same panel-count-dependent theme lag as the legacy csp-gateway frontend had before its local fix.
6+
7+
The `<perspective-panel>` theme path currently:
8+
9+
1. calls `restore({theme})` for the element chrome and active panel;
10+
2. calls `saveWorkspace()` to enumerate panels;
11+
3. loops over every panel and awaits `restore({theme}, {panel})` sequentially.
12+
13+
Each restore restyles a Perspective plugin, so total latency grows with the number of tabs. Browser profiling against Perspective 5.2 showed a visibly delayed transition with eight panels. In one run, sequential completion took about 300 ms; issuing the panel restores with `Promise.all()` reduced it to about 150 ms. Exact timings vary with panel contents and render state.
14+
15+
Upstream action:
16+
17+
- Keep the initial bare `restore({theme})`; it updates element chrome and the active panel.
18+
- After `saveWorkspace()`, restore background panel themes concurrently with `Promise.all()` rather than a serial `for ... of` loop.
19+
- Verify concurrent restores remain safe while live tables update and while a layout replacement is queued.
20+
- Add a multi-panel browser test that checks all saved panel themes after toggling the global theme.

csp_gateway/server/modules/web/perspective.py

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,15 @@
99
Literal,
1010
TypeVar,
1111
)
12+
from urllib.parse import parse_qs
1213

1314
import csp
1415
import orjson
1516
import pyarrow
1617
import pyarrow.json
1718
import uvloop
1819
from csp import ts
19-
from fastapi import APIRouter, WebSocket
20+
from fastapi import APIRouter, HTTPException, Request, Response, WebSocket
2021
from perspective import Client, Server, Table
2122
from perspective.handlers.starlette import PerspectiveStarletteHandler
2223
from pydantic import BaseModel, Field, PrivateAttr, field_validator, model_validator
@@ -43,6 +44,8 @@
4344

4445
log = getLogger(__name__)
4546

47+
_MAX_LAYOUT_DOWNLOAD_BYTES = 16 * 1024 * 1024
48+
4649
_PSP_ARROW_MAP = {
4750
int: pyarrow.int64(),
4851
float: pyarrow.float64(),
@@ -654,6 +657,36 @@ async def get_perspective_layouts():
654657
"""
655658
return self._layouts
656659

660+
@api_router.post("{}/{}".format(self._route, "download-layout"), include_in_schema=False)
661+
async def download_perspective_layout(request: Request) -> Response:
662+
try:
663+
content_length = int(request.headers.get("content-length", 0))
664+
if content_length > _MAX_LAYOUT_DOWNLOAD_BYTES:
665+
raise HTTPException(status_code=413, detail="Layout is too large")
666+
body = bytearray()
667+
async for chunk in request.stream():
668+
body.extend(chunk)
669+
if len(body) > _MAX_LAYOUT_DOWNLOAD_BYTES:
670+
raise HTTPException(status_code=413, detail="Layout is too large")
671+
fields = parse_qs(body.decode("utf-8"), strict_parsing=True, max_num_fields=1)
672+
layout = fields["layout"][0]
673+
parsed = orjson.loads(layout)
674+
if not isinstance(parsed, dict) or not isinstance(parsed.get("layout"), dict) or not isinstance(parsed.get("panels"), dict):
675+
raise TypeError
676+
except HTTPException:
677+
raise
678+
except (KeyError, TypeError, UnicodeDecodeError, ValueError, orjson.JSONDecodeError) as exc:
679+
raise HTTPException(status_code=400, detail="Invalid layout") from exc
680+
return Response(
681+
content=layout,
682+
media_type="application/json",
683+
headers={
684+
"Cache-Control": "no-store",
685+
"Content-Disposition": 'attachment; filename="layout.json"',
686+
"X-Content-Type-Options": "nosniff",
687+
},
688+
)
689+
657690
# add route to fetch layouts
658691
@api_router.get(
659692
"{}/{}".format(self._route, "meta"),
@@ -721,8 +754,13 @@ def ui(self, app: "GatewayUI") -> None:
721754
)
722755
default_view = self.default_layout or "__default__"
723756
app.seed_store(view=default_view)
724-
if layouts:
725-
app.add(Region.HEADER_RIGHT, app.layout_selector(layouts, value=default_view), order=90)
757+
app.add(Region.HEADER_RIGHT, app.layout_selector(layouts, value=default_view), order=90)
758+
app.add(Region.HEADER_RIGHT, app.save_layout_button(), order=100)
759+
app.add(
760+
Region.HEADER_RIGHT,
761+
app.download_layout_button(f"{app.settings.API_STR}{self._route}/download-layout"),
762+
order=101,
763+
)
726764

727765
def run_perspective(self):
728766
"""Launch the perspective threads"""
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { registerHandler } from "../../js/cdn/index.js";
2+
3+
const CUSTOM_LAYOUT_STORAGE_KEY = "csp_gateway_demo_config";
4+
const WORKSPACE_ID = "gateway-workspace";
5+
6+
function stripTransientFields(layout) {
7+
const cloned = structuredClone(layout);
8+
for (const panel of Object.values(cloned.panels || {})) {
9+
delete panel.theme;
10+
for (const column of Object.values(panel.plugin_config?.columns || {})) {
11+
delete column.column_size_override;
12+
}
13+
}
14+
return cloned;
15+
}
16+
17+
function workspace(currentTarget) {
18+
return currentTarget.ownerDocument.getElementById(WORKSPACE_ID);
19+
}
20+
21+
registerHandler("csp-gateway:save-layout", (_event, currentTarget) => {
22+
void (async () => {
23+
const layout = stripTransientFields(await workspace(currentTarget).save());
24+
localStorage.setItem(CUSTOM_LAYOUT_STORAGE_KEY, JSON.stringify(layout));
25+
const customLayout = globalThis.cspGatewayCustomLayout;
26+
for (const key of Object.keys(customLayout)) {
27+
delete customLayout[key];
28+
}
29+
Object.assign(customLayout, layout);
30+
})().catch((error) =>
31+
console.error("Failed to save Perspective layout:", error),
32+
);
33+
});
34+
35+
registerHandler("csp-gateway:download-layout", (_event, currentTarget) => {
36+
void (async () => {
37+
const layout = stripTransientFields(await workspace(currentTarget).save());
38+
const json = JSON.stringify(layout).replace(
39+
/PERSPECTIVE_GENERATED_/g,
40+
"CSP_GATEWAY_GENERATED_",
41+
);
42+
const form = currentTarget.ownerDocument.createElement("form");
43+
form.method = "POST";
44+
form.action = currentTarget.dataset.downloadUrl;
45+
form.target = "_blank";
46+
form.hidden = true;
47+
const input = currentTarget.ownerDocument.createElement("input");
48+
input.type = "hidden";
49+
input.name = "layout";
50+
input.value = json;
51+
form.appendChild(input);
52+
currentTarget.ownerDocument.body.appendChild(form);
53+
form.submit();
54+
form.remove();
55+
})().catch((error) =>
56+
console.error("Failed to download Perspective layout:", error),
57+
);
58+
});

csp_gateway/server/web/spaday_ui.py

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import json
1818
import logging
1919
from dataclasses import dataclass, field as _dc_field
20+
from pathlib import Path
2021
from typing import TYPE_CHECKING, Any
2122

2223
from pydantic import TypeAdapter
@@ -33,9 +34,10 @@
3334
"dependency, which ships with csp-gateway. Reinstall it with: pip install csp-gateway."
3435
) from exc
3536

36-
from spaday import element
37+
from spaday import Js, element
3738
from spaday.actions import (
3839
CallEndpoint,
40+
NamedJs,
3941
Sequence,
4042
SetField,
4143
Toggle,
@@ -51,6 +53,7 @@
5153
)
5254
from spaday.backends.starlette import mount as _spaday_mount
5355
from spaday.components.shell import AppShell, Column, Region, Row, Show
56+
from spaday.packages import ComponentPackage
5457
from spaday_perspective import PerspectivePanel
5558
from spaday_webawesome import (
5659
FormField,
@@ -78,6 +81,15 @@
7881

7982
# The tenant every connection shares when no auth middleware can identify it.
8083
_ANONYMOUS_TENANT = "__anonymous__"
84+
_CUSTOM_LAYOUT_NAME = "Custom Layout"
85+
_CUSTOM_LAYOUT_STORAGE_KEY = "csp_gateway_demo_config"
86+
_SAVE_LAYOUT_HANDLER = "csp-gateway:save-layout"
87+
_DOWNLOAD_LAYOUT_HANDLER = "csp-gateway:download-layout"
88+
_GATEWAY_COMPONENT_PACKAGE = ComponentPackage(
89+
name="csp-gateway",
90+
assets_dir=Path(__file__).with_name("spaday_assets"),
91+
assets=(("js", "actions.js"),),
92+
)
8193

8294

8395
# Page-level resets that spaday's document template does not ship. The palette is deliberately absent:
@@ -274,13 +286,27 @@ def perspective_panel(
274286
to all of ``tables``. Add it to `Region.MAIN`.
275287
"""
276288
tables = list(tables or [])
277-
layout_expr: Any = self._default_layout(list(default_tables) if default_tables is not None else tables)
289+
default_layout = self._default_layout(list(default_tables) if default_tables is not None else tables)
290+
layout_expr: Any = default_layout
278291
for name, layout_json in (layouts or {}).items():
279292
try:
280293
parsed = json.loads(layout_json)
281294
except (TypeError, ValueError):
282295
continue
283296
layout_expr = cond(eq(field("view"), name), parsed, layout_expr)
297+
fallback = json.dumps(default_layout).replace("<", "\\u003c")
298+
storage_key = json.dumps(_CUSTOM_LAYOUT_STORAGE_KEY)
299+
self._store_seeds["custom_layout"] = Js(
300+
"globalThis.cspGatewayCustomLayout = (() => { "
301+
f"const fallback = {fallback}; "
302+
f'try {{ const value = JSON.parse(localStorage.getItem({storage_key}) ?? "null"); '
303+
'return value && typeof value === "object" && !Array.isArray(value) '
304+
'&& value.layout && typeof value.layout === "object" && !Array.isArray(value.layout) '
305+
'&& value.panels && typeof value.panels === "object" && !Array.isArray(value.panels) ? value : fallback; } '
306+
"catch { return fallback; } "
307+
"})()"
308+
)
309+
layout_expr = cond(eq(field("view"), _CUSTOM_LAYOUT_NAME), field("custom_layout"), layout_expr)
284310

285311
return (
286312
PerspectivePanel()
@@ -291,15 +317,28 @@ def perspective_panel(
291317
)
292318

293319
def layout_selector(self, layouts: dict[str, str], *, value: str | None = None) -> Any:
294-
"""A dropdown two-way bound to the `view` state, listing "All Tables" + each named layout.
320+
"""A dropdown bound to `view`, listing the generated, saved, and custom layouts.
295321
296322
Add it to `Region.HEADER_RIGHT` (and `seed_store(view=...)`).
297323
"""
298324
select = WaSelect(value=value, size="s").bind("value", "view", mode="two-way").style(width="220px")
299325
select = select.child(WaOption(value="__default__").text("All Tables"))
300326
for name in layouts:
301327
select = select.child(WaOption(value=name).text(name))
302-
return select
328+
return select.child(WaOption(value=_CUSTOM_LAYOUT_NAME).text(_CUSTOM_LAYOUT_NAME))
329+
330+
def save_layout_button(self) -> Any:
331+
"""A header button that saves the current Perspective workspace in the browser."""
332+
return WaButton(appearance="plain", title="Save current layout").on("click", NamedJs(_SAVE_LAYOUT_HANDLER)).child(WaIcon(name="floppy-disk"))
333+
334+
def download_layout_button(self, url: str) -> Any:
335+
"""A header button that downloads the current Perspective workspace as JSON."""
336+
return (
337+
WaButton(appearance="plain", title="Download layout")
338+
.prop("data-download-url", self.url(url))
339+
.on("click", NamedJs(_DOWNLOAD_LAYOUT_HANDLER))
340+
.child(WaIcon(name="download"))
341+
)
303342

304343
def link_button(self, label: str, href: str, *, target: str = "_blank", variant: str | None = None) -> Any:
305344
"""A full-width link button (opens `href`), for a drawer/gutter. Add it to a region."""
@@ -715,7 +754,7 @@ def mount(self) -> None:
715754
scratch,
716755
self.build_page,
717756
# Component libraries ship as their own distributions and are resolved by entry point.
718-
packages=["webawesome", "perspective"],
757+
packages=["webawesome", "perspective", _GATEWAY_COMPONENT_PACKAGE],
719758
# spaday infers "source checkout" from a `js/` dir next to itself, which any distribution
720759
# shipping a top-level `js/` package (plotly does) satisfies -- serving assets we consume
721760
# from the wheel, never from a spaday checkout.

csp_gateway/tests/server/web/test_spaday_ui.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Tests for the optional spaday UI provider (`Settings.UI_PROVIDER == "spaday"`)."""
22

3+
import json
34
from datetime import timedelta
45
from enum import Enum, auto
56

@@ -15,6 +16,7 @@
1516
GatewayModule,
1617
GatewaySettings,
1718
GatewayStruct,
19+
MountPerspectiveTables,
1820
MountRestRoutes,
1921
MountSendForm,
2022
)
@@ -247,3 +249,65 @@ def test_one_datagrid_panel_per_table(self):
247249
"CSP_GATEWAY_0": {"table": "orders", "plugin": "Datagrid", "title": "orders"},
248250
"CSP_GATEWAY_1": {"table": "fills", "plugin": "Datagrid", "title": "fills"},
249251
}
252+
253+
254+
class TestSpadayPerspectiveLayoutActions:
255+
@pytest.fixture(scope="class")
256+
def gateway(self, free_port):
257+
return Gateway(
258+
modules=[ExampleModule(), MountPerspectiveTables()],
259+
channels=ExampleChannels(),
260+
settings=GatewaySettings(PORT=free_port, UI_PROVIDER="spaday"),
261+
)
262+
263+
@pytest.fixture(scope="class")
264+
def client(self, gateway):
265+
gateway.start(rest=True, ui=True, _in_test=True)
266+
try:
267+
yield TestClient(gateway.web_app.get_fastapi())
268+
finally:
269+
gateway.stop()
270+
271+
def test_layout_actions_are_available_without_server_layouts(self, client: TestClient):
272+
tree = client.get("/tree.json").text
273+
assert "Custom Layout" in tree
274+
assert "Save current layout" in tree
275+
assert "csp-gateway:save-layout" in tree
276+
assert "Download layout" in tree
277+
assert "csp-gateway:download-layout" in tree
278+
279+
def test_layout_action_script_is_served(self, client: TestClient):
280+
page = client.get("/").text
281+
assert "/components/csp-gateway/actions.js" in page
282+
assert "globalThis.cspGatewayCustomLayout" in page
283+
assert '"gateway-workspace"' in client.get("/tree.json").text
284+
script = client.get("/components/csp-gateway/actions.js")
285+
assert script.status_code == 200
286+
assert 'from "../../js/cdn/index.js"' in script.text
287+
assert '"csp-gateway:save-layout"' in script.text
288+
assert '"csp-gateway:download-layout"' in script.text
289+
assert '"csp_gateway_demo_config"' in script.text
290+
assert '"gateway-workspace"' in script.text
291+
assert client.get("/js/cdn/index.js").status_code == 200
292+
293+
def test_layout_download_is_a_same_origin_attachment(self, client: TestClient):
294+
layout = {"layout": {"type": "tab-layout", "tabs": []}, "panels": {}}
295+
296+
response = client.post("/api/v1/perspective/download-layout", data={"layout": json.dumps(layout)})
297+
298+
assert response.status_code == 200
299+
assert response.json() == layout
300+
assert response.headers["content-disposition"] == 'attachment; filename="layout.json"'
301+
assert response.headers["cache-control"] == "no-store"
302+
assert response.headers["x-content-type-options"] == "nosniff"
303+
304+
def test_layout_download_rejects_invalid_and_oversized_content(self, client: TestClient):
305+
invalid = client.post("/api/v1/perspective/download-layout", data={"layout": "1"})
306+
oversized = client.post(
307+
"/api/v1/perspective/download-layout",
308+
content=b"x",
309+
headers={"content-length": str(16 * 1024 * 1024 + 1)},
310+
)
311+
312+
assert invalid.status_code == 400
313+
assert oversized.status_code == 413

js/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@
1010
"scripts": {
1111
"build": "node build.mjs",
1212
"clean": "rimraf dist lib playwright-report ../csp_gateway/server/build",
13-
"lint": "prettier --check \"src/**/*.{js,ts,jsx,tsx,css,html}\" \"*.mjs\" \"*.json\"",
14-
"fix": "prettier --write \"src/**/*.{js,ts,jsx,tsx,css,html}\" \"*.mjs\" \"*.json\"",
15-
"test": "echo \"todo\"",
13+
"lint": "prettier --check \"src/**/*.{js,ts,jsx,tsx,css,html}\" \"../csp_gateway/server/web/spaday_assets/*.js\" \"*.mjs\" \"*.json\"",
14+
"fix": "prettier --write \"src/**/*.{js,ts,jsx,tsx,css,html}\" \"../csp_gateway/server/web/spaday_assets/*.js\" \"*.mjs\" \"*.json\"",
15+
"test": "node --test",
1616
"preinstall": "npx only-allow pnpm",
1717
"prepack": "pnpm run build"
1818
},

js/src/js/components/header.jsx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React, { useCallback, useEffect, useState } from "react";
22
import { FaBars, FaDownload, FaMoon, FaSave, FaSun } from "react-icons/fa";
33
import { CspGatewayLogo } from "./logo";
4-
import { getCurrentTheme } from "./perspective/theme";
4+
import { downloadLayout } from "./perspective/download";
55

66
const ICON_SIZE = 20;
77

@@ -59,12 +59,7 @@ export function Header(props) {
5959
const onDownload = useCallback(async () => {
6060
const json = await workspaceRef?.current?.exportLayout();
6161
if (!json) return;
62-
const link = document.createElement("a");
63-
link.href = `data:application/json;base64,${btoa(json)}`;
64-
link.download = "layout.json";
65-
document.body.appendChild(link);
66-
link.click();
67-
document.body.removeChild(link);
62+
downloadLayout(json);
6863
}, [workspaceRef]);
6964

7065
return (

0 commit comments

Comments
 (0)