From e55bddf5d452c89974fd720f8f68ffe6efeb1122 Mon Sep 17 00:00:00 2001 From: Danylo Korostil Date: Mon, 13 Jul 2026 17:58:20 +0300 Subject: [PATCH] Add portal mode: DB-backed dashboards, login, editor topbar overhaul (0.4.0) Portal mode (opt-in via FIREFLYER_PORTAL) stores many dashboards in a database and lists them in a gallery, reusing the existing stateless editor unchanged. Scoped to web/ as an owner-approved exception to the no-persistence anti-goal. - Store (web/portal.py): SqliteStore (dev/tests) + PostgresStore (runtime, lazy psycopg behind the .[portal] extra). Dashboards are opaque YAML blobs validated on save; name + author are columns. Gallery is a table (name, author, last updated) with Edit / Clone / Remove and a New-dashboard modal. - Entrypoint: python -m fireflyer.portal (reads portal.yaml, binds 0.0.0.0); docker compose --profile portal brings up Postgres + the app. - Auth (web/auth.py): login gate, default admin/admin, HMAC-signed session cookie, built on two swappable seams (Authenticator + session) so SSO/LDAP drop in without touching routes. Extension recipe documented in architecture.md. - Dashboard YAML gains a required top-level `name:` key (local and portal). - Editor topbar reworked: hamburger Dashboards link + logo + editable title (two-way with the name: key), Save (shown only when unsaved; Cmd/Ctrl+S), Preview, an icon theme switch (A / sun / moon) in the profile menu, profile dropdown. The Run button and status text are gone: editing greys the (still interactive) preview and shows a centered Refresh overlay. - Fixes: row-height drags now persist for block-style rows; column resize now works on tabbed dashboards (previously searched flat .items only and no-op'd). New tests: test_portal, test_auth, test_editor_page (no live DB or browser). Bumps version to 0.4.0. --- .gitignore | 4 + CHANGELOG.md | 59 ++- CLAUDE.md | 13 +- Dockerfile | 2 +- README.md | 4 +- architecture.md | 147 +++++- docker-compose.yml | 38 ++ fireflyer/config_edit.py | 9 +- fireflyer/dashboard.py | 18 +- fireflyer/portal.py | 44 ++ fireflyer/web/app.py | 473 ++++++++++++++++-- fireflyer/web/auth.py | 199 ++++++++ fireflyer/web/chat.py | 6 +- fireflyer/web/portal.py | 317 ++++++++++++ portal.yaml | 9 + pyproject.toml | 5 +- .../test_dashboard_smart_example.html | 1 + .../test_tabbed_dashboard_snapshot.html | 3 +- tests/test_auth.py | 72 +++ tests/test_chat.py | 3 +- tests/test_config_edit.py | 62 ++- tests/test_dashboard.py | 10 + tests/test_editor_page.py | 48 ++ tests/test_number.py | 2 + tests/test_portal.py | 112 +++++ tests/test_tabs.py | 24 +- 26 files changed, 1591 insertions(+), 93 deletions(-) create mode 100644 fireflyer/portal.py create mode 100644 fireflyer/web/auth.py create mode 100644 fireflyer/web/portal.py create mode 100644 portal.yaml create mode 100644 tests/test_auth.py create mode 100644 tests/test_editor_page.py create mode 100644 tests/test_portal.py diff --git a/.gitignore b/.gitignore index 5c098de..be4f1bb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ # Secrets — never commit .env +# Portal mode's local sqlite fallback store (created by `python -m fireflyer.portal` +# when no DATABASE_URL is set) +portal.db + # Python .venv/ __pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a3f31c..6f3ba9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,62 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.0] - 2026-07-10 + +### Added + +- **Portal mode** — an opt-in, DB-backed way to store and browse many + dashboards, reusing the existing editor unchanged. Enabled with + `python -m fireflyer.portal` (reads `portal.yaml`) or the compose `portal` + profile. `/` becomes a gallery of stored dashboards — a table of name, + author, and last-updated with per-row **Edit / Clone / Remove** actions and a + **+ New dashboard** button; New and Clone each prompt for a name in a modal. + New dashboards start **blank**; each opens in the normal editor with a + **Save** button. Dashboards are stored as an opaque YAML text blob (validated + by `Dashboard.from_yaml` on save, never decomposed into tables), so every + stateless editor route keeps working byte-for-byte. Rows also carry an + **author** (the logged-in user, recorded at create/clone). +- **Portal login** — portal mode is gated behind a simple auth (`web/auth.py`), + default **admin/admin** (`FIREFLYER_USER`/`FIREFLYER_PASSWORD`); a topbar + **profile** dropdown shows the username with a **Log out** action. It's a deliberately small, + swappable backbone: an `Authenticator` protocol (the credential check) and an + HMAC-signed session cookie (how the identity is remembered) are independent, + so an SSO/OAuth callback just reuses `set_session` — no route changes. No + advanced provider is implemented; the extension recipe is documented in + `architecture.md`. Local single-dashboard mode has no login. +- **Editor topbar** reorganized — left: a **☰ Dashboards** link and the + Fireflyer **logo** (both link to the gallery in portal), then an **editable + dashboard title** after a dot separator (click to rename → rewrites the YAML + `name:` key, and editing `name:` in the YAML updates the title); right: + **Save**, Preview, + a **3-segment Auto / Light / Dark theme switch**, and the profile button. + **Save only appears when there are unsaved changes**, saves on click or + ⌘/Ctrl+S, and warns before you navigate away with unsaved edits. The theme + control is a **3-segment icon switch** (A / sun / moon for Auto / Light / + Dark, inline SVG, no text) — in the profile + dropdown in portal mode, standalone in the topbar in local mode. +- **Refresh-on-edit preview.** The topbar **Run** button and status text are + gone. Editing the YAML now greys out the (stale) preview and reveals a **↻ + Refresh** button over the output pane; clicking it re-renders. The greyed + preview stays **interactive** (row/column resize keeps working). Two resize + snap-back bugs were fixed: row-height drags now persist for **block-style** + dashboard rows (the height rewrite was flow-style only), and **column** drags + now persist on **tabbed** dashboards (`resize_columns` searched flat `.items` + only, which are empty when the layout is tabbed, so it silently no-op'd). Save + feedback shows on the Save button itself, and rare edit errors use a toast. +- **Required top-level `name:` key** in the dashboard YAML — the dashboard's + display name, part of the definition (not portal metadata), so it works the + same in local and portal mode. `Dashboard.from_yaml` now rejects a dashboard + with no (or empty) `name`. Portal lists dashboards by it and re-derives the + listing name from the YAML on every save (no separate name field); the + gallery's "new" form seeds the typed name into the YAML's `name:` key. The store lives in `fireflyer/web/portal.py` behind two + backends: stdlib **sqlite** (local/dev + tests) and **Postgres** + (`python -m fireflyer.portal`); the Postgres driver is an optional `.[portal]` + extra so the core install and test suite never require a database. Portal is + an owner-approved exception to the "no persistence/multi-user" anti-goal, + scoped to `web/`. Auth and per-dataset storage are intentionally out of scope + for this first cut. + ## [0.3.1] - 2026-07-09 ### Added @@ -85,7 +141,8 @@ production-ready. definition with the exact expected HTML in `tests/snapshots/`. - **Source-available license.** Apache-2.0 with the Commons Clause. -[Unreleased]: https://github.com/dankor/fireflyer/compare/v0.3.1...HEAD +[Unreleased]: https://github.com/dankor/fireflyer/compare/v0.4.0...HEAD +[0.4.0]: https://github.com/dankor/fireflyer/compare/v0.3.1...v0.4.0 [0.3.1]: https://github.com/dankor/fireflyer/compare/v0.3.0...v0.3.1 [0.3.0]: https://github.com/dankor/fireflyer/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/dankor/fireflyer/compare/v0.1.0...v0.2.0 diff --git a/CLAUDE.md b/CLAUDE.md index 7596a6c..9a07c9a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,13 +92,24 @@ The editor renders a hover **toolbar** on every chart (edit + delete buttons, on - **Adding to the layout**: the editor's left gutter shows hover **"+" buttons**. The per-row one (`.fireflyer-add-cell`) adds a chart to that row (the add modal). The insert-strip one (`.fireflyer-add-row`) opens a small **menu — chart / header / separator**: chart uses the add modal (`build_add_form` → `/chart/config/create`), while header and separator insert directly via `config_edit.insert_layout_item` → `/chart/config/insert-item` (a header defaults to text "New header"). A header can be **renamed in place** — double-click it in edit mode (`config_edit.set_header_text` → `/chart/config/header`, located by header index). **Headers and separators also get the hover toolbar** (compact badge, top-right) with **move / edit / delete**, addressed by their **layout-item index** (not a chart id): move → `config_edit.move_layout_item` (`/chart/config/move-item`), delete → `config_edit.delete_layout_item` (`/chart/config/delete-item`, confirm dialog). Edit is **header-only** (opens the same inline rename) — a separator has **no edit button** (and its wrapper gets padding so the thin `
` is hoverable; the badge is centred on the item's top edge). Header edit mirrors move mode's focus feel: the dashboard gets `.ff-focus-mode` (dims everything but the edited header, suppresses hover UI, hides add strips) and the same topbar cancel button shows — `currentHeaderFinish` lets the button (mousedown+`preventDefault` so it cancels instead of blur-saving) or Esc restore the original; Enter/blur saves. Their **move is between-rows only**: it skips `buildMoveZones` and reuses the lit add-row strips as the sole drop targets (`enterItemMove` also hides the two strips flanking the moved item, which would be no-op drops; the shared move state is `moveCid` for charts vs `moveItemIndex` for items, unified by `inMove()`). Charts are rearranged via a **move mode** (not native drag-and-drop): a chart's **move** button lights the chart, dims the rest, and turns off every other interaction (resize, edit, add, crossfilter). Every valid spot lights up as a **blue drop-zone box** — a client-built overlay of column zones (before/after each cell; geometry-derived so merged/spanning cells align) plus **add-row strips in every gap between layout items** (drop into a new row there — `data-before` is a **layout-item index** into the full rows+headers+separators list, so it works around headers and separators too, not just between rows). The hovered box goes solid as a placement preview. The zones follow the **8 move-mode rules documented in `architecture.md`** — the client build is in `buildMoveZones`: side zones on every chart's left/right edge (`move_placement` → `/chart/config/move`); **common borders dedup** to one drop (`dedupBorders` keeps the taller candidate, so a **merged** chart's full-height side wins → dropping there adopts the span); between-rows via the add-row strips/internal gaps (`move_to_new_row` → `/chart/config/new-row`); a single **merge-down** bar (`.ff-move-zone-span`) down the *moved* chart's centre into the row below — `config_edit.merge_down` → `/chart/config/merge-down` adds a bare occurrence so *that chart's own* span grows down one row (only the moved chart, only downward, only one per dashboard); and the moved chart gets **no side zones**, nor its shared borders — **except** a **merged** moved chart, whose shared borders stay as **per-row unmerge** zones (`srcMerged` gate); dropping one is a plain `move_placement` that lands it single-row in that row (span removed). `dedupBorders` never collapses a border involving a merged chart. `move_span` still exists (place a chart spanning a target's whole span + 1 below) and is tested, but the editor's merge gesture now uses `merge_down`. If moving a member out breaks a span, `_finalize` repairs it by collapsing the broken span into its fullest remaining row. Inserts use **width 1** (`:1`); the first row's sizes drive the layout. **Column resize** on a merge group posts to `config_edit.resize_columns` (route `/chart/config/resize-columns`): it recomputes each cell's width from the fine (union) columns it spans, so dragging a boundary updates every row those columns belong to — even from an inherited/lower row — and spanning cells stay bare. Esc/Cancel exits; an emptied source row is dropped. Move-mode clicks/mousedowns are captured (`stopPropagation`) so nothing else fires. `add_chart` generates a unique id, appends the chart block, and splices a placement into the `dashboard:` list (flow-style rows only). All gated by `editing`. - To add a widget type: implement a `Param` subclass in `params.py`, then reference it from a chart's `PARAMS`. Follow [`fireflyer/PARAM_SKILL.md`](fireflyer/PARAM_SKILL.md) — the guide to the `Param` contract, wiring a chart's `PARAMS`, the surgical save path, and the sync-guard test (the `param` Claude Code skill points at this same file). Pure logic lives in `params.py`/`config_edit.py` (not `app.py`) so it unit-tests without the web stack. +## Portal mode (`fireflyer/web/portal.py`, `fireflyer/portal.py`) + +Opt-in, editor-only. An **owner-approved exception** to the "no persistence/multi-user" anti-goal, scoped to `web/` (same status as the AI assistant and `params.py`). It stores many dashboards in a database and lists them in a gallery, **reusing the existing editor unchanged**. + +- **The editor is already stateless** — every `/chart/config/*`, `/execute`, `/dashboard` route takes YAML text in from the browser and returns new YAML; nothing is persisted server-side. Portal only wraps this with a persistence + listing layer, so no existing edit logic changes. +- **Enable it** with `FIREFLYER_PORTAL=1` (the `python -m fireflyer.portal` entrypoint sets it, reads `portal.yaml`, and binds `0.0.0.0`). Off by default: `/` is the usual single-dashboard editor. On: `/` is a **gallery** — a table (name, author, last updated) with per-row **Edit / Clone / Remove** and a top **+ New dashboard** button; New and Clone each prompt for a name in a native `` modal (the gallery carries a little vanilla JS, allowed as editor chrome). Routes, all **UUID**-addressed: `POST /new` creates a **blank** dashboard (`_empty_yaml`, valid but no datasets/charts/layout) with the modal name + author; `POST /d/{id}/clone` copies one under a new name (`_set_yaml_name` rewrites the `name:` line); `GET /d/{id}` opens the editor seeded with that row's YAML; `POST /d/{id}/save` validates + persists; `POST /d/{id}/delete` removes. The editor **topbar** has left/right groups filled via `INDEX` placeholders — **left**: `__FF_NAV__` (☰ Dashboards link → `/`, portal) + `__FF_BRAND__` (Fireflyer logo; a link to `/` in portal, a plain span locally) + `__FF_DASH_NAME__` (`#ff-dash-name`, after a dot separator `.ff-sep`) — an **editable** title (`contenteditable`, capped width + ellipsis) two-way-bound to the YAML `name:` key (`yamlName`/`setYamlName`: click to rename → rewrites `name:`; edit `name:` in the YAML → title updates); **right**: `__FF_SAVE__`, Preview, `__FF_THEME__` (theme switch), `__FF_USER_MENU__` (profile). **Save** (`#ff-save`, class `run`) is **hidden until there are unsaved changes** (`updateSaveState()` compares `codeEl.value` to `savedYaml`; distinct from preview-*stale*), saves on click or ⌘/Ctrl+S, and a `beforeunload` guard warns if you navigate away dirty. The **theme switch** (`_theme_switch`, `#theme-switch`) is a **3-segment icon control** — inline-SVG **A / sun / moon** for Auto / Light / Dark, no text labels (`title`/`aria-label` carry meaning; icons use `stroke=currentColor` so they follow the segment colour); in **portal** mode it lives inside the profile dropdown (passed as `_user_menu(..., extra=)`, so `__FF_THEME__` is empty), and in **local** mode (no profile) it's standalone in the topbar (`__FF_THEME__`). Exactly one `#theme-switch` per page. There is **no Run button or status text**: instead the output pane (`#output-pane`, class `output`) shows a **↻ Refresh** overlay (`#refresh`, centered both axes, `clamp`-sized) that appears — over a greyed-out, stale preview — only after a **manual** YAML edit (`codeEl` `input` → `markStale()` adds `.stale`); `run()` re-renders and clears it. The stale preview is greyed **but stays interactive** (`.stale .pane-body` must *not* set `pointer-events: none` — that blocked the row/column resize handles; the resize/move handlers read the live textarea and re-render on release, so acting on a stale preview is consistent). Programmatic edits (chat, config-edit) call `run()` directly so never go stale. Rare config-edit error messages use a transient bottom toast (`#ff-toast`, `flash()`) instead of the old status line. +- **The dashboard name is the YAML's required top-level `name:` key** — part of the definition, not portal metadata, so local and portal mode share one format. `Dashboard.from_yaml` **requires** a non-empty `name` (parsed into `Dashboard.name`; missing/empty raises `DashboardError`). Name is a **DB column** but re-derived from the YAML on every `create(yaml, author)`/`save(id, yaml)`, so editing the `name:` key in the editor renames the listing; New/Clone write the modal name into the YAML. **`author`** is a separate column (the logged-in user via `_current_author`, set at create/clone, untouched by saves). `web/chat.py`'s DSL prompt teaches `name:` so the assistant emits it, and `architecture.md` documents it under "File shape" and the "Portal mode" section. +- **Dashboards are stored as an opaque YAML text blob**, validated by `Dashboard.from_yaml` on save — never decomposed into normalized tables (that would break the surgical-edit / comment-preservation design). Datasets stay inline in the YAML for now (CSV paths on the server filesystem); per-dataset storage is deliberately out of scope for the first cut. +- **Two stores in `web/portal.py`**: stdlib **`SqliteStore`** (in-memory for tests, a local file for dev) and **`PostgresStore`** (runtime; imports `psycopg` lazily). `make_store(dsn)` picks by DSN. The driver is an optional **`.[portal]`** extra, so `pip install -e ".[test]"` and CI stay database-free. Store logic + the gallery HTML live in `portal.py` (not `app.py`) so they **unit-test without the web stack** — `tests/test_portal.py` uses in-memory sqlite and never touches a live DB (same rule as the chat tests). +- **Auth (`web/auth.py`)** — portal mode is gated behind a login; default **admin/admin** (`FIREFLYER_USER`/`FIREFLYER_PASSWORD`). Intentionally minimal, built on **two independent seams** so SSO/LDAP drop in without touching routes: (1) the `Authenticator` protocol — `verify(user, pass) -> identity | None`, default `PasswordAuthenticator`, swap via `app.state.authenticator`; (2) an HMAC-signed session cookie (`set_session`/`current_user`/`clear_session`, secret from `FIREFLYER_SECRET`), independent of *how* the identity was proven — an SSO callback just calls `set_session`. A middleware guard redirects anon requests to `/login`; routes are `GET/POST /login`, `POST /logout`. The **profile button** (`auth.user_menu(identity, extra="")`) is a native `
` dropdown (username → optional `extra` — the editor passes the theme switch → **Log out**), styled by `auth.PROFILE_CSS` (injected into both the editor `INDEX` and the gallery, since there's no shared stylesheet); it sits top-right in the gallery (`render_gallery(..., user_menu)`, no `extra`) and the editor (`__FF_USER_MENU__` slot). Auth is on iff portal is; local mode has no login. Pure functions in `auth.py`, unit-tested without the web stack (`tests/test_auth.py`). **The SSO/OAuth extension recipe is in `architecture.md` → "Portal mode → Authentication".** + ## Non-negotiable constraints (from architecture.md) Explicit anti-goals. Do not add them, even if they seem like good engineering: - **No abstractions for future flexibility.** No service layers, repositories, registries, plugin frameworks, or DI containers until actually needed. The dashboard's `type → class` lookup is a plain dict — keep it that way. (One **deliberate, owner-approved exception**: the `params.py` widget layer, which exists to power the editor's edit modal. It earns its keep — don't take it as license for more abstractions.) - **No frontend tooling.** No npm, webpack, vite, tailwind, or bootstrap. PicoCSS-compatible markup and inline SVG only. -- **No production concerns.** No auth, multi-user, caching, streaming, large-dataset optimization, or realtime updates. +- **No production concerns.** No auth, multi-user, caching, streaming, large-dataset optimization, or realtime updates. (One **owner-approved exception**: **portal mode** — see below — adds DB persistence + a dashboard listing, scoped to `web/`. It does *not* license auth, caching, or the rest.) - **No chart features beyond the MVP spec.** Aggregation is count-only for pie/bar/map. Joins, calculated columns, SQL, and export are out of scope. Table reads at most the first 1000 rows. When in doubt: less code, fewer abstractions, hardcoded behavior, developer experience over architectural purity. The MVP is expected to be rewritten — if a solution feels generic, configurable, or extensible, it's probably wrong for this stage. diff --git a/Dockerfile b/Dockerfile index 4993763..2a283dc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ WORKDIR /app # them present at build time. COPY pyproject.toml LICENSE README.md ./ COPY fireflyer ./fireflyer -RUN pip install --no-cache-dir -e ".[test]" +RUN pip install --no-cache-dir -e ".[test,portal]" # Sample data the default dashboard references (files/orders.csv). COPY files ./files diff --git a/README.md b/README.md index 8f24d41..f5c0c99 100644 --- a/README.md +++ b/README.md @@ -100,9 +100,11 @@ Each chart's full options live in its spec: [`fireflyer/chart//spec.md`](f ## Dashboards -A dashboard is **one YAML file** that declares its datasets, its charts, and how they lay out on a page: +A dashboard is **one YAML file** that declares its name, its datasets, its charts, and how they lay out on a page: ```yaml +name: Orders overview + datasets: orders: path: files/orders.csv diff --git a/architecture.md b/architecture.md index 0e16bae..0d1d96b 100644 --- a/architecture.md +++ b/architecture.md @@ -1,8 +1,8 @@ -# Firefly MVP Architecture +# Fireflyer MVP Architecture ## Primary Goal -Firefly is a Python library for rapidly transforming CSV files into HTML visualizations. +Fireflyer is a Python library for rapidly transforming CSV files into HTML visualizations. The primary goal is developer experience. @@ -31,7 +31,7 @@ The MVP is expected to be rewritten. # Vision -Firefly provides a simple way to visualize CSV files using Python. +Fireflyer provides a simple way to visualize CSV files using Python. Example: @@ -48,7 +48,7 @@ chart The user writes Python code. -Firefly reads the CSV file, generates HTML, and displays the result. +Fireflyer reads the CSV file, generates HTML, and displays the result. The user does not interact with Polars directly. @@ -85,6 +85,10 @@ Not supported: * Large dataset optimization * Realtime updates +Persistence and a multi-dashboard listing are the one **owner-approved +exception** — added by **Portal mode** (below), scoped to `web/`. It does *not* +relax the rest of this list: no auth, no multi-user, no caching. + --- # Core Flow @@ -241,9 +245,11 @@ The Python API (`ff.chart.table(...)`, `ff.chart.pie(...)`) stays for ad-hoc ren ## File shape -A dashboard YAML has exactly three top-level sections: +A dashboard YAML has four top-level sections: ```yaml +name: + datasets: : @@ -255,6 +261,9 @@ dashboard: - ``` +* `name` — required. A short human-readable title for the whole dashboard, + part of the definition. `Dashboard.from_yaml` rejects a missing or empty + `name`; **Portal mode** lists dashboards by it. * `datasets` — mapping of dataset id → dataset config. * `charts` — mapping of chart id → chart config. * `dashboard` — the page layout (the layout DSL, below). Either a flat list of @@ -494,6 +503,8 @@ re-validated through `Dashboard.from_yaml`. ## Complete example ```yaml +name: Orders overview + datasets: orders: path: files/orders.csv @@ -612,7 +623,7 @@ Fireflyer ships a **light and a dark palette**. Colors are never hardcoded in ru The editor exists only to improve the development experience. -It is not part of the Firefly core architecture. +It is not part of the Fireflyer core architecture. The editor is a temporary development tool. @@ -644,6 +655,130 @@ No realtime execution. --- +# Portal mode + +Portal mode is an **opt-in** way to store many dashboards in a database and +browse them from a gallery, instead of hand-editing one YAML file. It is an +**owner-approved exception** to the "no persistence / no multiple dashboards" +anti-goal, and — like the editor and the AI assistant — it is **editor-only**, +scoped to `web/`. It is not part of the Fireflyer core. It does not add +authentication, multiple *users*, or caching; those stay out of scope. + +## Enabling it + +Off by default: `python -m fireflyer.web` serves the usual single-dashboard +editor at `/`. Portal mode is switched on by the `FIREFLYER_PORTAL` environment +variable; the `python -m fireflyer.portal` entrypoint sets it, reads runtime +config from `portal.yaml` (title, database url; the environment overrides the +file), and binds `0.0.0.0` for containers. With it on, `/` becomes the gallery. + +## It reuses the editor unchanged + +The editor is already **stateless**: every edit route (`/execute`, +`/dashboard`, `/chart/config/*`) takes the current YAML text in from the browser +and returns new YAML — nothing is persisted server-side. Portal mode only wraps +this with a persistence and listing layer, so **no existing edit logic changes**. + +New routes, all gated behind the portal flag and addressing dashboards by +**UUID**: + +* `GET /` — the gallery: a **table** of stored dashboards (name, author, last + updated) with per-row **Edit / Clone / Remove** actions and a **+ New + dashboard** button. New and Clone each prompt for a name in a small modal + (native ``). +* `POST /new` — create a **blank** dashboard with the given name (a valid but + empty YAML: no datasets, charts, or layout) and open it. +* `POST /d/{id}/clone` — copy an existing dashboard under a new name and open + the copy. +* `GET /d/{id}` — the normal editor page, seeded with that dashboard's stored + YAML and given a **Save** button (and a link back to the gallery). +* `POST /d/{id}/save` — validate and persist the edited YAML. +* `POST /d/{id}/delete` — remove it. + +## Storage model + +A dashboard is stored as an **opaque YAML text blob** — the same YAML the editor +already produces — never decomposed into normalized tables. Decomposing it would +break the surgical, comment-preserving block edits `config_edit.py` relies on. +On every write the backend validates the YAML with `Dashboard.from_yaml`; an +invalid dashboard is rejected and nothing is stored. + +The dashboard's **name** is stored in its own column but is the source-of-truth +top-level `name:` key (see **File shape**): the store re-derives the column from +the YAML on every save, so renaming is just editing the `name:` key in the +editor, and New/Clone write the modal-provided name into the YAML. **Author** is +separate metadata (not in the YAML) — the logged-in user, set once at +create/clone and left untouched by saves. Datasets stay inline in the YAML (CSV +paths on the server filesystem); per-dataset storage is deliberately out of +scope for the first cut. + +## Two stores, one schema + +The store lives in `fireflyer/web/portal.py` behind two interchangeable +backends over one small table (`id, name, author, yaml, created_at, +updated_at`): + +* **sqlite** (stdlib) — powers local dev (a file) and the test suite + (in-memory). No service, no driver. +* **Postgres** (`psycopg`) — the runtime backend for + `python -m fireflyer.portal`, selected when a `DATABASE_URL` is present. + +The Postgres driver is an optional `.[portal]` extra and is imported lazily, so +the core install and `pip install -e ".[test]"` never require a database. Store +logic and the gallery HTML are pure functions in `portal.py` (not `app.py`), so +they unit-test without the web stack — the tests use in-memory sqlite and never +touch a live database, the same rule the AI-assistant tests follow. + +## Authentication + +Portal mode is gated behind a login (`fireflyer/web/auth.py`). The default is a +single hardcoded user — **admin / admin**, overridable with `FIREFLYER_USER` / +`FIREFLYER_PASSWORD`. This is intentionally minimal; it is *not* hardened +production auth. It exists so the portal isn't wide open, and so richer schemes +have a clean place to plug in. Auth is on whenever portal mode is; local +single-dashboard mode has no login. + +The design is built from **two independent seams**, and that separation is the +whole point — it's what makes advanced schemes easy: + +1. **Who is allowed in** — the `Authenticator` protocol. Its one method, + `verify(username, password) -> identity | None`, is the credential check. + The default `PasswordAuthenticator` compares against the configured user. + Swap it for anything — an LDAP bind, a database user table, an API-key + lookup — by implementing that single method and setting + `app.state.authenticator`. +2. **How the identity is remembered** — a session in an HMAC-signed cookie + (`set_session` / `current_user` / `clear_session`), signed with + `FIREFLYER_SECRET`. This is completely independent of *how* the identity was + proven. + +A single middleware guard redirects any unauthenticated request to `/login`; +`GET/POST /login` and `POST /logout` are the only auth routes. The topbar shows +the signed-in username and a **Log out** button, in both the gallery and the +editor. + +### Adding SSO / OAuth (not implemented — the recipe) + +Because "how you remember the identity" is separate from "how you proved it," an +external-IdP flow (Google, Okta, SAML, OIDC…) slots in **without touching the +portal routes or the guard**: + +1. Add a provider route pair — e.g. `GET /login/oauth` that redirects to the + IdP, and `GET /auth/callback` that exchanges the code and verifies the token. +2. In the callback, on success call `set_session(response, )` — the + same session layer the password flow already uses. +3. Point the login page's button at your provider route instead of (or + alongside) the password form. + +The guard, logout, `current_user`, and every portal route stay exactly as they +are: they only ever ask "is there a valid session?", never "how did you log +in?". Enterprise concerns beyond this seam — per-user ownership/sharing (extend +the store's rows with the identity `current_user` returns), roles, token +refresh — are deliberately out of scope for the MVP; the seams are here so they +can be added without a rewrite. + +--- + # Testing Tests are snapshot-based. diff --git a/docker-compose.yml b/docker-compose.yml index 513cd19..adf6639 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,3 +18,41 @@ services: command: > uvicorn fireflyer.web.app:app --host 0.0.0.0 --port 8000 --reload + + # Portal mode: `docker compose --profile portal up --build`. Brings up Postgres + # and serves the dashboard gallery on http://127.0.0.1:8001 (8001 so it can run + # alongside the default single-dashboard editor on 8000). + postgres: + image: postgres:16 + profiles: ["portal"] + environment: + POSTGRES_USER: fireflyer + POSTGRES_PASSWORD: fireflyer + POSTGRES_DB: fireflyer + volumes: + - pgdata:/var/lib/postgresql/data + + portal: + build: . + profiles: ["portal"] + depends_on: + - postgres + env_file: + - path: .env + required: false + environment: + FIREFLYER_PORTAL: "1" + DATABASE_URL: postgresql://fireflyer:fireflyer@postgres:5432/fireflyer + # Login defaults to admin/admin. Override user/pass and set a real session + # secret for anything beyond local use (FIREFLYER_USER/PASSWORD/SECRET). + FIREFLYER_SECRET: dev-portal-secret-change-me + ports: + - "8001:8000" + volumes: + - ./fireflyer:/app/fireflyer + - ./files:/app/files + - ./tests:/app/tests + command: python -m fireflyer.portal + +volumes: + pgdata: diff --git a/fireflyer/config_edit.py b/fireflyer/config_edit.py index b4eaf24..960e9b7 100644 --- a/fireflyer/config_edit.py +++ b/fireflyer/config_edit.py @@ -1074,8 +1074,15 @@ def resize_columns(text: str, ordinals: list[int], widths: list[float]) -> str: widths = [float(w) for w in widths] if not ordinals or not widths: return text + # Row ordinals are global across tabs, so search flat items *and* every tab's + # items — otherwise a column drag on a tabbed dashboard finds no group and + # silently no-ops (the widths snap back). + dash = Dashboard.from_yaml(text) + layout_items = ( + dash.items if dash.tabs is None else [it for t in dash.tabs for it in t.items] + ) group = next( - (it for it in Dashboard.from_yaml(text).items + (it for it in layout_items if hasattr(it, "placements") and [o for o, _ in it.tracks] == ordinals), None, ) diff --git a/fireflyer/dashboard.py b/fireflyer/dashboard.py index a319820..658eb26 100644 --- a/fireflyer/dashboard.py +++ b/fireflyer/dashboard.py @@ -134,6 +134,10 @@ class Dashboard: # `dashboard:` section is a mapping of tab name -> layout list. tabs: list[_Tab] | None = None yaml_source: str = "" + # Optional top-level `name:` — the dashboard's display name, part of the + # definition (not portal metadata). Portal lists dashboards by it; local + # mode just carries it. Empty when the key is absent. + name: str = "" @classmethod def from_yaml(cls, text: str) -> "Dashboard": @@ -146,10 +150,14 @@ def from_yaml(cls, text: str) -> "Dashboard": raise DashboardError( "top-level must be a mapping with keys: datasets, charts, dashboard" ) - for key in ("datasets", "charts", "dashboard"): + for key in ("datasets", "charts", "dashboard", "name"): if key not in config: raise DashboardError(f"missing top-level key: {key!r}") + name = config["name"] + if not isinstance(name, str) or not name.strip(): + raise DashboardError("top-level `name` must be a non-empty string") + datasets = _parse_datasets(config["datasets"]) chart_configs = _parse_charts(config["charts"], datasets) @@ -160,12 +168,16 @@ def from_yaml(cls, text: str) -> "Dashboard": # dashboard (span-aware), so the move machinery — which pulls a # chart from every row it's in — stays sound across tabs. _validate_unique_placements([g for t in tabs for g in t.items]) - return cls(chart_configs=chart_configs, tabs=tabs, yaml_source=text) + return cls( + chart_configs=chart_configs, tabs=tabs, yaml_source=text, name=name + ) items = _parse_layout(raw_dashboard, chart_configs) items = _group_layout(items) _validate_unique_placements(items) - return cls(chart_configs=chart_configs, items=items, yaml_source=text) + return cls( + chart_configs=chart_configs, items=items, yaml_source=text, name=name + ) def _tab_context(self, active_tab: int): """(tabs_meta | None, clamped_active, active_items). diff --git a/fireflyer/portal.py b/fireflyer/portal.py new file mode 100644 index 0000000..066a3ff --- /dev/null +++ b/fireflyer/portal.py @@ -0,0 +1,44 @@ +"""`python -m fireflyer.portal` — launch the editor in portal mode. + +Portal mode stores dashboards in a database and lists them in a gallery (see +`fireflyer.web.portal` for the store itself). This entrypoint reads runtime +config from `portal.yaml` (title, database url), lets the environment override +it (`DATABASE_URL`, `FIREFLYER_PORTAL_TITLE`), flips `FIREFLYER_PORTAL` on, and +serves on 0.0.0.0 for containers. The plain `python -m fireflyer.web` +entrypoint stays on 127.0.0.1 in single-dashboard mode. +""" + +import os +from pathlib import Path + +import uvicorn +import yaml + + +def _load_config() -> dict: + path = Path(os.environ.get("FIREFLYER_PORTAL_CONFIG", "portal.yaml")) + if path.exists(): + return yaml.safe_load(path.read_text()) or {} + return {} + + +def main() -> None: + cfg = _load_config() + os.environ["FIREFLYER_PORTAL"] = "1" + # Env wins over the file, so a container can override without editing yaml. + if cfg.get("title") and "FIREFLYER_PORTAL_TITLE" not in os.environ: + os.environ["FIREFLYER_PORTAL_TITLE"] = cfg["title"] + if cfg.get("database_url") and "DATABASE_URL" not in os.environ: + os.environ["DATABASE_URL"] = cfg["database_url"] + + uvicorn.run( + "fireflyer.web.app:app", + host="0.0.0.0", + port=8000, + reload=True, + reload_includes=["*.py", "*.css", "*.html"], + ) + + +if __name__ == "__main__": + main() diff --git a/fireflyer/web/app.py b/fireflyer/web/app.py index 65eb94e..bb1a00b 100644 --- a/fireflyer/web/app.py +++ b/fireflyer/web/app.py @@ -1,5 +1,6 @@ import json import os +import re import traceback from html import escape @@ -12,15 +13,47 @@ from fireflyer import filters as filters_mod from fireflyer.chart.map.chart import Map from fireflyer.chart.table.chart import Table +from fastapi.responses import RedirectResponse from fireflyer.dashboard import Dashboard, DashboardError +from fireflyer.web import auth as auth_mod from fireflyer.web import chat as chat_mod +from fireflyer.web import portal as portal_mod # Load .env (ANTHROPIC_API_KEY) before reading it. The AI assistant is enabled # only when a key is present; otherwise the editor shows a setup notice. load_dotenv() CHAT_ENABLED = bool(os.environ.get("ANTHROPIC_API_KEY")) +# Portal mode (owner-approved exception to the no-persistence anti-goal, scoped +# to web/): when FIREFLYER_PORTAL is set, `/` becomes a gallery of dashboards +# stored in a DB and each opens in the existing editor. A DATABASE_URL selects +# Postgres; otherwise a local sqlite file. Off by default — `/` is the usual +# single-dashboard editor. Tests set `app.state.store` directly. +PORTAL_ENABLED = bool(os.environ.get("FIREFLYER_PORTAL")) +PORTAL_TITLE = os.environ.get("FIREFLYER_PORTAL_TITLE", "Fireflyer Portal") + app = FastAPI() +app.state.store = ( + portal_mod.make_store(os.environ.get("DATABASE_URL")) if PORTAL_ENABLED else None +) +# Portal mode is gated behind a login. `authenticator` is the swappable +# credential check (default: admin/admin); None disables auth entirely (local +# mode). Tests set both `store` and `authenticator` directly. +app.state.authenticator = auth_mod.default_authenticator() if PORTAL_ENABLED else None + + +@app.middleware("http") +async def _require_login(request: Request, call_next): + """When auth is on, every route except the login page requires a session; + unauthenticated requests are redirected to /login.""" + auth = app.state.authenticator + if ( + auth is not None + and request.url.path != "/login" + and auth_mod.current_user(request) is None + ): + return RedirectResponse("/login", status_code=303) + return await call_next(request) # Pinned htmx version. Loaded once on the editor page so charts embedded via # innerHTML can use hx-* attributes without each chart shipping its own script. @@ -28,7 +61,9 @@ # Starter — exercises every layout element: header, two-chart row, separator, # single-chart row. Small enough to read at a glance. -DEFAULT_YAML = """datasets: +DEFAULT_YAML = """name: Orders overview + +datasets: orders: path: files/orders.csv @@ -165,12 +200,39 @@ font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Helvetica, Arial, sans-serif; color: var(--text); background: var(--bg); }} .topbar {{ - position: relative; height: 44px; display: flex; align-items: center; gap: 16px; + position: relative; height: 44px; display: flex; align-items: center; + justify-content: space-between; padding: 0 16px; background: var(--panel); border-bottom: 1px solid var(--border); }} - .topbar .brand {{ font-weight: 600; font-size: 14px; letter-spacing: -0.01em; }} + .topbar-left, .topbar-right {{ display: flex; align-items: center; gap: 14px; }} + .topbar .brand {{ font-weight: 600; font-size: 14px; letter-spacing: -0.01em; + color: var(--text); text-decoration: none; }} + a.brand:hover {{ color: var(--accent); }} + .topbar .ff-nav {{ display: inline-flex; align-items: center; justify-content: center; + width: 30px; height: 30px; border-radius: 5px; color: var(--text); + text-decoration: none; font-size: 16px; }} + .topbar .ff-nav:hover {{ background: var(--bg); }} + /* Dashboard title in the left group, after the logo (separated by a dot). + Click-to-rename: editable in place; capped width with ellipsis so a long + name doesn't push the right group (grows to full text while focused). */ + .topbar .ff-sep {{ color: var(--muted); }} + .topbar .ff-dash-name {{ font-size: 14px; font-weight: 600; color: var(--text); + white-space: nowrap; max-width: 340px; overflow: hidden; text-overflow: ellipsis; + padding: 3px 8px; border-radius: 4px; cursor: text; outline: none; }} + .topbar .ff-dash-name:hover {{ background: var(--bg); }} + .topbar .ff-dash-name:focus {{ background: var(--bg); overflow: visible; max-width: none; + box-shadow: 0 0 0 2px var(--accent); }} + /* 3-segment icon theme switch: Auto (A) / Light (sun) / Dark (moon). */ + .topbar .ff-theme {{ display: inline-flex; border: 1px solid var(--border); + border-radius: 6px; overflow: hidden; }} + .topbar .ff-theme button {{ background: var(--panel); color: var(--muted); border: 0; + border-left: 1px solid var(--border); padding: 5px 8px; cursor: pointer; + display: inline-flex; align-items: center; }} + .topbar .ff-theme button:first-child {{ border-left: 0; }} + .topbar .ff-theme button svg {{ width: 16px; height: 16px; display: block; }} + .topbar .ff-theme button:hover {{ background: var(--bg); color: var(--text); }} + .topbar .ff-theme button.active {{ background: var(--accent); color: #fff; }} .topbar .toggle {{ - margin-left: auto; background: var(--panel); color: var(--text); border: 1px solid var(--border); padding: 5px 12px; border-radius: 4px; font-size: 12px; cursor: pointer; }} @@ -181,7 +243,30 @@ }} .topbar .run:hover {{ background: var(--accent-hover); }} .topbar .run:disabled {{ opacity: 0.6; cursor: not-allowed; }} - .topbar #status {{ font-size: 12px; color: var(--muted); }} +{auth_mod.PROFILE_CSS} + /* Output pane: refresh overlay shown when the YAML is edited but not re-run. */ + .pane.output {{ position: relative; }} + /* Greyed as a "stale" cue, but still interactive — the resize/move/edit + handlers read the live textarea and re-render on release, so acting on a + stale preview stays consistent (and blocking it broke vertical resize). */ + .pane.output.stale .pane-body {{ opacity: 0.55; filter: grayscale(0.35); + transition: opacity 0.12s; }} + /* Centered in the output pane (both axes) and sized responsively via clamp, + so it stays a big, obvious target at any pane width. */ + .ff-refresh {{ display: none; position: absolute; top: 50%; left: 50%; + transform: translate(-50%, -50%); z-index: 6; align-items: center; gap: 10px; + background: var(--accent); color: #fff; border: 0; + padding: clamp(10px, 1.6vw, 18px) clamp(20px, 2.6vw, 34px); + border-radius: 10px; font-size: clamp(15px, 1.4vw, 20px); font-weight: 600; + cursor: pointer; white-space: nowrap; max-width: calc(100% - 32px); + box-shadow: 0 8px 26px rgba(0,0,0,0.32); transition: background 0.12s, transform 0.08s; }} + .ff-refresh:hover {{ background: var(--accent-hover); }} + .ff-refresh:active {{ transform: translate(-50%, -50%) scale(0.97); }} + .pane.output.stale .ff-refresh {{ display: inline-flex; }} + /* Transient error toast (bottom-centre). */ + .ff-toast {{ position: fixed; bottom: 18px; left: 50%; transform: translateX(-50%); + background: var(--error); color: #fff; padding: 9px 16px; border-radius: 6px; + font-size: 13px; z-index: 50; box-shadow: 0 6px 20px rgba(0,0,0,0.25); }} .layout {{ display: grid; grid-template-columns: 1fr 5px 1fr; background: var(--border); @@ -352,17 +437,23 @@
- Fireflyer - - - - +
+ __FF_NAV__ + __FF_BRAND__ + __FF_DASH_NAME__ +
+
+ __FF_SAVE__ + + __FF_THEME__ + __FF_USER_MENU__ +
- +
-
+
+ +
@@ -384,30 +477,43 @@
+ + """ + + +def _row(row: DashboardRow) -> str: + updated = escape(row.updated_at.replace("T", " ")[:16]) + author = escape(row.author) if row.author else "—" + name = escape(row.name) + return ( + "" + f'{name}' + f"{author}" + f'{updated}' + '' + f'Edit' + f'' + f'
" + '
' + "" + ) + + +def _dialog(dialog_id: str, form_id: str, action: str, heading: str, ok_label: str) -> str: + # `action` is empty for the clone dialog — set by JS from the clicked row. + return f""" + +
+

{heading}

+ +
+ + +
+
+
""" + + +def render_gallery( + rows: list[DashboardRow], title: str = "Fireflyer Portal", user_menu: str = "" +) -> str: + if rows: + body = ( + '' + "" + "" + + "".join(_row(r) for r in rows) + + "
NameAuthorLast updated
" + ) + else: + body = '
No dashboards yet. Create one with + New dashboard.
' + # `user_menu` (username + logout) is right-aligned in the flex topbar. + menu = f'{user_menu}' if user_menu else "" + add_dialog = _dialog("add-dialog", "add-form", "/new", "New dashboard", "Create") + # Clone dialog id must match the input id JS targets (clone-name); build it + # with a fixed input id rather than the "{form_id}-name" convention. + clone_dialog = _dialog("clone-dialog", "clone-form", "", "Clone dashboard", "Clone") + clone_dialog = clone_dialog.replace('id="clone-form-name"', 'id="clone-name"') + return f""" + + + +{escape(title)} + + + +
{escape(title)}{menu}
+
+ + {body} +
+{add_dialog} +{clone_dialog} +{_GALLERY_JS} + +""" diff --git a/portal.yaml b/portal.yaml new file mode 100644 index 0000000..dde91a0 --- /dev/null +++ b/portal.yaml @@ -0,0 +1,9 @@ +# Runtime config for portal mode (`python -m fireflyer.portal`). +# The environment overrides every value here — DATABASE_URL and +# FIREFLYER_PORTAL_TITLE take precedence, so containers need not edit this file. + +title: Fireflyer Portal + +# Postgres connection string. Leave unset to fall back to a local sqlite file +# (portal.db) so you can try portal mode without standing up a database. +# database_url: postgresql://fireflyer:fireflyer@localhost:5432/fireflyer diff --git a/pyproject.toml b/pyproject.toml index 4d6c700..c90b769 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fireflyer" -version = "0.3.1" +version = "0.4.0" description = "Turn CSVs into HTML visualizations." readme = "README.md" requires-python = ">=3.11" @@ -22,6 +22,9 @@ dependencies = [ [project.optional-dependencies] test = ["pytest>=8.0"] +# Portal mode only: the Postgres driver. Kept out of the core install and the +# test suite (which uses stdlib sqlite) so neither needs a database driver. +portal = ["psycopg[binary]>=3.1"] [build-system] requires = ["hatchling"] diff --git a/tests/snapshots/test_dashboard_smart_example.html b/tests/snapshots/test_dashboard_smart_example.html index ea14e23..6625c59 100644 --- a/tests/snapshots/test_dashboard_smart_example.html +++ b/tests/snapshots/test_dashboard_smart_example.html @@ -693,6 +693,7 @@
- admin") + assert 'action="/logout"' in html + assert "admin" not in html + assert "<b>admin</b>" in html diff --git a/tests/test_chat.py b/tests/test_chat.py index 3e11e72..eb21048 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -55,7 +55,8 @@ def install(responses): def _valid_yaml(csv_path): - return f"""datasets: + return f"""name: Test dashboard +datasets: orders: path: {csv_path} charts: diff --git a/tests/test_config_edit.py b/tests/test_config_edit.py index 7a0582d..b92bfa1 100644 --- a/tests/test_config_edit.py +++ b/tests/test_config_edit.py @@ -21,7 +21,8 @@ def getlist(self, key): def _doc(csv_path: str) -> str: # Includes comments + two charts so we can prove non-target content survives. - return f"""datasets: + return f"""name: Test dashboard +datasets: orders: path: {csv_path} @@ -110,7 +111,7 @@ def test_replace_chart_block_preserves_siblings_and_reparses(orders_csv): assert "by_status: # sibling — must stay byte-for-byte" in new_text assert "title: By status" in new_text # Datasets + dashboard preserved. - assert new_text.startswith("datasets:") + assert new_text.startswith("name: Test dashboard\ndatasets:") assert '- ["@20", "revenue:1", "by_status:1"]' in new_text # Whole doc still parses as a dashboard. ff.Dashboard.from_yaml(new_text) @@ -118,7 +119,8 @@ def test_replace_chart_block_preserves_siblings_and_reparses(orders_csv): def test_emit_drops_none_and_empty(orders_csv): """An unset nullable int (map zoom) and an empty filter list emit no key.""" - text = f"""datasets: + text = f"""name: Test dashboard +datasets: o: {{path: {orders_csv}}} charts: m: @@ -207,7 +209,8 @@ def test_delete_chart_from_shared_row_keeps_siblings(orders_csv): def test_delete_chart_drops_now_empty_row(orders_csv): """A chart that is the only cell in its row takes the row with it.""" - text = f"""datasets: + text = f"""name: Test dashboard +datasets: o: {{path: {orders_csv}}} charts: a: {{type: table, dataset: o, title: A}} @@ -251,7 +254,8 @@ def test_insert_unknown_kind_raises(orders_csv): def _headed_doc(csv_path: str) -> str: - return f"""datasets: + return f"""name: Test dashboard +datasets: o: {{path: {csv_path}}} charts: a: {{type: table, dataset: o, title: A}} @@ -344,7 +348,8 @@ def test_delete_layout_item_rejects_a_row(orders_csv): def _move_doc(csv_path: str) -> str: - return f"""datasets: + return f"""name: Test dashboard +datasets: o: {{path: {csv_path}}} charts: a: {{type: table, dataset: o, title: A}} @@ -401,7 +406,8 @@ def test_move_to_new_row_unknown(orders_csv): def _merge_doc(csv_path: str) -> str: # `status` spans the first two rows: sized in row 1, repeated bare below. - return f"""datasets: + return f"""name: Test dashboard +datasets: o: {{path: {csv_path}}} charts: orders: {{type: table, dataset: o, title: O}} @@ -452,7 +458,8 @@ def test_move_merge_member_out_dissolves_span(orders_csv): def test_move_span_across_two_rows(orders_csv): """`move_span` places the chart spanning a row and the row directly below: `src:1` in the top row, bare `src` below so it inherits and spans.""" - doc = f"""datasets: + doc = f"""name: Test dashboard +datasets: o: {{path: {orders_csv}}} charts: orders: {{type: table, dataset: o, title: O}} @@ -473,7 +480,8 @@ def test_move_span_across_two_rows(orders_csv): def test_move_span_onto_merged_chart_extends_to_three_rows(orders_csv): """Merging onto a chart that already spans 2 rows makes the moved chart span that whole span + 1 row below (rows 1-3).""" - doc = f"""datasets: + doc = f"""name: Test dashboard +datasets: o: {{path: {orders_csv}}} charts: orders: {{type: table, dataset: o, title: O}} @@ -500,7 +508,8 @@ def test_move_span_onto_merged_chart_extends_to_three_rows(orders_csv): def test_merge_down_extends_own_span(orders_csv): """`merge_down` grows a chart's own span down one row: a single-row chart becomes a 2-row line; a 2-row line becomes 3.""" - doc = f"""datasets: + doc = f"""name: Test dashboard +datasets: o: {{path: {orders_csv}}} charts: orders: {{type: table, dataset: o, title: O}} @@ -517,7 +526,8 @@ def test_merge_down_extends_own_span(orders_csv): def test_merge_down_no_row_below_errors(orders_csv): - doc = f"""datasets: + doc = f"""name: Test dashboard +datasets: o: {{path: {orders_csv}}} charts: orders: {{type: table, dataset: o, title: O}} @@ -530,7 +540,8 @@ def test_merge_down_no_row_below_errors(orders_csv): def test_move_span_no_row_below_is_single_insert(orders_csv): """With no adjacent row below the target, span degrades to a single insert.""" - doc = f"""datasets: + doc = f"""name: Test dashboard +datasets: o: {{path: {orders_csv}}} charts: orders: {{type: table, dataset: o, title: O}} @@ -558,7 +569,8 @@ def test_resize_columns_owner_row(orders_csv): def test_resize_columns_from_inherited_row(orders_csv): """Resizing a boundary owned by a lower (inherited) row updates that row's cells only, leaving the first row's sizes and the span intact.""" - doc = f"""datasets: + doc = f"""name: Test dashboard +datasets: o: {{path: {orders_csv}}} charts: a: {{type: table, dataset: o, title: A}} @@ -576,6 +588,30 @@ def test_resize_columns_from_inherited_row(orders_csv): assert "grid-template-columns: 1.5fr 0.5fr 2fr" in ff.Dashboard.from_yaml(out).to_html() +def test_resize_columns_in_tabbed_dashboard(orders_csv): + """Row ordinals are global across tabs, so a column drag on a *tabbed* + dashboard must still find its group. It previously searched flat `.items` + only (empty when tabbed), silently no-op'd, and the drag snapped back.""" + doc = f"""name: Test dashboard +datasets: + o: {{path: {orders_csv}}} +charts: + a: {{type: table, dataset: o, title: A}} + b: {{type: table, dataset: o, title: B}} + c: {{type: table, dataset: o, title: C}} + d: {{type: table, dataset: o, title: D}} +dashboard: + Overview: + - ["@22", "a", "b", "c"] + More: + - ["@30", "d"] +""" + out = ce.resize_columns(doc, [0], [10, 57, 33]) + rows = [ln.strip() for ln in out.splitlines() if '"@' in ln] + assert rows[0] == '- ["@22", "a:10", "b:57", "c:33"]' # widths actually applied + ff.Dashboard.from_yaml(out) + + def test_apply_edit_invalid_value_raises(orders_csv): """A bad enum value surfaces as a DashboardError (the whole doc is validated).""" text = _doc(orders_csv) diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 2f7422f..8520361 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -5,6 +5,7 @@ def _smart_yaml(csv_path: str) -> str: return f""" +name: Test dashboard datasets: orders: path: {csv_path} @@ -173,6 +174,7 @@ def test_render_cell_unknown_id_errors(orders_csv): def _merge_yaml(csv_path: str, dashboard_block: str) -> str: return f""" +name: Test dashboard datasets: o: {{path: {csv_path}}} charts: @@ -272,6 +274,7 @@ def test_dashboard_single_row_unchanged_placement(orders_csv): def test_dashboard_indicator_skips_missing_columns(orders_csv): """A declared filter on a column the dataset lacks doesn't count.""" yaml = f""" +name: Test dashboard datasets: o: {{path: {orders_csv}}} charts: @@ -298,6 +301,7 @@ def test_dashboard_widths_are_proportions(orders_csv): """Widths are proportions (fr tracks), so any positive values are valid and equal integers split the row evenly — no sum-to-100 requirement.""" yaml = f""" +name: Test dashboard datasets: o: {{path: {orders_csv}}} charts: @@ -316,6 +320,7 @@ def test_dashboard_proportional_widths_equivalent(orders_csv): as their literal fr weights.""" def cols(a, b): yaml = f""" +name: Test dashboard datasets: o: {{path: {orders_csv}}} charts: @@ -335,6 +340,7 @@ def cols(a, b): def test_dashboard_single_cell_fills_row(orders_csv): """A lone cell fills the row regardless of its number — proportions, not %.""" yaml = f""" +name: Test dashboard datasets: o: {{path: {orders_csv}}} charts: @@ -370,6 +376,7 @@ def test_dashboard_bare_inherit_spans(orders_csv): def test_dashboard_rejects_unknown_chart(orders_csv): yaml = f""" +name: Test dashboard datasets: o: {{path: {orders_csv}}} charts: @@ -398,6 +405,7 @@ def test_dashboard_single_row_insert_keeps_span(orders_csv): def test_dashboard_rejects_unknown_dataset(): yaml = """ +name: Test dashboard datasets: {} charts: t: {type: table, dataset: missing, title: T} @@ -409,6 +417,7 @@ def test_dashboard_rejects_unknown_dataset(): def test_dashboard_rejects_unknown_chart_type(orders_csv): yaml = f""" +name: Test dashboard datasets: o: {{path: {orders_csv}}} charts: @@ -421,6 +430,7 @@ def test_dashboard_rejects_unknown_chart_type(orders_csv): def test_dashboard_rejects_missing_top_level(orders_csv): yaml = f""" +name: Test dashboard datasets: o: {{path: {orders_csv}}} charts: diff --git a/tests/test_editor_page.py b/tests/test_editor_page.py new file mode 100644 index 0000000..99ba694 --- /dev/null +++ b/tests/test_editor_page.py @@ -0,0 +1,48 @@ +"""Static guards on the editor page (`INDEX`). Its interactive behavior is +JS/browser territory the snapshot suite can't reach, but a couple of regressions +are cheap to pin down from the rendered HTML/CSS — notably that the "stale" +preview overlay stays *interactive*: a `pointer-events: none` there once made +the greyed preview swallow clicks and broke the row/column resize handles.""" + +import re + +from fireflyer.web.app import DEFAULT_YAML, _theme_switch, render_editor_page + + +def _page() -> str: + return render_editor_page(DEFAULT_YAML, theme=_theme_switch()) + + +def test_stale_preview_is_greyed_but_still_interactive(): + page = _page() + m = re.search(r"\.pane\.output\.stale \.pane-body \{([^}]*)\}", page) + assert m, "the `.stale .pane-body` rule is missing" + rule = m.group(1) + assert "opacity" in rule # greyed as a stale cue + # ...but not disabled — `pointer-events: none` here broke vertical resize. + assert "pointer-events" not in rule + + +def test_refresh_overlay_and_stale_wiring_present(): + page = _page() + assert 'id="output-pane"' in page and 'class="pane output"' in page + assert 'id="refresh"' in page and "ff-refresh" in page + assert "function markStale" in page + assert "addEventListener('input'" in page and "markStale()" in page + + +def test_run_button_and_status_removed(): + page = _page() + assert 'id="run"' not in page + assert 'id="status"' not in page + + +def test_row_resize_rewrite_is_yaml_style_agnostic(): + # Row-height drags rewrite the Nth `@height` token directly. An earlier + # version scanned for the row's `[ ... ]` flow-style brackets and silently + # no-op'd on block-style rows, so drags snapped back. Guard against a revert + # to that bracket-only approach (verified for real via a browser drag). + page = _page() + assert "function setRowUnits" in page + assert "rowBracketSpan" not in page + assert "lastIndexOf('['" not in page diff --git a/tests/test_number.py b/tests/test_number.py index a58be45..fd778cc 100644 --- a/tests/test_number.py +++ b/tests/test_number.py @@ -131,6 +131,7 @@ def test_number_full_format_param(orders_csv): def test_number_in_dashboard(orders_csv): """The number type resolves in dashboard YAML and renders a KPI cell.""" yaml = f""" +name: Test dashboard datasets: o: {{path: {orders_csv}}} charts: @@ -146,6 +147,7 @@ def test_number_in_dashboard(orders_csv): def test_number_dashboard_rejects_bad_agg(orders_csv): yaml = f""" +name: Test dashboard datasets: o: {{path: {orders_csv}}} charts: diff --git a/tests/test_portal.py b/tests/test_portal.py new file mode 100644 index 0000000..b04ff58 --- /dev/null +++ b/tests/test_portal.py @@ -0,0 +1,112 @@ +"""Portal store + gallery tests. Exercises `fireflyer.web.portal` directly with +an in-memory sqlite store — no web stack, no live Postgres (same rule as the +chat tests). Postgres is only used at portal runtime; here sqlite stands in.""" + +import pytest + +from fireflyer.dashboard import DashboardError +from fireflyer.web.portal import SqliteStore, render_gallery + +# Minimal valid dashboard. `from_yaml` only parses (it never opens the CSV), so +# the dataset path need not exist for validation to pass. The listing name comes +# from the top-level `name:` key — the store never takes a separate name. +def _yaml(name: str = "Sales", title: str = "KPI") -> str: + return f"""name: {name} +datasets: + o: {{path: x.csv}} +charts: + kpi: {{type: number, dataset: o, title: {title}, column: amount, agg: sum}} +dashboard: + Main: + - ["@100", "kpi"] +""" + + +VALID_YAML = _yaml() +INVALID_YAML = "charts: [not, a, dashboard]" + + +@pytest.fixture +def store(): + return SqliteStore(":memory:") + + +def test_create_derives_name_from_yaml(store): + new_id = store.create(VALID_YAML) + + rows = store.list() + assert [r.name for r in rows] == ["Sales"] # from the `name:` key + assert rows[0].id == new_id + assert store.get(new_id).yaml == VALID_YAML + + +def test_create_rejects_missing_name_key(store): + no_name = VALID_YAML.replace("name: Sales\n", "") + with pytest.raises(DashboardError): + store.create(no_name) + assert store.list() == [] + + +def test_get_missing_returns_none(store): + assert store.get("does-not-exist") is None + + +def test_create_records_author(store): + new_id = store.create(VALID_YAML, author="dana") + assert store.get(new_id).author == "dana" + + +def test_save_updates_name_and_yaml_but_keeps_author(store): + new_id = store.create(VALID_YAML, author="dana") + + store.save(new_id, _yaml(name="Revenue", title="Total")) + + row = store.get(new_id) + assert row.name == "Revenue" # re-derived from the edited `name:` key + assert "Total" in row.yaml + assert row.author == "dana" # author (creator) is preserved across saves + + +def test_delete_removes_row(store): + new_id = store.create(VALID_YAML) + store.delete(new_id) + assert store.list() == [] + + +def test_create_rejects_invalid_yaml(store): + with pytest.raises(DashboardError): + store.create(INVALID_YAML) + assert store.list() == [] + + +def test_save_rejects_invalid_yaml(store): + new_id = store.create(VALID_YAML) + with pytest.raises(DashboardError): + store.save(new_id, INVALID_YAML) + # The bad save left the stored YAML untouched. + assert store.get(new_id).yaml == VALID_YAML + + +def test_gallery_is_a_table_with_author_and_actions(store): + store.create(VALID_YAML, author="dana") + html = render_gallery(store.list()) + assert "{header}" in html + assert ">dana<" in html + # per-row actions + top add button + clone/add dialogs + assert "Edit" in html and "Clone" in html and "Remove" in html + assert "openAdd()" in html and 'id="add-dialog"' in html + assert "openClone(this)" in html and 'id="clone-dialog"' in html + + +def test_gallery_escapes_dashboard_names(store): + store.create(_yaml(name='""'), author="dana") + html = render_gallery(store.list()) + assert "" not in html + assert "<script>" in html + + +def test_gallery_empty_state(): + html = render_gallery([]) + assert "No dashboards yet" in html diff --git a/tests/test_tabs.py b/tests/test_tabs.py index d24c724..16c49ed 100644 --- a/tests/test_tabs.py +++ b/tests/test_tabs.py @@ -9,7 +9,8 @@ def _tabbed(csv_path: str) -> str: - return f"""datasets: + return f"""name: Test dashboard +datasets: o: {{path: {csv_path}}} charts: a: {{type: table, dataset: o, title: A}} @@ -33,7 +34,8 @@ def test_tabs_parse_names_and_shape(orders_csv): def test_flat_dashboard_has_no_tabs(orders_csv): - yaml = f"""datasets: + yaml = f"""name: Test dashboard +datasets: o: {{path: {orders_csv}}} charts: a: {{type: table, dataset: o, title: A}} @@ -44,7 +46,8 @@ def test_flat_dashboard_has_no_tabs(orders_csv): def test_empty_tab_rejected(orders_csv): - yaml = f"""datasets: + yaml = f"""name: Test dashboard +datasets: o: {{path: {orders_csv}}} charts: a: {{type: table, dataset: o, title: A}} @@ -59,7 +62,8 @@ def test_empty_tab_rejected(orders_csv): def test_chart_in_two_tabs_rejected(orders_csv): """A chart resolves to one placement across the whole dashboard.""" - yaml = f"""datasets: + yaml = f"""name: Test dashboard +datasets: o: {{path: {orders_csv}}} charts: a: {{type: table, dataset: o, title: A}} @@ -76,7 +80,8 @@ def test_chart_in_two_tabs_rejected(orders_csv): def test_span_within_a_lower_tab(orders_csv): """A bare-inherit span still works inside a tab that isn't the first — proof that per-tab grouping and global ordinal numbering are correct.""" - yaml = f"""datasets: + yaml = f"""name: Test dashboard +datasets: o: {{path: {orders_csv}}} charts: a: {{type: table, dataset: o, title: A}} @@ -137,7 +142,8 @@ def test_skeleton_editing_shows_tab_toolbar_not_add_first(orders_csv): def test_skeleton_flat_editing_has_no_tab_bar(orders_csv): """A flat dashboard renders no tab bar; tabs are created from the between-rows "+" menu (which lives in the editor page, not the skeleton).""" - yaml = f"""datasets: + yaml = f"""name: Test dashboard +datasets: o: {{path: {orders_csv}}} charts: a: {{type: table, dataset: o, title: A}} @@ -157,7 +163,8 @@ def test_tabbed_dashboard_snapshot(orders_csv, snapshot): def _flat(csv_path: str) -> str: - return f"""datasets: + return f"""name: Test dashboard +datasets: o: {{path: {csv_path}}} charts: a: {{type: table, dataset: o, title: A}} @@ -199,7 +206,8 @@ def test_set_tab_text_empty_rejected(orders_csv): def test_move_tab_repositions_boundary(orders_csv): """Move repositions the tab's start boundary (delimiter model): moving a tab's key line earlier hands it the rows it now sits above.""" - yaml = f"""datasets: + yaml = f"""name: Test dashboard +datasets: o: {{path: {orders_csv}}} charts: x: {{type: table, dataset: o, title: X}}