Skip to content

Open card tabs by key, lift the card page's rules, and fix three catalogue import issues - #1242

Merged
vincentmakes merged 6 commits into
mainfrom
claude/mutation-lift-card-page
Oct 10, 2026
Merged

vincentmakes merged 6 commits into
mainfrom
claude/mutation-lift-card-page

Conversation

@vincentmakes

@vincentmakes vincentmakes commented Oct 10, 2026 •

Copy link
Copy Markdown
Owner

Summary

Fixes the Admin → Resources link, which opened a card's Card tab instead of its Resources tab, and the tab selection jumping when a counted tab loads. To get there, the rules behind card detail move into modules with rule-by-rule tests: the tab strip, the section order and the History rows (nightly mutation backlog, #1184). It also resolves the three issues #1241 parked in catalogue_common.py — the PyPI sdist fallback that could never work, the request's database session held across the PyPI round-trip, and the catalogue imports bypassing the card write path — plus a fourth found on the way: the value-stream import was gated on a card type that does not exist. Version 2.158.11.

Type

  • feature
  • fix
  • refactor
  • docs
  • chore (CI / build / dependency / housekeeping)
  • security

Changes

Bugs fixed

1. ?tab=resources opened the Card tab. ResourcesAdmin.tsx links to /cards/{id}?tab=resources, but CardDetail.tsx read the parameter with parseInt(…) || 0. The parameter is now passed through as given. A key is resolved against the strip; an index (?tab=1 for a process's flow, the BPM and navigator links) still works.

2. The open tab jumped when a tab before it appeared or vanished. Tabs were addressed by index, each position worked out by hand from the tabs before it. ADRs, Risks and Compliance show while their count loads and go once it settles at 0, so the selection moved onto the neighbouring tab. MUI Tabs now take each tab's key as its value, and a tab that has vanished opens the Card tab.

Lifted into tested modules

  • features/cards/cardTabs.ts (new):

    • cardTabKeys: the ordered list from card type, module flags, permissions and counts;
    • hasItemsOrLoading;
    • resolveCardTab: a key, a numeric index or digits, anything else → Card;
    • notesVisit: no PPM, no extension tabs;
    • visibleExtensionTabs / extensionTabValue;
    • CARD_TAB_LABEL_KEYS.

    useCardTabActivity's event map is now typed with these keys, so a renamed tab fails to compile there.

  • features/cards/sectionConfig.ts (extended):

    • buildSectionOrder;
    • customSectionsOf, hiddenFieldKeys, allFieldsHidden, calculatedFieldKeys.

    The Card Layout editor now calls buildSectionOrder instead of its private, untested copy (getSectionOrder), so the editor and the card cannot disagree on order.

  • features/cards/sections/historyChanges.ts (new):

    • one EVENT_META table (label key, icon, colour), replacing two parallel tables;
    • eventMeta, fmtVal, resolveFieldLabel, parseChanges, fieldLabelsFor;
    • eventDetailModel, a discriminated union (relation / risk / link / processFlow / plain) that HistoryTab renders.

CardDetailContent and HistoryTab keep the fetch effects, the dirty tracking and all rendering. Markup and i18n keys are unchanged.

Catalogue fixes (from #1241's parked list)

3. The PyPI sdist fallback could never work. wheel_url_from_pypi_payload picked a source distribution when a release listed no wheel, but extract_all_catalogues_from_wheel opens the bytes with zipfile and reads the wheel's turbo_ea_capabilities/data/*.json paths — a .tar.gz has neither the container nor the layout, so the branch only ever produced a generic 502 "Catalogue fetch failed" (and the one test of it served zip bytes under a .tar.gz URL). The helper now takes a wheel only and raises ValueError("PyPI lists no wheel for turbo-ea-capabilities <version>"); nothing is requested or cached for such a release.

4. The request's session was held across the PyPI round-trip. require_permission had already queried on the request session, so a pooled connection was checked out while check_remote_version_for probed PyPI (30 s timeout) and fetch_and_cache_all downloaded and parsed a wheel — and the latter committed a session it was handed. Following run_extension_store_check_now: the six GET …/update-status / POST …/update-fetch routes await db.commit() before calling the service, update_fetch commits again after it returns (inside its try, so a failed commit still maps to the 502), check_remote_version_for probes before it reads the cache, and fetch_and_cache_all no longer commits. Response JSON is unchanged.

5. The three imports bypassed the card write path. import_capabilities, import_processes and import_value_streams built Card rows directly and committed inside the service, so an imported card had no History entry (the CLAUDE.md updated_at rule), no hierarchyLevel, no reference in auto mode, a data-quality score of 0 until next touched, a capabilityLevel copied from the catalogue rather than its real depth, and could land as a same-named sibling the UI refuses. They now go through card_write_service.create_card via catalogue_common.create_catalogue_card — one savepoint per row, the POST /cards/bulk-create shape — with a ReferenceAllocator (card_reference.py) that scans each prefix once per batch and is shared with bulk create (_bulk_assign_reference is now a call to it; create_card takes it as an optional argument, so every other caller is byte-identical). A refused row comes back in a new failed: [{catalogue_id, reason}] list with the server's message, and the entries beneath a refused parent are reported as parent not imported instead of being created as roots. The services no longer commit; the import routes do. Two visible behaviour changes: a capability imported without its catalogue parent is an L1 root card (as every other write path labels it; the catalogue's level stays readable from catalogueId), and an entry whose name another card of the type already uses at the same level is reported and not imported — macros included, since they never match by name (#1241). The catalogue page shows the failed entries with their reasons (new importFailedBody key, 10 locales); the three guides document the behaviour (10 locales each). create_catalogue_card imports the write path inside the function: card_write_service reaches the extension bundle module through card_approval → notification_service, and that module imports version_tuple from catalogue_common, so a module-level import closed a cycle (the first CI run failed to collect eight test modules on it).

6. The value-stream import was gated on ValueStream. POST /value-stream-catalogue/import required inventory.create on a ValueStream type that does not exist, so a per-type Create deny on Business Context — the type it creates — did not apply. It is gated on BusinessContext.

Test Plan

  • cardTabs.test.ts:

    • the full strip for a plain card, a process, and an initiative (with PPM on, off, or hosted elsewhere);
    • where ADRs, Risks and Compliance sit;
    • each visibility condition: no GRC, no permission, a 0 count, a loading count, the ADR link permission;
    • resolveCardTab for keys, numbers, digits and garbage;
    • notesVisit; the extension filter by type and permission.
  • sectionConfig.test.ts:

    • buildSectionOrder with no stored order or an empty one; stored order plus custom sections it does not name; successors and tags spliced before relations or appended; a stored hierarchy or successors the type no longer has;
    • the hidden-field (subtype ∪ live extension reports), all-hidden and calculated-field helpers.
  • historyChanges.test.ts:

    • the event table and the unknown-event fallback;
    • fmtVal for every value shape;
    • resolveFieldLabel precedence and the attr_ prefix;
    • parseChanges: scalars, skipped malformed entries, attribute and lifecycle whole-dict diffs, a null side, approval labels with the raw fallback;
    • fieldLabelsFor ([bug] Failure to link a document in resources after deleting one #1166);
    • eventDetailModel for every event family and its fallbacks.
  • CardDetailContent.tabs.test.tsx (new, real component with marker stubs):

    • the process strip, with the index link opening Process Flow;
    • the "resources" key link;
    • an unknown key → Card;
    • GRC tabs settling, and the ADR tab kept for a linker;
    • the open tab falls back when its count settles at 0, and stays put when an earlier tab vanishes;
    • an initiative's SoAW, and its PPM tab navigating away;
    • panels switch, and visits are noted;
    • an extension tab is appended and opens;
    • section order: built-in, stored-plus-splices, hidden.
  • CardDetail.test.tsx: regression opens the tab a link names by key (Admin → Resources links ?tab=resources).

  • HistoryTab.test.tsx: one test per event family: relation peer link, risk reference link and level, document link in a new tab, withdrawn flow's revision and reason, unknown event, per-attribute rows, empty history.

  • Catalogue, backend:

    • tests/api/test_catalogue_update_routes.py (new, parametrised over the three catalogues with the services faked): an admin gets the service's answer from update-status and update-fetch; a member gets 403 on both without the service running; a failed download maps to 502 Catalogue fetch failed; the import route passes the caller, the selection and the body's locale to the service, and falls back to English without one. These are what lifted the diff-coverage gate: its first run read 63 % because no API test reached any of the nine route bodies.
    • test_catalogue_common.py: TestWheelUrlFromPypiPayload (the wheel is chosen whatever its position; sdist-only, a wheel entry with no URL, no urls and a missing info.version each raise with the literal message); TestCreateCatalogueCard (a created card carries the parent as a UUID, capabilityLevel recomputed from depth, created_by, DRAFT and one card.created event; a refused row rolls back alone, returns the 409 message rather than the detail dict, and the next row still lands; a plain-string refusal — the hierarchy depth guard — comes back untouched with its row gone).
    • test_capability_catalogue_service.py: test_fetch_remote_catalogue_refuses_a_release_without_a_wheel replaces the sdist test (raises, nothing cached, only the index requested); test_import_writes_cards_through_the_shared_write_path (a has_hierarchy type with reference_config auto BC-: references BC-0001…0003, hierarchyLevel 1/2/3, capabilityLevel L1/L2/L3, a weighted field raising data_quality, one card.created each); test_a_refused_entry_and_the_entries_below_it_are_reported (failed = the taken name and its child with parent not imported, neither created); test_macro_matched_by_catalogue_id_only_not_by_name now asserts the macro is reported with the 409 reason, nothing is created and the customer's card is untouched; capabilityLevel expectations follow the card's real depth.
    • test_process_catalogue_service.py / test_value_stream_catalogue_service.py: an imported branch carries hierarchyLevel 1/2 and one card.created per card; a stage pulls in its stream; and, in each, a refused entry (a shared name a root card already uses) and the entries beneath it come back in failed, none created.
    • test_card_reference.py: ReferenceAllocator continues after the highest reference in use, scans once per prefix (monkeypatched scan_highest_for_prefix records ["APP-", "PRC-"]), and leaves an off-mode or missing type without a reference.
    • test_updated_at_invariant.py::TestImportPathsRecordThemselves: a catalogue import records exactly one card.created with the importer's user_id.
    • test_db_session_holding.py (source scans): each of the six routes commits before its service call, update_fetch commits again after it, check_remote_version_for asks PyPI before it reads the cache, fetch_and_cache_all and the three imports contain no db.commit(), and each import route commits after await svc.import_.
    • test_cards_type_permissions.py::TestCatalogueImporterGate: the value-stream import returns 403 for a role with a Business Context Create deny and not for one that allows it.
  • Catalogue, frontend: CataloguePage.test.tsx — a response with failed shows the count and BC-2 — <reason>; a response from a backend older than 2.158.11 (no failed key) renders the summary alone.

  • Local checks: ruff format / ruff check clean; tsc -b and eslint on the touched files are clean (the four pre-existing no-explicit-any warnings in CataloguePage.tsx and the two Card Layout editor warnings predate this change); python scripts/dump_openapi.py leaves docs/api/openapi.json unchanged (the import routes return plain dicts); every module in the former import cycle imports cleanly when imported first in a fresh interpreter. As agreed, the test suites run in CI (Backend Integration, Frontend Tests, E2E and the mutation jobs).

  • All CI checks pass (backend lint, backend tests, frontend lint, frontend build, frontend tests, mutation tests)

  • Manually tested the affected feature

  • Added/updated tests for new or changed behavior

  • Mutation gate: my tests kill the mutants on the lines I changed (make mutation-diff). Score on the merged head 5b1d1c597: backend 92.1 % of 229 changed-line mutants (card_reference.py 100 %, card_write_service.py 100 %, catalogue_common.py 96.4 %, value_stream_catalogue_service.py 90.0 %, capability_catalogue_service.py 89.7 %, process_catalogue_service.py 83.7 %); frontend 87.9 % of 672 (sectionConfig.ts 96.7 %, historyChanges.ts 92.3 %, cardTabs.ts 91.2 %, CataloguePage.tsx 82.4 %, HistoryTab.tsx 71.7 %, CardDetailContent.tsx 59.4 %). Survivors kept, none pragma'd: in CardDetailContent.tsx the per-tab tab === "x" render guards (each panel's mount condition — swapping one renders the same panel twice, which the marker-stub tests cannot tell apart) and the ?. optional chains on typeConfig (equivalent: the type is loaded before the component renders); in CataloguePage.tsx two sx objects and the ?? [] fallback array; in the backend the LEVEL_TO_SUBTYPE.get(…, "process") default literal, allocator=None (the per-card reference path gives the same numbers with one scan per card instead of one per prefix), failed_ids.add(None) and continue → break on the last node of a refused chain, and two catalogue_common literals. Floors added in floors.toml [modules]: cardTabs.ts 89, historyChanges.ts 90, sectionConfig.ts 94.

Checklist

  • My changes follow the conventions in CLAUDE.md
  • I added permission checks to any new mutating endpoints
  • I created an Alembic migration for any schema changes
  • I did not introduce hardcoded card types or fields (metamodel is data-driven)
  • I used async def for all new route handlers and DB operations
  • I did not expose sensitive fields (password hashes, encrypted secrets) in API responses
  • I bumped /VERSION and added a CHANGELOG.md entry (if user-facing change)
  • I added translations for new UI strings in all 10 locales (if applicable)
  • I updated user documentation in docs/ (if UI or feature change)
  • Screenshots attached for UI changes (if applicable)

🤖 Generated with Claude Code

https://claude.ai/code/session_014eZ98kaESE7YBa84fT1msc


Generated by Claude Code

Admin -> Resources links to /cards/{id}?tab=resources, but the page
read ?tab as a number, so the link opened the Card tab. Every tab was
addressed by an index worked out by hand from the tabs before it, so a
tab appearing or vanishing as its count loaded (ADRs, risks,
compliance) also moved the selection onto its neighbour.

- cardTabs.ts: the ordered tab list (cardTabKeys), resolveCardTab for a
  key or an older index link, the visit rule and the extension-tab
  filter. MUI Tabs now take each tab's key as its value; a tab that
  vanished opens the Card tab.
- sectionConfig.ts: buildSectionOrder, now also used by the Card Layout
  editor in place of its private copy, plus the hidden-field,
  custom-section and calculated-field helpers.
- historyChanges.ts: one event table (label key, icon, colour), fmtVal,
  resolveFieldLabel, parseChanges and eventDetailModel, a discriminated
  union HistoryTab renders.
- useCardTabActivity's event map is typed with the tab keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014eZ98kaESE7YBa84fT1msc
The ADR tab shows while its count loads, so the two strip assertions now
wait for the strip to settle. CardDetail hands a non-numeric ?tab= to the
content as a key, which resolves it against its own strip, so the deep
link test asserts the key is passed on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014eZ98kaESE7YBa84fT1msc
…card-page

# Conflicts:
#	CHANGELOG.md
#	VERSION
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

Deploying turbo-ea-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: d0b5046
Status: ✅  Deploy successful!
Preview URL: https://38bdfefe.turbo-ea.pages.dev
Branch Preview URL: https://claude-mutation-lift-card-pa.turbo-ea.pages.dev

View logs

…alue-stream import on Business Context

Sdist fallback: `wheel_url_from_pypi_payload` picked a source distribution
when PyPI listed no wheel, but the extractor opens the bytes as a zip and
reads the wheel's paths, so the fallback could only ever fail with a
generic 502. It now raises a ValueError naming the project and version.

Session across PyPI: the six update-status / update-fetch routes commit
the request session before the service probes PyPI, so the pooled
connection the permission check used is handed back for the round-trip;
`check_remote_version_for` probes before it reads the cache and
`fetch_and_cache_all` no longer commits a session it was handed (the route
commits after it returns). Source-scan guards in test_db_session_holding.

Write path: the three catalogue imports create their cards through
`card_write_service.create_card` (`create_catalogue_card`, one savepoint
per row), so an imported card gets its History entry, its hierarchy level,
a reference when the type numbers cards automatically, calculations and a
data-quality score. A refused row (a name another card already uses at the
same level) comes back in a new `failed` list with the server's reason, and
the entries beneath a refused parent are reported as "parent not imported"
rather than created as roots. `ReferenceAllocator` scans each prefix once
per batch, shared with `POST /cards/bulk-create`. The services no longer
commit; the import routes do. The catalogue page shows the failed entries.

The value-stream import was gated on a `ValueStream` type that does not
exist; it is gated on Business Context, the type it creates.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014eZ98kaESE7YBa84fT1msc
@vincentmakes vincentmakes changed the title Open card tabs by key and lift the card page's rules into modules Open card tabs by key, lift the card page's rules, and fix three catalogue import issues Oct 10, 2026
`card_write_service` reaches `extensions.bundle` through `card_approval` and
`notification_service`, and `bundle` imports `version_tuple` from
`catalogue_common`, so the module-level import added for
`create_catalogue_card` closed a cycle: whichever side was imported first
failed with a partially initialised module (eight test modules failed to
collect in Backend Unit Tests). The helper now imports the write path inside
the function and takes the importing user, building the actor itself, so the
catalogue services and tests no longer need `WriteActor` from this module.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014eZ98kaESE7YBa84fT1msc
…ches

The diff-coverage gate read 63 % on cf2f903: no API test called the
three catalogues' update-status, update-fetch or import routes, so their
commit-before-PyPI, commit-after-fetch and 502 paths never ran under
coverage; only the capability import had a refused-row test, so the process
and value-stream imports' `failed` branches were unexercised; and the
plain-string refusal branch of `create_catalogue_card` had no test.

Adds `tests/api/test_catalogue_update_routes.py` (parametrised over the
three catalogues, services faked: admin gets the service's answer, a member
gets 403, a failed download maps to 502, the import route passes the
caller, the selection and the locale with the English fallback), a
refused-row test for the process and the value-stream import, and a
hierarchy-depth refusal case for `create_catalogue_card`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014eZ98kaESE7YBa84fT1msc
@vincentmakes
vincentmakes merged commit 696b915 into main Oct 10, 2026
38 checks passed
@vincentmakes
vincentmakes deleted the claude/mutation-lift-card-page branch October 10, 2026 14:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants