diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c0fc60f1..51d63e463 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to Turbo EA are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [2.12.0] - 2026-07-13 + +### Added +- **UI extension SDK 1.13 — saved-report localStorage persistence for extension reports.** The `useSavedReport` SDK hook now re-exports the core hook's `consumeConfig` / `persistConfig` / `resetAll` layer, so an extension report page keeps its filters and selection across a browser refresh exactly like a core report (URL saved report > localStorage > defaults). + ## [2.11.0] - 2026-07-13 ### Added @@ -88,7 +93,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - **Admin → Extensions: remove a license, and a smoother file install.** The Installed tab now has a **Remove license** action (soft-disables licensed extensions until a license is re-applied — no data is deleted). Installing an unlicensed extension from a file no longer dead-ends with an error pointing at the Installed tab: the license dialog opens inline and the install continues automatically once the license is applied. - **Instance-bound extension licensing.** Every installation now mints a unique instance ID (`TEA-XXXX-XXXX-XXXX`, shown on Admin → Extensions with a copy button) that identifies it for licensing: purchases carry it automatically from the in-app Store (and the storefront checkout asks for it), so every extension bought for the instance — by any administrator, under any email — lands in one combined license. Licenses are bound to the ID: one issued for a different instance is refused with a clear message, and a restored/copied database explains itself with a banner instead of silently mis-licensing. The ID identifies only — it is never a credential — and it travels with workspace transfer so host migrations keep licenses working. - **Admin → Extensions** gains a page title and icon, an intro covering both install models (one-click Store and offline file upload), and a note that extensions are built and signed by Turbo EA (not self-built or third-party) with a link to consulting for a tailored build. Documented in the admin guide across all languages. -- **Extensions can extend Architecture Decision Records.** Two new UI extension points (UI SDK 1.3) let a licensed extension render its own panel on the ADR editor and preview pages — e.g. a value-savings form — and contribute extra sections to the ADR DOCX export so that data appears in the exported document. ADRs now also carry an `ext.*` attributes bag that extensions write to; it is frozen automatically once the decision is signed and carried into revisions and duplicates, so figures approved by the signatories stay accurate. +- **Extensions can extend Architecture Decision Records.** Two new UI extension points (UI SDK 1.3) let a licensed extension render its own panel on the ADR editor and preview pages, and contribute extra sections to the ADR DOCX export so that data appears in the exported document. ADRs now also carry an `ext.*` attributes bag that extensions write to; it is frozen automatically once the decision is signed and carried into revisions and duplicates, so figures approved by the signatories stay accurate. ### Security - Installed extensions are now verified byte-for-byte at every startup, not just by their manifest signature: each file is re-hashed against the signed manifest and backend code is re-derived from the verified wheels, so tampering with extension code on the data volume is detected and quarantined instead of loaded. diff --git a/CLAUDE.md b/CLAUDE.md index 287290df7..91e1b8f3a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -185,8 +185,8 @@ The **Extension Store** (Admin → Extensions) installs vendor-signed, licensed - **SDK surface only.** Backend extensions may import exclusively from `app.services.extensions.sdk` (SDK 1.1, major-version checked at load; 1.1 added the route dependencies `get_db` / `get_current_user` / `require_permission` re-exports so extension routes authenticate and authorize without touching core internals — guard test `tests/services/test_extension_sdk.py`). Extension tables are `ext_{key}_*` (tracked in `extension_schema_versions`, never in core Alembic), permission keys `ext.{key}.*` (namespace enforced by the loader), API routes mounted under `/api/v1/ext/{key}/` at import time in `app.main` (**backend**-code install/uninstall ⇒ `needs_restart`; UI-only/content/metamodel bundles install live — `RUNTIME_CAPABILITIES` in `installer.py` is `{"backend"}` only, since `/ext-assets` serves from disk per request and `/extensions/ui-manifest` refreshes from the DB per call). - **Content packs go through `workspace_io.apply_selected`** (the public facade) and reuse the workspace-transfer engine + guarantees; allowed sheets exclude `Users`/`Roles`/`Settings`. `ext_*` tables are excluded from workspace transfer. A pack may also ship the `Surveys` sheet (but never `SurveyResponses`): `build_content_bundle` clamps every pack survey to `status="draft"` with cleared `sent_at`/`closed_at` (`_enforce_survey_draft`), so an install can never send a survey / email subscribers — the survey lands as a reviewable draft with its fields pre-selected in the builder. Keep `CONTENT_ALLOWED_SHEETS` (`content_pack.py`) in sync with `CONTENT_SHEETS` in `scripts/extension-tools/teax.py`. - **Manifest field-section contributions (`metamodel` capability) are the ONLY way an extension adds fields to an EXISTING card type.** A content-pack `CardTypes` row whole-column-overwrites `fields_schema` (fine for pack-owned new types, destructive for existing ones), so a manifest may instead declare `"capabilities": [..., "metamodel"]` + `"metamodel": {"field_sections": [{card_type, section, columns?, translations?, fields: [...]}]}` and `apply_field_contributions` (`backend/app/services/extensions/field_contributions.py`) MERGES the section into the target type — stamped `"ext": ""` on section + fields, idempotent, never touching admin/core fields (a conflicting field key is skipped, never hijacked). Contributed field types must be built-in or `ext.{key}.*` (validated in `bundle.py` + `teax lint`). Lifecycle mirrors content-pack soft-hide: install/enable/update re-merge (updates re-sync ext-owned fields and clean retargeted types via the stamp scan, preserving admin-added fields inside the section); disable/uninstall strip the stamped fields **but never the values in `cards.attributes`** — re-enabling restores the section and every value reappears. License lapse does NOT remove contributions (lapse ≠ deactivation; rendering is never gated). Data-quality is recomputed for affected types on apply/remove. Tests: `tests/services/test_extension_field_contributions.py`. -- **UI plugins**: assets served same-origin by `GET /ext-assets/...` (unauthenticated by design — entitlement gating is the authenticated `/extensions/ui-manifest` + the `/ext/{key}/` APIs; CSP stays `'self'`). **Named UI SDK extension points** (nav routes, card tabs, admin panels; since UI SDK 1.1 **custom field types** — `fieldTypes` on `TurboExtensionUI`, a display/editor pair for a namespaced `ext.{key}.*` field type used inside card attribute sections; since UI SDK 1.2 **survey templates** — `surveyTemplates`, a "New from template" shortcut on the Surveys admin page whose `build()` payload is POSTed to `/surveys` (always a draft) and opened in the builder, surfaced by `getExtensionSurveyTemplates` / `useExtensionSurveyTemplates`, additive and ungated; since UI SDK 1.3 two **ADR** points — `adrPanels`, a component rendered on the ADR editor + preview (`ADREditor`/`ADRPreview`, consumed via `useExtensionAdrPanels`, props `{adrId, status, signed, readOnly}` — the value-savings extension's home; ADRs are not cards so `cardTabs`/`metamodel` field contributions do not apply to them), and `adrExportSections`, a plain-data builder (`getExtensionAdrExportSections`) whose returned `{heading, paragraphs?, table?}` sections core renders into the ADR DOCX export in `adrExport.ts`; both additive/ungated; and since UI SDK 1.10 **ADR grid columns** — `adrGridColumns`, a plain-data column (`{id, label, align?, value(adr), sortValue?(adr)}`) appended to the shared decisions grid (`AdrGrid`, used by EA Delivery → Decisions AND GRC → Governance → Decisions). Core builds a native AG Grid `ColDef` from it (`useExtensionAdrGridColumns`), so the column shares theme/sorting/quick-filter with built-in columns; `value()`/`sortValue()` run guarded — a throw yields an empty cell, never a broken grid. Deliberately plain data, NOT a `cellRenderer` — extensions contribute data, core owns the rendering, same philosophy as `adrExportSections`). **Since UI SDK 1.12 the *preferred* way to add a plug point is the generic slot registry, not a new named point.** An extension declares `slots: [{ slot, id, component | build, permission?, appliesTo?, order? }]` on `TurboExtensionUI`; core exposes a location by dropping `` (component slots, rendered inside `ExtensionBoundary` with `context` as props) or calling `getExtensionSlots(name)` and rendering the returned `build(ctx)` data itself (data slots — the `adrGridColumns`/`adrExportSections` philosophy). A contribution must supply exactly one of `component`/`build`; `permission` hides the slot silently (never an access-denied page), `appliesTo` filters on `context.cardType`/`type`, `order` sorts within a slot. The payoff: a **new** plug location no longer needs an SDK type/hook/version change — only a one-line `` in core with a fresh `slot` string (seeded locations: `card.detail.header` in `CardDetailContent`, `risk.detail.panel` in `RiskDetailPage`). The named points above are retained for compatibility; add new locations as slots. Every extension component renders inside `ExtensionBoundary`. `UI_SDK_VERSION` bumps major on breaks, minor on additive points; the host checks the major only. A nav route may carry an optional `navGroup` placement hint (whitelisted by `EXTENSION_NAV_GROUPS`, currently `"reports"`) so its menu entry renders inside a core nav group instead of top-level — this is a hint on the existing routes point, **not** a fourth-plus extension point; the path stays `/ext/{key}/...`, `AppLayout` injects grouped routes into the Reports `children` via `getExtensionRoutesForGroup`, and an unrecognised `navGroup` surfaces nowhere in the nav (still reachable by URL). -- **The SDK is the reuse boundary — expose core UI on `window.TurboEA.sdk`, never make an extension rebuild it.** When an extension needs UI that core already has (a dialog, a picker, a report frame, a drawer), expose the existing core component on `window.TurboEA.sdk`: add the import + sdk entry + a `UI_SDK_VERSION` minor bump + a surface-test assertion, same as `SaveReportDialog` / `CardDetailSidePanel` / `ReportShell` / `FilterSelect`, and — since UI SDK 1.8 — `useCurrency`, `MetricCard`, `ReportLegend`, `UserMultiSelect` (the shared multi-user picker in `frontend/src/components/UserMultiSelect.tsx`), and `loadRecharts` (an async `() => import("recharts")` loader so extension charts reuse core's code-split Recharts chunk — extensions must NOT bundle their own charting library), and — since UI SDK 1.9 — `useChartTheme` (`frontend/src/hooks/useChartTheme.ts`, the theme-aware Recharts chrome — grid stroke, axis ticks, tooltip styling — that keeps extension charts visually identical to core reports; extensions must use it instead of hand-rolling chart chrome). A weaker extension re-implementation is a bug, not a workaround. One caveat when adding an entry: if the component pulls a heavy code-split graph or would create a module cycle back into `extensionHost` (e.g. `CardDetailSidePanel` → `CardDetailContent`, `ReportShell` → the xlsx/pptx export engine), expose it as a `React.lazy` wrapper with Suspense handled internally (or an async loader like `loadRecharts` for a bare library) — never a static import that drags the graph into the eager main bundle. Two advanced metamodel field-authoring capabilities live entirely in core but are only *authorable* when an installed, enabled, licensed extension declares the matching grant in its manifest (`"grants": ["metamodel.field_help", "metamodel.custom_field_types"]`, surfaced by `registry.grants_for` / `granted_capabilities` and the `/extensions/status` `grants` field): **field help text** (`FieldDef.help` + `helpTranslations`, rendered as collapsible guidance by `FieldHelp` in `cardDetailUtils.tsx`) and **custom field types** (`FieldDef.type = "ext.*"` + `config`, rendered via the `fieldTypes` registry). The free core ships the *engine* (schema, storage, rendering, the empty registry) but no UI to author these; `_enforce_field_gating` in `backend/app/api/v1/metamodel.py` strips help/`ext.*` types on `create_type`/`update_type` unless the grant is active (checked against `useExtensionCapabilities` on the frontend, `granted_capabilities()` on the backend). **Two invariants:** (1) **rendering is never gated** — `FieldValue`/`FieldEditor` always render an `ext.*` value, degrading to a read-only text view when the extension is missing/disabled/unlicensed, so a lapse never blanks a card or deletes data; (2) **existing values are grandfathered** — the gating strip preserves already-stored help/custom-types verbatim so a lapse can't block an unrelated metamodel edit. Content packs bypass the strip (they write `fields_schema` through `workspace_io`, an extension-delivered path) so a pack can ship help text and custom-typed fields directly. When adding a new gated core capability, follow this shape: inert plumbing + `grants` gate on the *write/authoring* path only + a guard test (`tests/api/test_metamodel_field_gating.py`). +- **UI plugins**: assets served same-origin by `GET /ext-assets/...` (unauthenticated by design — entitlement gating is the authenticated `/extensions/ui-manifest` + the `/ext/{key}/` APIs; CSP stays `'self'`). **Named UI SDK extension points** (nav routes, card tabs, admin panels; since UI SDK 1.1 **custom field types** — `fieldTypes` on `TurboExtensionUI`, a display/editor pair for a namespaced `ext.{key}.*` field type used inside card attribute sections; since UI SDK 1.2 **survey templates** — `surveyTemplates`, a "New from template" shortcut on the Surveys admin page whose `build()` payload is POSTed to `/surveys` (always a draft) and opened in the builder, surfaced by `getExtensionSurveyTemplates` / `useExtensionSurveyTemplates`, additive and ungated; since UI SDK 1.3 two **ADR** points — `adrPanels`, a component rendered on the ADR editor + preview (`ADREditor`/`ADRPreview`, consumed via `useExtensionAdrPanels`, props `{adrId, status, signed, readOnly}`; ADRs are not cards so `cardTabs`/`metamodel` field contributions do not apply to them), and `adrExportSections`, a plain-data builder (`getExtensionAdrExportSections`) whose returned `{heading, paragraphs?, table?}` sections core renders into the ADR DOCX export in `adrExport.ts`; both additive/ungated; and since UI SDK 1.10 **ADR grid columns** — `adrGridColumns`, a plain-data column (`{id, label, align?, value(adr), sortValue?(adr)}`) appended to the shared decisions grid (`AdrGrid`, used by EA Delivery → Decisions AND GRC → Governance → Decisions). Core builds a native AG Grid `ColDef` from it (`useExtensionAdrGridColumns`), so the column shares theme/sorting/quick-filter with built-in columns; `value()`/`sortValue()` run guarded — a throw yields an empty cell, never a broken grid. Deliberately plain data, NOT a `cellRenderer` — extensions contribute data, core owns the rendering, same philosophy as `adrExportSections`). **Since UI SDK 1.12 the *preferred* way to add a plug point is the generic slot registry, not a new named point.** An extension declares `slots: [{ slot, id, component | build, permission?, appliesTo?, order? }]` on `TurboExtensionUI`; core exposes a location by dropping `` (component slots, rendered inside `ExtensionBoundary` with `context` as props) or calling `getExtensionSlots(name)` and rendering the returned `build(ctx)` data itself (data slots — the `adrGridColumns`/`adrExportSections` philosophy). A contribution must supply exactly one of `component`/`build`; `permission` hides the slot silently (never an access-denied page), `appliesTo` filters on `context.cardType`/`type`, `order` sorts within a slot. The payoff: a **new** plug location no longer needs an SDK type/hook/version change — only a one-line `` in core with a fresh `slot` string (seeded locations: `card.detail.header` in `CardDetailContent`, `risk.detail.panel` in `RiskDetailPage`). The named points above are retained for compatibility; add new locations as slots. Every extension component renders inside `ExtensionBoundary`. `UI_SDK_VERSION` bumps major on breaks, minor on additive points; the host checks the major only. A nav route may carry an optional `navGroup` placement hint (whitelisted by `EXTENSION_NAV_GROUPS`, currently `"reports"`) so its menu entry renders inside a core nav group instead of top-level — this is a hint on the existing routes point, **not** a fourth-plus extension point; the path stays `/ext/{key}/...`, `AppLayout` injects grouped routes into the Reports `children` via `getExtensionRoutesForGroup`, and an unrecognised `navGroup` surfaces nowhere in the nav (still reachable by URL). +- **The SDK is the reuse boundary — expose core UI on `window.TurboEA.sdk`, never make an extension rebuild it.** When an extension needs UI that core already has (a dialog, a picker, a report frame, a drawer), expose the existing core component on `window.TurboEA.sdk`: add the import + sdk entry + a `UI_SDK_VERSION` minor bump + a surface-test assertion, same as `SaveReportDialog` / `CardDetailSidePanel` / `ReportShell` / `FilterSelect`, and — since UI SDK 1.8 — `useCurrency`, `MetricCard`, `ReportLegend`, `UserMultiSelect` (the shared multi-user picker in `frontend/src/components/UserMultiSelect.tsx`), and `loadRecharts` (an async `() => import("recharts")` loader so extension charts reuse core's code-split Recharts chunk — extensions must NOT bundle their own charting library), and — since UI SDK 1.9 — `useChartTheme` (`frontend/src/hooks/useChartTheme.ts`, the theme-aware Recharts chrome — grid stroke, axis ticks, tooltip styling — that keeps extension charts visually identical to core reports; extensions must use it instead of hand-rolling chart chrome), and — since UI SDK 1.13 — the saved-report **localStorage-persistence** passthroughs on `useSavedReport` (`consumeConfig` / `persistConfig` / `resetAll`, re-exported from `useSavedReport.ts`) so an extension report keeps its filters + selection across a refresh exactly like a core report (URL saved report > localStorage > defaults; `useExtensionSavedReport` returns them alongside `config` / `savedReportId` / `name`). A weaker extension re-implementation is a bug, not a workaround. One caveat when adding an entry: if the component pulls a heavy code-split graph or would create a module cycle back into `extensionHost` (e.g. `CardDetailSidePanel` → `CardDetailContent`, `ReportShell` → the xlsx/pptx export engine), expose it as a `React.lazy` wrapper with Suspense handled internally (or an async loader like `loadRecharts` for a bare library) — never a static import that drags the graph into the eager main bundle. Two advanced metamodel field-authoring capabilities live entirely in core but are only *authorable* when an installed, enabled, licensed extension declares the matching grant in its manifest (`"grants": ["metamodel.field_help", "metamodel.custom_field_types"]`, surfaced by `registry.grants_for` / `granted_capabilities` and the `/extensions/status` `grants` field): **field help text** (`FieldDef.help` + `helpTranslations`, rendered as collapsible guidance by `FieldHelp` in `cardDetailUtils.tsx`) and **custom field types** (`FieldDef.type = "ext.*"` + `config`, rendered via the `fieldTypes` registry). The free core ships the *engine* (schema, storage, rendering, the empty registry) but no UI to author these; `_enforce_field_gating` in `backend/app/api/v1/metamodel.py` strips help/`ext.*` types on `create_type`/`update_type` unless the grant is active (checked against `useExtensionCapabilities` on the frontend, `granted_capabilities()` on the backend). **Two invariants:** (1) **rendering is never gated** — `FieldValue`/`FieldEditor` always render an `ext.*` value, degrading to a read-only text view when the extension is missing/disabled/unlicensed, so a lapse never blanks a card or deletes data; (2) **existing values are grandfathered** — the gating strip preserves already-stored help/custom-types verbatim so a lapse can't block an unrelated metamodel edit. Content packs bypass the strip (they write `fields_schema` through `workspace_io`, an extension-delivered path) so a pack can ship help text and custom-typed fields directly. When adding a new gated core capability, follow this shape: inert plumbing + `grants` gate on the *write/authoring* path only + a guard test (`tests/api/test_metamodel_field_gating.py`). - **ADR extension attributes bag** (`architecture_decisions.attributes`, migration 122). ADRs are **not cards**, so extensions cannot extend them via the `metamodel` field-contribution capability; instead they stash data under `ext..*` top-level keys in this JSONB bag. `update_adr` shallow-merges the bag, **rejects non-`ext.*` keys** (core owns no native ADR attributes), and a key set to `null` is deleted. It is **frozen automatically once the ADR is signed** (`update_adr` 403s on signed decisions) and **carried into revisions + duplicates**, so figures the signatories approved stay accurate. Surfaced in both `_adr_to_dict` and `_adr_to_summary` (so the list endpoint can power a dashboard without N+1). The column transfers via workspace-transfer introspection (ADR is an `ENTITY_SECTION`); user ids stored *inside* the bag are **not** email-remapped on cross-instance transfer (same caveat as card `attributes`) — a portable extension stores emails or its own `ext_*` tables. Paired with the `adrPanels` (authoring) + `adrExportSections` (DOCX) UI points. - A broken extension must never block core boot or blank the SPA — quarantine + surface on the admin page. - **Instance-bound licensing.** Every install mints a checksummed **instance ID** once (`TEA-XXXX-XXXX-XXXX`, Crockford base32 + weighted mod-32 check char — `app/services/extensions/instance_id.py`, persisted in `app_settings.general_settings.instanceId` by `ensure_instance_id()` at startup, surfaced by `GET /admin/extensions/instance` and a copy-chip on ExtensionsAdmin). Licenses carry `payload.instance_id` and core **binds** on it: `_apply_license_text` 400s on mismatch (paste, claim, and auto-renew alike), the registry soft-disables a stored mismatched license at load (`license_problem` → error banner; data untouched), and an **unbound** license is accepted only when `ENVIRONMENT=development` (`teax sign-license` requires `--instance-id`, so production files always carry one). The in-app Buy button appends the ID to Stripe's `client_reference_id` (`-`, parsed off the end by the store webhook); the renew request sends `instance` alongside the renewal key. **Two invariants:** (1) the ID *identifies*, claim tokens / `renewal_key` *authenticate* — knowing an ID must never allow fetching a license; (2) the ID **travels with workspace transfer** (never add `instanceId` to the secret-strip lists) so host migrations keep licenses valid — binding is to the ID, not hardware. Full design + vendor re-key/self-issue tooling: the private extensions repo's `docs/plans/instance-id-licensing.md`. diff --git a/VERSION b/VERSION index 46b81d815..3ca2c9b2c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.11.0 +2.12.0 \ No newline at end of file diff --git a/backend/alembic/versions/122_add_adr_attributes.py b/backend/alembic/versions/122_add_adr_attributes.py index 19429b8f9..3153b2da8 100644 --- a/backend/alembic/versions/122_add_adr_attributes.py +++ b/backend/alembic/versions/122_add_adr_attributes.py @@ -2,8 +2,8 @@ ADRs are not cards, so extensions cannot extend them through the metamodel field-contribution capability. This JSONB column lets extensions stash their -own data under ``ext..*`` top-level keys (e.g. the value-savings -extension writes ``ext.savings``). It is frozen once the ADR is signed and +own data under ``ext..*`` top-level keys (an extension might write, e.g., +``ext.savings``). It is frozen once the ADR is signed and carried into revisions by the API layer. Revision ID: 122 diff --git a/backend/app/models/architecture_decision.py b/backend/app/models/architecture_decision.py index a8bf3135a..9b49d7f89 100644 --- a/backend/app/models/architecture_decision.py +++ b/backend/app/models/architecture_decision.py @@ -27,8 +27,8 @@ class ArchitectureDecision(Base, UUIDMixin, TimestampMixin): # Extension attributes bag. ADRs are not cards, so extensions cannot use the # metamodel field-contribution capability here; instead they stash their own - # data under ``ext..*`` top-level keys (e.g. the value-savings extension - # writes ``ext.savings``). Frozen automatically once the ADR is signed + # data under ``ext..*`` top-level keys (an extension writes, e.g., + # ``ext.savings``). Frozen automatically once the ADR is signed # (update_adr blocks edits on signed decisions) and carried into revisions. attributes: Mapped[dict] = mapped_column(JSONB, default=dict, server_default="{}") diff --git a/backend/tests/api/test_adr.py b/backend/tests/api/test_adr.py index 09c753755..64a0e5ebe 100644 --- a/backend/tests/api/test_adr.py +++ b/backend/tests/api/test_adr.py @@ -894,7 +894,7 @@ async def test_list_adrs_for_card(self, client, db, card_env): class TestAdrAttributes: - """The attributes bag that ADR extensions (e.g. value-savings) write to.""" + """The attributes bag that ADR extensions write to.""" async def test_default_is_empty_object(self, client, db, adr_env): admin = adr_env["admin"] diff --git a/frontend/src/features/ea-delivery/ADREditor.tsx b/frontend/src/features/ea-delivery/ADREditor.tsx index b0c600705..6373884de 100644 --- a/frontend/src/features/ea-delivery/ADREditor.tsx +++ b/frontend/src/features/ea-delivery/ADREditor.tsx @@ -651,7 +651,7 @@ export default function ADREditor() { )} - {/* ── Extension ADR panels (SDK 1.3, e.g. value-savings) ── */} + {/* ── Extension ADR panels (SDK 1.3) ── */} {!isNew && id && adrPanels diff --git a/frontend/src/features/ea-delivery/adrExport.test.ts b/frontend/src/features/ea-delivery/adrExport.test.ts index aa904f7e0..bdc57b6a8 100644 --- a/frontend/src/features/ea-delivery/adrExport.test.ts +++ b/frontend/src/features/ea-delivery/adrExport.test.ts @@ -42,7 +42,7 @@ describe("exportAdrsToDocx — extension sections", () => { it("invokes registered export builders with the ADR and still saves the file", async () => { const build = vi.fn(() => [ - { heading: "Value Savings", paragraphs: ["Total: 50,000"], table: { headers: ["A"], rows: [["1"]] } }, + { heading: "Extra Section", paragraphs: ["Total: 50,000"], table: { headers: ["A"], rows: [["1"]] } }, ]); registerExtension("daaf", { key: "daaf", diff --git a/frontend/src/features/ea-delivery/adrExport.ts b/frontend/src/features/ea-delivery/adrExport.ts index da199a08c..f06bfcbda 100644 --- a/frontend/src/features/ea-delivery/adrExport.ts +++ b/frontend/src/features/ea-delivery/adrExport.ts @@ -469,7 +469,7 @@ export async function exportAdrsToDocx(adrs: ArchitectureDecision[]): Promise { }); it("pins the current UI SDK version", () => { - expect(UI_SDK_VERSION).toBe("1.12"); + expect(UI_SDK_VERSION).toBe("1.13"); }); it("aggregates generic slots (component + data), sorts by order, drops invalid ones", () => { diff --git a/frontend/src/lib/extensionHost.tsx b/frontend/src/lib/extensionHost.tsx index a8fb6db5f..b457d244d 100644 --- a/frontend/src/lib/extensionHost.tsx +++ b/frontend/src/lib/extensionHost.tsx @@ -14,7 +14,7 @@ * `ext.{key}.*` field type used inside card attribute sections, plus — SDK 1.5 * — an optional admin-side config editor for that type's config object), survey * templates (SDK 1.2), — SDK 1.3 — ADR panels (a component rendered on - * the Architecture Decision Record editor/preview, e.g. a value-savings form + * the Architecture Decision Record editor/preview, e.g. a custom form * writing the ADR `ext.*` attributes bag) and ADR export sections (plain data * a plugin contributes into the core ADR DOCX export), and — SDK 1.4 — a * headless field-visibility provider (hides specific card fields at render @@ -78,7 +78,7 @@ import { useSavedReport as useCoreSavedReport } from "@/hooks/useSavedReport"; import * as tokens from "@/theme/tokens"; import type { ArchitectureDecision, Card } from "@/types"; -export const UI_SDK_VERSION = "1.12"; +export const UI_SDK_VERSION = "1.13"; /** * Core nav groups an extension route may request placement into (instead of the @@ -191,7 +191,7 @@ export interface ExtensionAdrPanelProps { /** * A panel rendered on the ADR editor and preview pages (SDK 1.3). ADRs are not * cards, so this is the sanctioned place to attach ADR-scoped UI — e.g. a - * value-savings form that reads/writes the ADR `ext.*` attributes bag via + * custom form that reads/writes the ADR `ext.*` attributes bag via * `PATCH /adr/{id}`. `appliesTo` has no analogue (there is only one ADR * "type"); gate with `permission` if needed. Rendered read-only-friendly: the * component should respect `signed` to disable editing once frozen. @@ -556,17 +556,32 @@ export function ExtensionReportShell(props: ReportShellProps) { * core save/share dialog re-exported verbatim: name/description/visibility/ * share-with-users → POST /saved-reports). Ungated — permissions are the same * `saved_reports.*` keys every core report uses, enforced server-side. + * + * SDK 1.13 additionally re-exports the core hook's **localStorage + * auto-persist** layer — `consumeConfig` / `persistConfig` / `resetAll` — so an + * extension report keeps its filters + selection across a page refresh exactly + * like a core report (same three-tier priority: URL saved report > localStorage + * > component defaults). `consumeConfig()` is called once on mount to restore, + * `persistConfig(config)` on every change to auto-save, and `resetAll()` clears + * both localStorage and the URL saved report. See any core report + * (`CostReport.tsx`) for the canonical usage. */ export function useExtensionSavedReport(reportType: string): { config: Record | null; savedReportId: string | null; name: string | null; + consumeConfig: () => Record | null; + persistConfig: (config: Record) => void; + resetAll: () => void; } { const saved = useCoreSavedReport(reportType); return { config: (saved.savedReport?.config as Record | undefined) ?? null, savedReportId: saved.savedReport?.id ?? null, name: saved.savedReportName, + consumeConfig: saved.consumeConfig, + persistConfig: saved.persistConfig, + resetAll: saved.resetAll, }; } diff --git a/frontend/src/lib/extensionSavedReport.test.tsx b/frontend/src/lib/extensionSavedReport.test.tsx index f96e47146..fe72ea150 100644 --- a/frontend/src/lib/extensionSavedReport.test.tsx +++ b/frontend/src/lib/extensionSavedReport.test.tsx @@ -29,6 +29,25 @@ describe("SDK saved-report participation (SDK 1.6)", () => { expect(window.TurboEA?.sdk.CardDetailSidePanel).toBeTypeOf("function"); }); + it("useSavedReport exposes the SDK 1.13 localStorage-persistence layer", () => { + // consumeConfig / persistConfig / resetAll let an extension report keep its + // filters + selection across a refresh exactly like a core report. + function Probe() { + const saved = useExtensionSavedReport("ext:daaf:quadrant"); + const ok = + typeof saved.consumeConfig === "function" && + typeof saved.persistConfig === "function" && + typeof saved.resetAll === "function"; + return
{ok ? "has-persistence" : "missing"}
; + } + render( + + + , + ); + expect(screen.getByText("has-persistence")).toBeInTheDocument(); + }); + it("useSavedReport loads the config for ?saved_report_id=", async () => { vi.mocked(api.get).mockResolvedValue({ id: "r1",