diff --git a/CHANGELOG.md b/CHANGELOG.md index dc02d6a03..64cfdefc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ 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.158.11] - 2026-10-10 + +### Fixed +- Admin → Resources: the link from a file or link to its card opens the card's **Resources** tab; it opened the Card tab. +- Card detail: the open tab stays open when a tab before it (ADRs, Risks, Compliance) appears or disappears as its count loads; the selection used to move to the neighbouring tab. +- Reference catalogues: imported cards now record their creation in History, get their level in your hierarchy, a reference number when the type uses automatic references and a data-quality score; an entry whose name another card already uses at the same level is reported instead of creating a duplicate, and the capability level of a card imported without its parent follows your hierarchy. +- Reference catalogues: checking for or fetching a catalogue update no longer keeps a database connection out of the pool while PyPI answers. +- Reference catalogues: a catalogue update from the online store reports a release published without a wheel as such instead of failing with a generic fetch error. +- Value Stream Catalogue: the import is gated by the Create permission on Business Context cards, the type it creates. + +### Changed +- Internal clean-up of card detail and its History tab: the rules that decide the tab strip, the section order and the history rows moved into modules with tests of their own, and the Card Layout editor takes its section order from the same function as the card. + ## [2.158.10] - 2026-10-10 ### Fixed diff --git a/VERSION b/VERSION index 738235039..c5923b424 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.158.10 +2.158.11 diff --git a/backend/app/api/v1/capability_catalogue.py b/backend/app/api/v1/capability_catalogue.py index 77bcdeccb..4e254418c 100644 --- a/backend/app/api/v1/capability_catalogue.py +++ b/backend/app/api/v1/capability_catalogue.py @@ -79,12 +79,15 @@ async def import_capabilities( always English-anchored so a localized import never duplicates a card. """ effective_locale = (payload.locale or user.locale or "en").strip() or "en" - return await svc.import_capabilities( + result = await svc.import_capabilities( db, user=user, catalogue_ids=payload.catalogue_ids, locale=effective_locale, ) + # The service stages its rows and never commits a session it was handed. + await db.commit() + return result @router.get("/update-status") @@ -93,6 +96,11 @@ async def update_status( user: User = Depends(require_permission("admin.metamodel")), ): """Compare the bundled/cached catalogue against the public site.""" + # The permission check used this request's session, so a pooled connection + # is already checked out. Hand it back before the PyPI round-trip: + # ``get_db`` is a yield-dependency that would otherwise pin it until the + # response is sent. + await db.commit() return await svc.check_remote_version(db) @@ -102,8 +110,17 @@ async def update_fetch( user: User = Depends(require_permission("admin.metamodel")), ): """Download the latest catalogue from the public site and cache it.""" + # The permission check used this request's session, so a pooled connection + # is already checked out. Hand it back before the PyPI round-trip: + # ``get_db`` is a yield-dependency that would otherwise pin it until the + # response is sent. + await db.commit() try: - return await svc.fetch_remote_catalogue(db) + result = await svc.fetch_remote_catalogue(db) + # The helper stages the cache rows and never commits a session it was + # handed; the write is committed here, once the download is over. + await db.commit() + return result except Exception: logger.exception("Capability catalogue fetch failed") raise HTTPException(status_code=502, detail="Catalogue fetch failed") diff --git a/backend/app/api/v1/cards.py b/backend/app/api/v1/cards.py index 851d91fb3..e36285c63 100644 --- a/backend/app/api/v1/cards.py +++ b/backend/app/api/v1/cards.py @@ -822,7 +822,7 @@ async def bulk_create_cards( # sharing a prefix stay contiguous, each seeded once via a global scan. # Rolled-back rows leave gaps, which is acceptable by design. _ref_types: dict[str, CardType | None] = {} - _ref_next: dict[str, int] = {} + allocator = card_reference.ReferenceAllocator() # Fields schemas for every type in the batch, fetched once. The select-option # check below runs per row, so it must not repeat a query per row the way @@ -842,18 +842,7 @@ async def _bulk_assign_reference(card: Card, row) -> None: _ref_types[row.type] = ( await db.execute(select(CardType).where(CardType.key == row.type)) ).scalar_one_or_none() - ct = _ref_types[row.type] - if ct is None or card_reference.get_mode(ct) != "auto": - return - cfg = ct.reference_config or {} - prefix = str(cfg.get("prefix", "") or "") - start = int(cfg.get("start", card_reference.DEFAULT_START)) - padding = int(cfg.get("padding", card_reference.DEFAULT_PADDING)) - if prefix not in _ref_next: - _ref_next[prefix] = await card_reference.scan_highest_for_prefix(db, prefix, start) - n = _ref_next[prefix] + 1 - _ref_next[prefix] = n - card.reference = card_reference.format_reference(prefix, padding, n) + await allocator.assign(db, card, _ref_types[row.type]) for row_idx in order: r = rows_by_index[row_idx] diff --git a/backend/app/api/v1/process_catalogue.py b/backend/app/api/v1/process_catalogue.py index 58e0669a3..bf6904237 100644 --- a/backend/app/api/v1/process_catalogue.py +++ b/backend/app/api/v1/process_catalogue.py @@ -62,12 +62,15 @@ async def import_processes( if the target capability hasn't been imported yet). """ effective_locale = (payload.locale or user.locale or "en").strip() or "en" - return await svc.import_processes( + result = await svc.import_processes( db, user=user, catalogue_ids=payload.catalogue_ids, locale=effective_locale, ) + # The service stages its rows and never commits a session it was handed. + await db.commit() + return result @router.get("/update-status") @@ -76,6 +79,11 @@ async def update_status( user: User = Depends(require_permission("admin.metamodel")), ): """Compare the bundled/cached process catalogue against PyPI.""" + # The permission check used this request's session, so a pooled connection + # is already checked out. Hand it back before the PyPI round-trip: + # ``get_db`` is a yield-dependency that would otherwise pin it until the + # response is sent. + await db.commit() return await svc.check_remote_version(db) @@ -85,8 +93,17 @@ async def update_fetch( user: User = Depends(require_permission("admin.metamodel")), ): """Download the latest wheel from PyPI. Hydrates all three caches.""" + # The permission check used this request's session, so a pooled connection + # is already checked out. Hand it back before the PyPI round-trip: + # ``get_db`` is a yield-dependency that would otherwise pin it until the + # response is sent. + await db.commit() try: - return await svc.fetch_remote_catalogue(db) + result = await svc.fetch_remote_catalogue(db) + # The helper stages the cache rows and never commits a session it was + # handed; the write is committed here, once the download is over. + await db.commit() + return result except Exception: logger.exception("Process catalogue fetch failed") raise HTTPException(status_code=502, detail="Catalogue fetch failed") diff --git a/backend/app/api/v1/value_stream_catalogue.py b/backend/app/api/v1/value_stream_catalogue.py index cfe3d1d64..26b86a765 100644 --- a/backend/app/api/v1/value_stream_catalogue.py +++ b/backend/app/api/v1/value_stream_catalogue.py @@ -51,7 +51,9 @@ async def get_catalogue( async def import_value_streams( payload: ImportRequest, db: AsyncSession = Depends(get_db), - user: User = Depends(require_permission("inventory.create", card_type_key="ValueStream")), + # Gated on the type the import creates. It named a `ValueStream` type that + # does not exist, so a per-type Create deny on Business Context never applied. + user: User = Depends(require_permission("inventory.create", card_type_key="BusinessContext")), ): """Create BusinessContext / valueStream cards for selected streams or stages. Selecting a stage automatically pulls in its parent stream so @@ -59,12 +61,15 @@ async def import_value_streams( relProcessToBizCtx relations to any matching capability/process cards. """ effective_locale = (payload.locale or user.locale or "en").strip() or "en" - return await svc.import_value_streams( + result = await svc.import_value_streams( db, user=user, catalogue_ids=payload.catalogue_ids, locale=effective_locale, ) + # The service stages its rows and never commits a session it was handed. + await db.commit() + return result @router.get("/update-status") @@ -72,6 +77,11 @@ async def update_status( db: AsyncSession = Depends(get_db), user: User = Depends(require_permission("admin.metamodel")), ): + # The permission check used this request's session, so a pooled connection + # is already checked out. Hand it back before the PyPI round-trip: + # ``get_db`` is a yield-dependency that would otherwise pin it until the + # response is sent. + await db.commit() return await svc.check_remote_version(db) @@ -80,8 +90,17 @@ async def update_fetch( db: AsyncSession = Depends(get_db), user: User = Depends(require_permission("admin.metamodel")), ): + # The permission check used this request's session, so a pooled connection + # is already checked out. Hand it back before the PyPI round-trip: + # ``get_db`` is a yield-dependency that would otherwise pin it until the + # response is sent. + await db.commit() try: - return await svc.fetch_remote_catalogue(db) + result = await svc.fetch_remote_catalogue(db) + # The helper stages the cache rows and never commits a session it was + # handed; the write is committed here, once the download is over. + await db.commit() + return result except Exception: logger.exception("Value-stream catalogue fetch failed") raise HTTPException(status_code=502, detail="Catalogue fetch failed") diff --git a/backend/app/services/capability_catalogue_service.py b/backend/app/services/capability_catalogue_service.py index 010a15538..16948dd47 100644 --- a/backend/app/services/capability_catalogue_service.py +++ b/backend/app/services/capability_catalogue_service.py @@ -23,7 +23,6 @@ import turbo_ea_capabilities as catalogue_pkg from sqlalchemy.ext.asyncio import AsyncSession -from app.models.card import Card from app.models.user import User from app.services import catalogue_common as common @@ -272,6 +271,11 @@ async def import_capabilities( same call so the catalogue hierarchy is reproduced. - Re-parents existing children whose new catalogue parent was just created in this batch. + - Creates every card through the shared write path + (``common.create_catalogue_card``); a row it refuses — a name another + card already uses at that level — is reported in ``failed`` together + with the entries below it, and the rest of the batch goes on. Stages + only: the route commits. """ flat, meta = await _resolve_active_catalogue(db, locale=locale) by_id = {c["id"]: c for c in flat} @@ -283,9 +287,12 @@ async def import_capabilities( created: list[dict[str, str]] = [] skipped: list[dict[str, str]] = [] + failed: list[dict[str, str]] = [] + failed_ids: set[str] = set() created_in_batch: set[str] = set() now = common.now_iso() user_id = user.id + allocator = common.ReferenceAllocator() for cap in ordered: if cap["id"] in pre_existing_ids: @@ -299,22 +306,30 @@ async def import_capabilities( continue cat_parent = cap.get("parent_id") + if cat_parent in failed_ids: + failed.append({"catalogue_id": cap["id"], "reason": common.PARENT_NOT_IMPORTED}) + failed_ids.add(cap["id"]) + continue level_key = ( MACRO_CAPABILITY_LEVEL_KEY if cap["level"] == MACRO_LEVEL else f"L{cap['level']}" ) - card = Card( - type=BUSINESS_CAPABILITY_TYPE, + card, reason = await common.create_catalogue_card( + db, + user, + type_key=BUSINESS_CAPABILITY_TYPE, name=cap["name"], + subtype=None, description=cap.get("description"), parent_id=catalogue_id_to_card_id.get(cat_parent) if cat_parent else None, attributes=common.catalogue_attributes( cap, meta, now, ("capabilityLevel", level_key), _COPIED_ATTRIBUTES ), - created_by=user_id, - updated_by=user_id, + allocator=allocator, ) - db.add(card) - await db.flush() + if card is None: + failed.append({"catalogue_id": cap["id"], "reason": reason}) + failed_ids.add(cap["id"]) + continue catalogue_id_to_card_id[cap["id"]] = str(card.id) created.append({"catalogue_id": cap["id"], "card_id": str(card.id)}) created_in_batch.add(cap["id"]) @@ -328,11 +343,11 @@ async def import_capabilities( user_id=user_id, ) - await db.commit() macro_warnings = list(meta.get("macro_warnings") or []) return { "created": created, "skipped": skipped, + "failed": failed, "relinked": relinked, "catalogue_version": meta.get("catalogue_version"), "warnings": macro_warnings, diff --git a/backend/app/services/card_reference.py b/backend/app/services/card_reference.py index 479ec76be..cc55c6123 100644 --- a/backend/app/services/card_reference.py +++ b/backend/app/services/card_reference.py @@ -153,6 +153,33 @@ async def next_reference(db: AsyncSession, card_type: CardType) -> str | None: return await next_reference_for_prefix(db, _prefix(cfg), _start(cfg), _padding(cfg)) +class ReferenceAllocator: + """Hand out consecutive references across a batch with one global scan per prefix. + + ``next_reference`` scans every ``cards.reference`` on each call, which is + right for one card and wrong for a 500-card import. The Excel importer + (``POST /cards/bulk-create``) and the reference-catalogue imports share + this instead: cards sharing a prefix stay contiguous, and a row rolled + back after its number was taken leaves a gap, which is acceptable by + design. + """ + + def __init__(self) -> None: + self._next: dict[str, int] = {} + + async def assign(self, db: AsyncSession, card: Card, card_type: CardType | None) -> None: + """Set ``card.reference`` for an ``auto``-mode type; leave it NULL otherwise.""" + if card_type is None or get_mode(card_type) != "auto": + return + cfg = _cfg(card_type) + prefix = _prefix(cfg) + if prefix not in self._next: + self._next[prefix] = await scan_highest_for_prefix(db, prefix, _start(cfg)) + n = self._next[prefix] + 1 + self._next[prefix] = n + card.reference = format_reference(prefix, _padding(cfg), n) + + async def backfill_references_for_type(db: AsyncSession, card_type: CardType) -> int: """Assign references to existing ID-less cards of an ``auto``-mode type. diff --git a/backend/app/services/card_write_service.py b/backend/app/services/card_write_service.py index cfb64bf20..7fa44943c 100644 --- a/backend/app/services/card_write_service.py +++ b/backend/app/services/card_write_service.py @@ -740,9 +740,15 @@ async def create_card( alias: str | None = None, strict_attributes: bool = False, dry_run: bool = False, + reference_allocator: card_reference.ReferenceAllocator | None = None, ) -> Card: """Create one card with full validation and side effects; returns the - flushed (uncommitted) row. Caller owns permission checks + the commit.""" + flushed (uncommitted) row. Caller owns permission checks + the commit. + + ``reference_allocator`` lets a batch (the reference-catalogue imports) + number its cards with one global scan per prefix instead of one per + card; without it the reference is allocated exactly as before. + """ await _validate_url_attributes(db, type_key, attributes or {}) await _validate_percentage_attributes(db, type_key, attributes or {}) await _validate_cost_attributes(db, type_key, attributes or {}) @@ -775,7 +781,10 @@ async def create_card( card_type_row = ( await db.execute(select(CardType).where(CardType.key == type_key)) ).scalar_one_or_none() - await _assign_reference_on_create(db, card, card_type_row) + if reference_allocator is not None: + await reference_allocator.assign(db, card, card_type_row) + else: + await _assign_reference_on_create(db, card, card_type_row) # Guard: hierarchy depth limit for BusinessCapability if card.parent_id: diff --git a/backend/app/services/catalogue_common.py b/backend/app/services/catalogue_common.py index e7b7f78f1..630a88ce5 100644 --- a/backend/app/services/catalogue_common.py +++ b/backend/app/services/catalogue_common.py @@ -34,11 +34,14 @@ from typing import Any import httpx +from fastapi import HTTPException from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.models.card import Card from app.models.relation import Relation +from app.models.user import User +from app.services.card_reference import ReferenceAllocator from app.services.event_bus import event_bus logger = logging.getLogger(__name__) @@ -556,6 +559,66 @@ async def card_lookup(db: AsyncSession, card_type: str) -> dict[str, str]: return {**name_index, **cat_id_index} +PARENT_NOT_IMPORTED = "parent not imported" + + +async def create_catalogue_card( + db: AsyncSession, + user: User, + *, + type_key: str, + name: str, + subtype: str | None, + description: str | None, + parent_id: str | None, + attributes: dict[str, Any], + allocator: ReferenceAllocator, +) -> tuple[Card | None, str]: + """Create one imported card through the shared write path, in its own savepoint. + + A catalogue card takes the road every other card takes + (``card_write_service.create_card``): validation, the sibling-name check, + its reference when the type numbers cards automatically, the hierarchy + depth guard and level sync, calculations, data quality and the + ``card.created`` event that puts it in the History tab (CLAUDE.md, *A + write path that can move cards.updated_at must also persist a card + event*). The imports used to build ``Card`` rows directly and got none + of that. A row the write path refuses rolls back alone and comes back as + ``(None, reason)``, so one taken name cannot fail a 500-card batch — the + bulk-create shape. ``reason`` is empty when the card was created. + + ``card_write_service`` is imported here, not at module level: it reaches + ``extensions.bundle`` through ``card_approval`` → ``notification_service``, + and ``bundle`` imports ``version_tuple`` from this module, so a top-level + import closes a cycle that breaks whichever side is imported first. + """ + from app.services import card_write_service + + savepoint = await db.begin_nested() + try: + card = await card_write_service.create_card( + db, + card_write_service.WriteActor.from_user(user), + type_key=type_key, + name=name, + subtype=subtype, + description=description, + parent_id=uuid.UUID(parent_id) if parent_id else None, + attributes=attributes, + reference_allocator=allocator, + ) + except HTTPException as exc: + await savepoint.rollback() + # The sibling-name check raises a structured detail whose ``message`` + # is what the API client shows; other refusals are plain strings. + detail = exc.detail + if isinstance(detail, dict) and isinstance(detail.get("message"), str): + return None, detail["message"] + return None, str(detail) + await savepoint.commit() + return card, "" + + async def add_relation_once( db: AsyncSession, *, @@ -673,9 +736,11 @@ def catalogue_attributes( def wheel_url_from_pypi_payload(payload: dict[str, Any]) -> tuple[str, str]: """Pick the wheel artefact URL and version from a PyPI JSON response. - Falls back to a sdist if no wheel is published — the extraction logic - only depends on the `data/*.json` paths which both distribution formats - use. + Only a wheel will do. The extractor reads the wheel's zip layout and its + ``turbo_ea_capabilities/data/*.json`` paths; an sdist is a ``.tar.gz`` + with everything under a ``-/`` folder, so it has neither + the container nor the paths. This used to fall back to the sdist, which + then failed inside ``zipfile`` and surfaced as a generic fetch error. """ info = payload.get("info") or {} version = info.get("version") @@ -683,11 +748,9 @@ def wheel_url_from_pypi_payload(payload: dict[str, Any]) -> tuple[str, str]: raise ValueError("PyPI response missing info.version") urls = payload.get("urls") or [] wheel = next((u for u in urls if u.get("packagetype") == "bdist_wheel"), None) - fallback = next((u for u in urls if u.get("packagetype") == "sdist"), None) - chosen = wheel or fallback - if not chosen or not chosen.get("url"): - raise ValueError(f"PyPI did not list a downloadable artefact for {PYPI_PROJECT_NAME}") - return str(chosen["url"]), version + if not wheel or not wheel.get("url"): + raise ValueError(f"PyPI lists no wheel for {PYPI_PROJECT_NAME} {version}") + return str(wheel["url"]), version async def fetch_wheel_from_pypi() -> tuple[bytes, str]: @@ -695,7 +758,7 @@ async def fetch_wheel_from_pypi() -> tuple[bytes, str]: Returns `(wheel_bytes, pypi_version)`. Caller is responsible for extracting + caching. Raises `httpx.HTTPError` / `ValueError` on - upstream issues. + upstream issues, a release published without a wheel included. """ async with httpx.AsyncClient( timeout=CATALOGUE_FETCH_TIMEOUT_SECONDS, follow_redirects=True @@ -814,11 +877,13 @@ async def check_remote_version_for( `catalogue_pkg.VERSION`. `cache_key` selects which cached-remote payload to compare against, so the "update available" badge reflects the right artefact even though all three caches are filled by the same wheel. - """ - cached = await get_cached_remote(db, cache_key) - serving_remote = remote_wins(cached, bundled_version) - active_version = cached["catalogue_version"] if cached and serving_remote else bundled_version + The PyPI probe runs BEFORE the cache read. The route hands its pooled + connection back (``await db.commit()``) before calling this, and a query + issued ahead of the probe would take it out again for the whole 30 s + round-trip (CLAUDE.md, *Never hold a database session across work that + is not database work*). + """ remote_meta: dict[str, Any] | None = None error: str | None = None try: @@ -840,6 +905,10 @@ async def check_remote_version_for( logger.exception("PyPI version check failed") error = "Could not reach PyPI" + cached = await get_cached_remote(db, cache_key) + serving_remote = remote_wins(cached, bundled_version) + active_version = cached["catalogue_version"] if cached and serving_remote else bundled_version + update_available = False if remote_meta and "catalogue_version" in remote_meta: update_available = version_tuple(remote_meta["catalogue_version"]) > version_tuple( @@ -865,6 +934,11 @@ async def fetch_and_cache_all(db: AsyncSession) -> dict[str, Any]: streams, so any of the three "Fetch update" admin actions only needs to call this once. The return value includes per-artefact counts so each route can surface a domain-appropriate summary. + + Stages the cache rows in the caller's transaction and never commits: + the route commits once the download is over, so the pooled connection + is not held across the PyPI round-trip (the route also commits BEFORE + calling this, for the same reason). """ wheel_bytes, pypi_version = await fetch_wheel_from_pypi() artefacts = extract_all_catalogues_from_wheel(wheel_bytes) @@ -918,7 +992,6 @@ async def fetch_and_cache_all(db: AsyncSession) -> dict[str, Any]: if updates: await set_cached_remote(db, updates) - await db.commit() return { "catalogue_version": catalogue_version, diff --git a/backend/app/services/process_catalogue_service.py b/backend/app/services/process_catalogue_service.py index 5cb952a73..b070466aa 100644 --- a/backend/app/services/process_catalogue_service.py +++ b/backend/app/services/process_catalogue_service.py @@ -24,7 +24,6 @@ import turbo_ea_capabilities as catalogue_pkg from sqlalchemy.ext.asyncio import AsyncSession -from app.models.card import Card from app.models.user import User from app.services import catalogue_common as common @@ -178,6 +177,9 @@ async def import_processes( - for each newly-created card, auto-create `relProcessToBC` relations for the entries in `realizes_capability_ids` whose target BC card exists (matched by `attributes.catalogueId` or English name) + - every card goes through the shared write path + (``common.create_catalogue_card``); a refused row and the entries + below it land in ``failed``. Stages only: the route commits. """ flat, meta = await _resolve_active_catalogue(db, locale=locale) by_id = {p["id"]: p for p in flat} @@ -190,10 +192,13 @@ async def import_processes( created: list[dict[str, str]] = [] skipped: list[dict[str, str]] = [] + failed: list[dict[str, str]] = [] + failed_ids: set[str] = set() auto_relations_total = 0 created_in_batch: set[str] = set() now = common.now_iso() user_id = user.id + allocator = common.ReferenceAllocator() for proc in ordered: if proc["id"] in pre_existing_ids: @@ -207,20 +212,27 @@ async def import_processes( continue cat_parent = proc.get("parent_id") - card = Card( - type=BUSINESS_PROCESS_TYPE, - subtype=LEVEL_TO_SUBTYPE.get(int(proc["level"]), "process"), + if cat_parent in failed_ids: + failed.append({"catalogue_id": proc["id"], "reason": common.PARENT_NOT_IMPORTED}) + failed_ids.add(proc["id"]) + continue + card, reason = await common.create_catalogue_card( + db, + user, + type_key=BUSINESS_PROCESS_TYPE, name=proc["name"], + subtype=LEVEL_TO_SUBTYPE.get(int(proc["level"]), "process"), description=proc.get("description"), parent_id=catalogue_id_to_card_id.get(cat_parent) if cat_parent else None, attributes=common.catalogue_attributes( proc, meta, now, ("processLevel", f"L{proc['level']}"), _COPIED_ATTRIBUTES ), - created_by=user_id, - updated_by=user_id, + allocator=allocator, ) - db.add(card) - await db.flush() + if card is None: + failed.append({"catalogue_id": proc["id"], "reason": reason}) + failed_ids.add(proc["id"]) + continue catalogue_id_to_card_id[proc["id"]] = str(card.id) auto_relations_total += await _create_realizes_relations( @@ -242,10 +254,10 @@ async def import_processes( user_id=user_id, ) - await db.commit() return { "created": created, "skipped": skipped, + "failed": failed, "relinked": relinked, "auto_relations_created": auto_relations_total, "catalogue_version": meta.get("catalogue_version"), diff --git a/backend/app/services/value_stream_catalogue_service.py b/backend/app/services/value_stream_catalogue_service.py index ea2ea05e5..b7855aa67 100644 --- a/backend/app/services/value_stream_catalogue_service.py +++ b/backend/app/services/value_stream_catalogue_service.py @@ -30,7 +30,6 @@ import turbo_ea_capabilities as catalogue_pkg from sqlalchemy.ext.asyncio import AsyncSession -from app.models.card import Card from app.models.user import User from app.services import catalogue_common as common @@ -334,6 +333,10 @@ async def import_value_streams( payload is already flattened into the standard `parent_id`-bearing shape, so the existing `bfs_order_by_parent` helper handles this when we enrich the requested set with the parent ids of any selected stages. + + Every card goes through the shared write path + (``common.create_catalogue_card``); a refused row and the stages below + it land in ``failed``. Stages only: the route commits. """ flat, meta = await _resolve_active_catalogue(db, locale=locale) by_id = {n["id"]: n for n in flat} @@ -358,10 +361,13 @@ async def import_value_streams( created: list[dict[str, str]] = [] skipped: list[dict[str, str]] = [] + failed: list[dict[str, str]] = [] + failed_ids: set[str] = set() auto_relations_total = 0 created_in_batch: set[str] = set() now = common.now_iso() user_id = user.id + allocator = common.ReferenceAllocator() for node in ordered: if node["id"] in pre_existing_ids: @@ -375,23 +381,30 @@ async def import_value_streams( continue cat_parent = node.get("parent_id") + if cat_parent in failed_ids: + failed.append({"catalogue_id": node["id"], "reason": common.PARENT_NOT_IMPORTED}) + failed_ids.add(node["id"]) + continue is_stream = node["level"] == LEVEL_STREAM attrs = ( _stream_attributes(node, meta, now) if is_stream else _stage_attributes(node, meta, now) ) - card = Card( - type=VALUE_STREAM_TYPE, - subtype=VALUE_STREAM_SUBTYPE, + card, reason = await common.create_catalogue_card( + db, + user, + type_key=VALUE_STREAM_TYPE, name=node.get("name") or node.get("stage_name") or node["id"], + subtype=VALUE_STREAM_SUBTYPE, description=node.get("description"), parent_id=catalogue_id_to_card_id.get(cat_parent) if cat_parent else None, attributes=attrs, - created_by=user_id, - updated_by=user_id, + allocator=allocator, ) - db.add(card) - await db.flush() + if card is None: + failed.append({"catalogue_id": node["id"], "reason": reason}) + failed_ids.add(node["id"]) + continue catalogue_id_to_card_id[node["id"]] = str(card.id) if not is_stream: @@ -416,10 +429,10 @@ async def import_value_streams( user_id=user_id, ) - await db.commit() return { "created": created, "skipped": skipped, + "failed": failed, "relinked": relinked, "auto_relations_created": auto_relations_total, "catalogue_version": meta.get("catalogue_version"), diff --git a/backend/tests/api/test_cards_type_permissions.py b/backend/tests/api/test_cards_type_permissions.py index 3f8139aac..eb99a2822 100644 --- a/backend/tests/api/test_cards_type_permissions.py +++ b/backend/tests/api/test_cards_type_permissions.py @@ -356,3 +356,32 @@ async def test_import_allowed_when_the_type_grants_create(self, client, db, env) # Past the permission gate — whatever the importer then makes of an # empty selection is not this test's concern. assert response.status_code != 403 + + async def test_value_stream_import_is_gated_on_the_type_it_creates(self, client, db, env): + """The value-stream importer creates Business Context cards. It used to + name a `ValueStream` type that does not exist, so a Create deny on + Business Context never reached it.""" + await create_card_type(db, key="BusinessContext", label="Business Context") + PermissionService.invalidate_type_permission_cache() + await set_overrides(db, "BusinessContext", {"member": {"inventory.create": False}}) + + response = await client.post( + "/api/v1/value-stream-catalogue/import", + json={"ids": ["anything"]}, + headers=auth_headers(env["member"]), + ) + assert response.status_code == 403 + + async def test_value_stream_import_allowed_when_business_context_grants_create( + self, client, db, env + ): + await create_card_type(db, key="BusinessContext", label="Business Context") + PermissionService.invalidate_type_permission_cache() + await set_overrides(db, "BusinessContext", {"viewer": {"inventory.create": True}}) + + response = await client.post( + "/api/v1/value-stream-catalogue/import", + json={"ids": []}, + headers=auth_headers(env["viewer"]), + ) + assert response.status_code != 403 diff --git a/backend/tests/api/test_catalogue_update_routes.py b/backend/tests/api/test_catalogue_update_routes.py new file mode 100644 index 000000000..8f1552a8e --- /dev/null +++ b/backend/tests/api/test_catalogue_update_routes.py @@ -0,0 +1,163 @@ +"""Route-level tests for the reference catalogues' update and import endpoints. + +The three catalogue routers share one shape (CLAUDE.md, *Never hold a database +session across work that is not database work*): ``update-status`` and +``update-fetch`` hand the request's connection back before the service talks +to PyPI, ``update-fetch`` commits the cache the service staged, and ``import`` +commits the cards the service staged. The services are faked here — their own +suites cover what they do — so these tests pin what the routes add: the gate, +the commit path, the 502 mapping and the locale fallback. The *position* of +each commit is pinned by the source scans in +``tests/services/test_db_session_holding.py``. +""" + +from __future__ import annotations + +import importlib + +import pytest + +from tests.conftest import auth_headers, create_user + +CATALOGUES = [ + pytest.param( + "capability-catalogue", + "app.services.capability_catalogue_service", + "import_capabilities", + id="capability", + ), + pytest.param( + "process-catalogue", + "app.services.process_catalogue_service", + "import_processes", + id="process", + ), + pytest.param( + "value-stream-catalogue", + "app.services.value_stream_catalogue_service", + "import_value_streams", + id="value-stream", + ), +] + +STATUS = {"active_version": "2.0.0", "active_source": "bundled", "update_available": False} +FETCHED = {"catalogue_version": "2.1.0", "node_count": 3} +IMPORTED = { + "created": [], + "skipped": [], + "relinked": [], + "failed": [], + "catalogue_version": "2.0.0", +} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("prefix, module_name, import_fn", CATALOGUES) +async def test_update_status_returns_the_service_answer_to_an_admin( + client, db, admin_role, monkeypatch, prefix, module_name, import_fn +): + svc = importlib.import_module(module_name) + seen: list[object] = [] + + async def fake_check(session): + seen.append(session) + return dict(STATUS) + + monkeypatch.setattr(svc, "check_remote_version", fake_check) + admin = await create_user(db, email=f"{prefix}-status@test.com", role="admin") + + response = await client.get(f"/api/v1/{prefix}/update-status", headers=auth_headers(admin)) + + assert response.status_code == 200, response.text + assert response.json() == STATUS + assert len(seen) == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("prefix, module_name, import_fn", CATALOGUES) +async def test_update_routes_need_admin_metamodel( + client, db, member_role, monkeypatch, prefix, module_name, import_fn +): + svc = importlib.import_module(module_name) + + async def never(session): + raise AssertionError("the service must not run for a refused caller") + + monkeypatch.setattr(svc, "check_remote_version", never) + monkeypatch.setattr(svc, "fetch_remote_catalogue", never) + member = await create_user(db, email=f"{prefix}-member@test.com", role="member") + headers = auth_headers(member) + + assert (await client.get(f"/api/v1/{prefix}/update-status", headers=headers)).status_code == 403 + assert (await client.post(f"/api/v1/{prefix}/update-fetch", headers=headers)).status_code == 403 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("prefix, module_name, import_fn", CATALOGUES) +async def test_update_fetch_returns_what_the_service_cached( + client, db, admin_role, monkeypatch, prefix, module_name, import_fn +): + svc = importlib.import_module(module_name) + + async def fake_fetch(session): + return dict(FETCHED) + + monkeypatch.setattr(svc, "fetch_remote_catalogue", fake_fetch) + admin = await create_user(db, email=f"{prefix}-fetch@test.com", role="admin") + + response = await client.post(f"/api/v1/{prefix}/update-fetch", headers=auth_headers(admin)) + + assert response.status_code == 200, response.text + assert response.json() == FETCHED + + +@pytest.mark.asyncio +@pytest.mark.parametrize("prefix, module_name, import_fn", CATALOGUES) +async def test_update_fetch_maps_a_failed_download_to_502( + client, db, admin_role, monkeypatch, prefix, module_name, import_fn +): + svc = importlib.import_module(module_name) + + async def boom(session): + raise RuntimeError("PyPI unreachable") + + monkeypatch.setattr(svc, "fetch_remote_catalogue", boom) + admin = await create_user(db, email=f"{prefix}-boom@test.com", role="admin") + + response = await client.post(f"/api/v1/{prefix}/update-fetch", headers=auth_headers(admin)) + + assert response.status_code == 502 + assert response.json()["detail"] == "Catalogue fetch failed" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("prefix, module_name, import_fn", CATALOGUES) +async def test_import_passes_the_caller_selection_and_locale_to_the_service( + client, db, admin_role, monkeypatch, prefix, module_name, import_fn +): + svc = importlib.import_module(module_name) + calls: list[tuple] = [] + + async def fake_import(session, *, user, catalogue_ids, locale): + calls.append((user.id, list(catalogue_ids), locale)) + return dict(IMPORTED) + + monkeypatch.setattr(svc, import_fn, fake_import) + admin = await create_user(db, email=f"{prefix}-import@test.com", role="admin") + headers = auth_headers(admin) + + first = await client.post( + f"/api/v1/{prefix}/import", + json={"catalogue_ids": ["X-1", "X-2"], "locale": "de"}, + headers=headers, + ) + assert first.status_code == 200, first.text + assert first.json() == IMPORTED + + # No locale in the body and none saved on the user: English. + second = await client.post( + f"/api/v1/{prefix}/import", json={"catalogue_ids": ["X-3"]}, headers=headers + ) + assert second.status_code == 200, second.text + + assert calls == [(admin.id, ["X-1", "X-2"], "de"), (admin.id, ["X-3"], "en")] diff --git a/backend/tests/services/test_capability_catalogue_service.py b/backend/tests/services/test_capability_catalogue_service.py index 7ab13bd6b..e335b0bee 100644 --- a/backend/tests/services/test_capability_catalogue_service.py +++ b/backend/tests/services/test_capability_catalogue_service.py @@ -21,7 +21,7 @@ from app.models.card import Card from app.services import catalogue_common as common -from tests.conftest import create_card, create_user +from tests.conftest import create_card, create_card_type, create_user # --------------------------------------------------------------------------- # Fake catalogue package @@ -229,7 +229,9 @@ async def test_import_creates_hierarchy_and_skips_existing(db, monkeypatch): assert len(rows) == 1 new_child = rows[0] assert str(new_child.parent_id) == str(existing_parent.id) - assert new_child.attributes["capabilityLevel"] == "L3" + # Its level is its depth in THIS hierarchy: the existing parent is a root + # card, so the catalogue's L3 lands as L2, as any card created under it would. + assert new_child.attributes["capabilityLevel"] == "L2" assert new_child.attributes["catalogueVersion"] == "1.2.3" @@ -881,18 +883,18 @@ def route(url: str) -> _FakeHttpResponse: @pytest.mark.asyncio -async def test_fetch_remote_catalogue_falls_back_to_sdist_when_no_wheel(db, monkeypatch): - """If a release ships an sdist but no wheel (rare, but legal on PyPI), - the extractor still works because the `.tar.gz` carries the same JSON - paths. We exercise the fallback by serving the wheel bytes through an - sdist-typed URL — same payload, different `packagetype`.""" +async def test_fetch_remote_catalogue_refuses_a_release_without_a_wheel(db, monkeypatch): + """A release published with only an sdist cannot be read: the extractor + needs the wheel's zip layout. The fetch says so by name, downloads + nothing, and leaves the cache as it was.""" _install_fake_pkg(monkeypatch) from app.services import capability_catalogue_service as svc - wheel_bytes = _build_fake_wheel(version="1.5.0") sdist_url = "https://files.pythonhosted.org/packages/cc/dd/turbo_ea_capabilities-1.5.0.tar.gz" + requested: list[str] = [] def route(url: str) -> _FakeHttpResponse: + requested.append(url) if url == common.PYPI_INDEX_URL: return _FakeHttpResponse( json_data={ @@ -900,14 +902,14 @@ def route(url: str) -> _FakeHttpResponse: "urls": [{"packagetype": "sdist", "url": sdist_url}], } ) - if url == sdist_url: - return _FakeHttpResponse(content=wheel_bytes) raise AssertionError(f"unexpected URL {url}") monkeypatch.setattr(common.httpx, "AsyncClient", lambda **kw: _FakeHttpClient(route, **kw)) - result = await svc.fetch_remote_catalogue(db) - assert result["catalogue_version"] == "1.5.0" + with pytest.raises(ValueError, match="PyPI lists no wheel for turbo-ea-capabilities 1.5.0"): + await svc.fetch_remote_catalogue(db) + assert requested == [common.PYPI_INDEX_URL] + assert await common.get_cached_remote(db, common.CAPABILITY_CACHE_KEY) is None @pytest.mark.asyncio @@ -1376,6 +1378,12 @@ async def test_macro_matched_by_catalogue_id_only_not_by_name(db, monkeypatch): `catalogueId`. Otherwise the next macro import would silently re-parent every unrelated L1 (whose capability_ids include "BC-1") under the customer's pre-existing card. + + The import goes through the shared card write path, which refuses a + second root card of the type with the same name — as the Create dialog + and the Excel importer do. So the macro is offered, and on import it is + *reported* rather than created or matched: the customer's card stays as + it is, and nothing is re-parented under it. """ _install_fake_pkg(monkeypatch, macros=_FAKE_MACROS) from app.services import capability_catalogue_service as svc @@ -1394,16 +1402,26 @@ async def test_macro_matched_by_catalogue_id_only_not_by_name(db, monkeypatch): by_id = {c["id"]: c for c in payload["capabilities"]} assert by_id["MC-10"]["existing_card_id"] is None - # POST import must create the macro as a NEW card, not skip on the - # name match. + # POST import must not skip on the name match, and must not create a + # same-named sibling either: the row is reported with the write path's + # own reason. result = await svc.import_capabilities(db, user=user, catalogue_ids=["MC-10"]) - assert len(result["created"]) == 1 - macro_card = ( + assert result["created"] == [] + assert result["skipped"] == [] + assert [f["catalogue_id"] for f in result["failed"]] == ["MC-10"] + reason = result["failed"][0]["reason"] + assert reason.startswith('A card of type BusinessCapability named "Customer Experience"') + assert str(unrelated_card.id) in reason + + macro_cards = ( (await db.execute(select(Card).where(Card.attributes["catalogueId"].astext == "MC-10"))) .scalars() - .one() + .all() ) - assert macro_card.id != unrelated_card.id + assert macro_cards == [] + await db.refresh(unrelated_card) + assert unrelated_card.parent_id is None + assert "catalogueId" not in (unrelated_card.attributes or {}) @pytest.mark.asyncio @@ -1605,7 +1623,9 @@ async def test_imported_capability_attributes(db, monkeypatch): assert attrs == { "catalogueId": "BC-7", "catalogueVersion": "1.2.3", - "capabilityLevel": "L2", + # Imported without its parent, the card is a root card here: L1, whatever + # the catalogue called it (BC-7 keeps saying where it came from). + "capabilityLevel": "L1", "aliases": ["Claims"], "industry": "Insurance", "tags": ["core"], @@ -1618,6 +1638,82 @@ async def test_imported_capability_attributes(db, monkeypatch): ) assert result["relinked"] == [] assert result["warnings"] == [] + assert result["failed"] == [] + + +@pytest.mark.asyncio +async def test_import_writes_cards_through_the_shared_write_path(db, monkeypatch): + """What every other create gets, an import now gets too: a reference when + the type numbers cards, the hierarchy level, a data-quality score and a + History entry. Needs the type row, which the bare test database lacks.""" + _install_fake_pkg(monkeypatch) + from app.models.event import Event + from app.services import capability_catalogue_service as svc + + ct = await create_card_type( + db, + key="BusinessCapability", + label="Business Capability", + has_hierarchy=True, + fields_schema=[ + { + "section": "Catalogue", + "fields": [{"key": "industry", "label": "Industry", "type": "text", "weight": 1}], + } + ], + ) + ct.reference_config = {"mode": "auto", "prefix": "BC-", "start": 1, "padding": 4} + await db.flush() + user = await create_user(db, email="path@x.com") + + result = await svc.import_capabilities( + db, user=user, catalogue_ids=["BC-1", "BC-1.1", "BC-1.1.1"] + ) + assert [c["catalogue_id"] for c in result["created"]] == ["BC-1", "BC-1.1", "BC-1.1.1"] + assert result["failed"] == [] + ids = [uuid.UUID(c["card_id"]) for c in result["created"]] + cards = [(await db.execute(select(Card).where(Card.id == i))).scalar_one() for i in ids] + + assert [c.reference for c in cards] == ["BC-0001", "BC-0002", "BC-0003"] + assert [c.attributes["hierarchyLevel"] for c in cards] == [1, 2, 3] + assert [c.attributes["capabilityLevel"] for c in cards] == ["L1", "L2", "L3"] + # BC-1.1 carries an industry, the one weighted field; BC-1 carries none. + assert cards[1].data_quality > cards[0].data_quality + for c in cards: + events = (await db.execute(select(Event).where(Event.card_id == c.id))).scalars().all() + assert [(e.event_type, e.user_id) for e in events] == [("card.created", user.id)] + + +@pytest.mark.asyncio +async def test_a_refused_entry_and_the_entries_below_it_are_reported(db, monkeypatch): + """A name two catalogue entries share never matches a card by name (#1241), + so importing such an entry where a card of that name already sits at the + same level is refused like any duplicate sibling — reported, not created, + and not silently rooted. Its children are reported with it rather than + landing as root cards.""" + _install_fake_pkg(monkeypatch) + from app.services import capability_catalogue_service as svc + + caps = [ + {"id": "BC-8", "name": "Shared Name", "level": 1, "parent_id": None}, + {"id": "BC-9", "name": "Shared Name", "level": 1, "parent_id": None}, + {"id": "BC-9.1", "name": "Under Nine", "level": 2, "parent_id": "BC-9"}, + ] + monkeypatch.setattr(common, "load_bundled_capabilities_raw", lambda: list(caps)) + user = await create_user(db, email="refused@x.com") + mine = await create_card( + db, card_type="BusinessCapability", name="Shared Name", user_id=user.id + ) + + result = await svc.import_capabilities(db, user=user, catalogue_ids=["BC-9", "BC-9.1"]) + + assert result["created"] == [] + assert result["skipped"] == [] + assert [f["catalogue_id"] for f in result["failed"]] == ["BC-9", "BC-9.1"] + assert "Shared Name" in result["failed"][0]["reason"] + assert result["failed"][1]["reason"] == "parent not imported" + rows = (await db.execute(select(Card).where(Card.type == "BusinessCapability"))).scalars().all() + assert [(r.id, r.attributes.get("catalogueId")) for r in rows] == [(mine.id, None)] def test_bundled_payload_counts_from_the_package_constant(monkeypatch): diff --git a/backend/tests/services/test_card_reference.py b/backend/tests/services/test_card_reference.py index 7c347580d..2c24d67f7 100644 --- a/backend/tests/services/test_card_reference.py +++ b/backend/tests/services/test_card_reference.py @@ -148,3 +148,58 @@ async def test_backfill_noop_for_off(db): ct = await _typed(db, "Application", "off") await create_card(db, card_type="Application", name="X", user_id=user.id) assert await card_reference.backfill_references_for_type(db, ct) == 0 + + +# ── Batch allocation ──────────────────────────────────────────────────── + + +async def test_allocator_continues_after_the_highest_reference_in_use(db): + user = await create_user(db, email="alloc@test.com", role="member") + ct = await _typed(db, "Application", "auto", prefix="APP-", start=10000) + await _card_with_ref(db, user, "Application", "APP-10042") + first = await create_card(db, card_type="Application", name="A1", user_id=user.id) + second = await create_card(db, card_type="Application", name="A2", user_id=user.id) + + allocator = card_reference.ReferenceAllocator() + await allocator.assign(db, first, ct) + await allocator.assign(db, second, ct) + + assert (first.reference, second.reference) == ("APP-10043", "APP-10044") + + +async def test_allocator_scans_the_workspace_once_per_prefix(db, monkeypatch): + user = await create_user(db, email="scan@test.com", role="member") + app = await _typed(db, "Application", "auto", prefix="APP-", start=1, padding=3) + prc = await _typed(db, "BusinessProcess", "auto", prefix="PRC-", start=1, padding=3) + scans: list[str] = [] + real = card_reference.scan_highest_for_prefix + + async def counting(db_, prefix, start): + scans.append(prefix) + return await real(db_, prefix, start) + + monkeypatch.setattr(card_reference, "scan_highest_for_prefix", counting) + allocator = card_reference.ReferenceAllocator() + cards = [ + await create_card(db, card_type="Application", name=f"A{i}", user_id=user.id) + for i in range(3) + ] + for c in cards: + await allocator.assign(db, c, app) + proc = await create_card(db, card_type="BusinessProcess", name="P", user_id=user.id) + await allocator.assign(db, proc, prc) + + assert [c.reference for c in cards] == ["APP-001", "APP-002", "APP-003"] + assert proc.reference == "PRC-001" + assert scans == ["APP-", "PRC-"] + + +async def test_allocator_leaves_an_off_type_and_a_missing_type_without_a_reference(db): + user = await create_user(db, email="off@test.com", role="member") + off = await _typed(db, "Application", "off") + card = await create_card(db, card_type="Application", name="A", user_id=user.id) + allocator = card_reference.ReferenceAllocator() + await allocator.assign(db, card, off) + assert card.reference is None + await allocator.assign(db, card, None) + assert card.reference is None diff --git a/backend/tests/services/test_catalogue_common.py b/backend/tests/services/test_catalogue_common.py index 1448d3a56..ee1fc7614 100644 --- a/backend/tests/services/test_catalogue_common.py +++ b/backend/tests/services/test_catalogue_common.py @@ -15,8 +15,10 @@ from typing import Any import httpx +import pytest from sqlalchemy import select +from app.models.card import Card from app.models.event import Event from app.models.relation import Relation from app.services import catalogue_common as common @@ -262,6 +264,40 @@ def _pypi(version: str | None, wheel_url: str = "https://files.invalid/w.whl") - return {"info": info, "urls": [{"packagetype": "bdist_wheel", "url": wheel_url}]} +class TestWheelUrlFromPypiPayload: + def test_the_wheel_is_chosen_whatever_its_position(self): + sdist = {"packagetype": "sdist", "url": "https://files.invalid/w.tar.gz"} + wheel = {"packagetype": "bdist_wheel", "url": "https://files.invalid/w.whl"} + payload = {"info": {"version": "2.1.0"}, "urls": [sdist, wheel]} + assert common.wheel_url_from_pypi_payload(payload) == ( + "https://files.invalid/w.whl", + "2.1.0", + ) + + def test_a_release_with_only_an_sdist_is_refused_by_name(self): + payload = { + "info": {"version": "2.1.0"}, + "urls": [{"packagetype": "sdist", "url": "https://files.invalid/w.tar.gz"}], + } + with pytest.raises(ValueError) as exc: + common.wheel_url_from_pypi_payload(payload) + assert str(exc.value) == "PyPI lists no wheel for turbo-ea-capabilities 2.1.0" + + def test_a_wheel_entry_without_a_url_counts_as_no_wheel(self): + payload = {"info": {"version": "2.1.0"}, "urls": [{"packagetype": "bdist_wheel"}]} + with pytest.raises(ValueError, match="lists no wheel"): + common.wheel_url_from_pypi_payload(payload) + with pytest.raises(ValueError, match="lists no wheel"): + common.wheel_url_from_pypi_payload({"info": {"version": "2.1.0"}}) + + def test_a_payload_without_a_version_is_refused_first(self): + with pytest.raises(ValueError) as exc: + common.wheel_url_from_pypi_payload(_pypi(None)) + assert str(exc.value) == "PyPI response missing info.version" + with pytest.raises(ValueError, match="missing info.version"): + common.wheel_url_from_pypi_payload(_pypi("")) + + class TestCheckRemoteVersion: async def test_no_cache_compares_pypi_with_the_bundled_wheel(self, db, monkeypatch): seen = patch_httpx_client( @@ -677,6 +713,106 @@ async def test_nothing_moves_unless_the_parent_was_created_now(self, db): assert child.parent_id is None +class TestCreateCatalogueCard: + async def test_a_created_card_takes_the_write_path(self, db): + user = await create_user(db, email="cc1@x.com") + parent = await create_card( + db, card_type="BusinessCapability", name="Parent", user_id=user.id + ) + card, reason = await common.create_catalogue_card( + db, + user, + type_key="BusinessCapability", + name="Child", + subtype=None, + description="d", + parent_id=str(parent.id), + attributes={"catalogueId": "BC-1.1", "capabilityLevel": "L5"}, + allocator=common.ReferenceAllocator(), + ) + assert reason == "" + assert card is not None + # The string id the imports carry arrives as a UUID, and the level the + # catalogue claimed gives way to the card's real depth. + assert card.parent_id == parent.id + assert card.attributes["capabilityLevel"] == "L2" + assert (card.created_by, card.updated_by, card.approval_status) == ( + user.id, + user.id, + "DRAFT", + ) + events = (await db.execute(select(Event).where(Event.card_id == card.id))).scalars().all() + assert [(e.event_type, e.user_id) for e in events] == [("card.created", user.id)] + + async def test_a_plain_string_refusal_is_returned_as_is(self, db): + """Not every refusal is the sibling-name dict: the hierarchy depth guard + raises a plain string, which comes back untouched, and the flushed row + is gone with the savepoint.""" + user = await create_user(db, email="cc3@x.com") + parent = None + for level in range(1, 6): + parent = await create_card( + db, + card_type="BusinessCapability", + name=f"L{level}", + user_id=user.id, + parent_id=parent.id if parent is not None else None, + ) + assert parent is not None + + card, reason = await common.create_catalogue_card( + db, + user, + type_key="BusinessCapability", + name="Too deep", + subtype=None, + description=None, + parent_id=str(parent.id), + attributes={"catalogueId": "BC-deep"}, + allocator=common.ReferenceAllocator(), + ) + + assert card is None + assert reason.startswith( + "Cannot set parent: hierarchy would exceed maximum depth of 5 levels" + ) + rows = (await db.execute(select(Card).where(Card.name == "Too deep"))).scalars().all() + assert rows == [] + + async def test_a_refused_row_rolls_back_alone_and_names_the_reason(self, db): + user = await create_user(db, email="cc2@x.com") + taken = await create_card(db, card_type="BusinessCapability", name="Taken", user_id=user.id) + allocator = common.ReferenceAllocator() + card, reason = await common.create_catalogue_card( + db, + user, + type_key="BusinessCapability", + name="Taken", + subtype=None, + description=None, + parent_id=None, + attributes={}, + allocator=allocator, + ) + assert card is None + assert "Taken" in reason and "{" not in reason + # The transaction is still usable: the next row goes through. + after, reason_after = await common.create_catalogue_card( + db, + user, + type_key="BusinessCapability", + name="Free", + subtype=None, + description=None, + parent_id=None, + attributes={}, + allocator=allocator, + ) + assert reason_after == "" and after is not None + named = (await db.execute(select(Card.id).where(Card.name == "Taken"))).scalars().all() + assert named == [taken.id] + + class TestAddRelationOnce: async def test_adds_once_then_reports_the_existing_row(self, db): user = await create_user(db, email="a1@x.com") diff --git a/backend/tests/services/test_db_session_holding.py b/backend/tests/services/test_db_session_holding.py index b969f7b86..3e96f0c06 100644 --- a/backend/tests/services/test_db_session_holding.py +++ b/backend/tests/services/test_db_session_holding.py @@ -332,3 +332,113 @@ def test_surveys_bridge_delivers_after_the_write_session_closed(): write_at = body.find("await self._write(") assert deliver_at != -1 and write_at != -1 assert deliver_at > write_at, "delivery must follow the committed write, never precede it" + + +# --------------------------------------------------------------------------- +# The reference catalogues' PyPI probe and download +# --------------------------------------------------------------------------- + +_CATALOGUE_ROUTES = [ + "app/api/v1/capability_catalogue.py", + "app/api/v1/process_catalogue.py", + "app/api/v1/value_stream_catalogue.py", +] + + +@pytest.mark.parametrize("module", _CATALOGUE_ROUTES) +def test_update_status_releases_the_request_session_before_the_probe(module): + """``GET …/update-status`` probes PyPI with a 30 s timeout. + + The permission check has already used the request's session, so without a + commit before the call ``get_db`` keeps that connection checked out for the + whole probe. + """ + body = _function_source(module, "update_status") + call_at = body.find("await svc.check_remote_version(") + assert call_at != -1, "the endpoint must be the one asking for the remote version" + assert body.rfind("await db.commit()", 0, call_at) != -1, ( + f"{module}: update_status must `await db.commit()` before " + "svc.check_remote_version — the commit hands the connection back." + ) + + +@pytest.mark.parametrize("module", _CATALOGUE_ROUTES) +def test_update_fetch_releases_the_session_and_commits_the_cache_itself(module): + """``POST …/update-fetch`` downloads a multi-megabyte wheel from PyPI. + + Same rule before the download, and the write after it is the route's to + commit: the helper stages the cache rows in the session it was handed and + must not commit it (CLAUDE.md, *A helper must never commit or roll back a + session it was handed*). + """ + body = _function_source(module, "update_fetch") + call_at = body.find("await svc.fetch_remote_catalogue(") + assert call_at != -1, "the endpoint must be the one fetching the catalogue" + assert body.rfind("await db.commit()", 0, call_at) != -1, ( + f"{module}: update_fetch must `await db.commit()` before svc.fetch_remote_catalogue." + ) + assert body.find("await db.commit()", call_at) != -1, ( + f"{module}: update_fetch must `await db.commit()` after the fetch — " + "the helper no longer commits the cache rows it staged." + ) + + +def test_the_version_probe_asks_pypi_before_it_reads_the_cache(): + """``check_remote_version_for`` is called on a session whose connection the + route has just handed back; a cache read ahead of the probe would take it + out again for the whole round-trip.""" + body = _function_source("app/services/catalogue_common.py", "check_remote_version_for") + probe_at = body.find("client.get(PYPI_INDEX_URL") + read_at = body.find("await get_cached_remote(") + assert probe_at != -1 and read_at != -1 + assert probe_at < read_at, ( + "check_remote_version_for must probe PyPI before reading the cache — " + "the read re-acquires the connection the route just released." + ) + + +def test_the_catalogue_fetch_helper_never_commits(): + body = _function_source("app/services/catalogue_common.py", "fetch_and_cache_all") + assert "db.commit()" not in body, ( + "fetch_and_cache_all must leave the commit to the route: it was handed the session." + ) + + +@pytest.mark.parametrize( + ("module", "func", "call"), + [ + ( + "app/api/v1/capability_catalogue.py", + "import_capabilities", + "await svc.import_capabilities(", + ), + ("app/api/v1/process_catalogue.py", "import_processes", "await svc.import_processes("), + ( + "app/api/v1/value_stream_catalogue.py", + "import_value_streams", + "await svc.import_value_streams(", + ), + ], +) +def test_the_import_routes_commit_what_their_service_staged(module, func, call): + body = _function_source(module, func) + call_at = body.find(call) + assert call_at != -1, "the route must be the one running the import" + assert body.find("await db.commit()", call_at) != -1, ( + f"{module}: {func} must `await db.commit()` after the import — the service " + "stages its cards and never commits a session it was handed." + ) + + +@pytest.mark.parametrize( + ("module", "func"), + [ + ("app/services/capability_catalogue_service.py", "import_capabilities"), + ("app/services/process_catalogue_service.py", "import_processes"), + ("app/services/value_stream_catalogue_service.py", "import_value_streams"), + ], +) +def test_the_catalogue_imports_never_commit(module, func): + assert "db.commit()" not in _function_source(module, func), ( + f"{func} was handed its session by the route; the route commits." + ) diff --git a/backend/tests/services/test_process_catalogue_service.py b/backend/tests/services/test_process_catalogue_service.py index 95f81a72c..e309e0e1b 100644 --- a/backend/tests/services/test_process_catalogue_service.py +++ b/backend/tests/services/test_process_catalogue_service.py @@ -22,7 +22,7 @@ from app.models.card import Card from app.models.relation import Relation from app.services import catalogue_common as common -from tests.conftest import create_card, create_user +from tests.conftest import create_card, create_card_type, create_user # --------------------------------------------------------------------------- # Fake catalogue @@ -409,6 +409,27 @@ async def test_imported_process_attributes_and_subtype(db, monkeypatch): assert card.subtype == "process" +@pytest.mark.asyncio +async def test_imported_processes_record_their_creation_and_level(db, monkeypatch): + _install_fake_pkg(monkeypatch) + from app.models.event import Event + from app.services import process_catalogue_service as svc + + await create_card_type(db, key="BusinessProcess", label="Business Process", has_hierarchy=True) + user = await create_user(db, email="proc-path@x.com") + result = await svc.import_processes(db, user=user, catalogue_ids=["BP-100", "BP-100.10"]) + + assert [c["catalogue_id"] for c in result["created"]] == ["BP-100", "BP-100.10"] + assert result["failed"] == [] + for level, created in enumerate(result["created"], start=1): + card = ( + await db.execute(select(Card).where(Card.id == uuid.UUID(created["card_id"]))) + ).scalar_one() + assert card.attributes["hierarchyLevel"] == level + events = (await db.execute(select(Event).where(Event.card_id == card.id))).scalars().all() + assert [(e.event_type, e.user_id) for e in events] == [("card.created", user.id)] + + def test_bundled_payload_counts_from_the_package_constant(monkeypatch): # The wheel's PROCESS_COUNT is the count shown; a wheel without one counts the list. _install_fake_pkg(monkeypatch) @@ -418,3 +439,42 @@ def test_bundled_payload_counts_from_the_package_constant(monkeypatch): assert svc._bundled_payload()[1]["process_count"] == 77 monkeypatch.delattr(svc.catalogue_pkg, "PROCESS_COUNT") assert svc._bundled_payload()[1]["process_count"] == len(_FAKE_PROCESSES) + + +@pytest.mark.asyncio +async def test_a_refused_process_and_the_entries_below_it_are_reported(db, monkeypatch): + """Two catalogue entries sharing a name never match a card by name (#1241), + so importing the second where a card of that name already sits at root is + refused by the write path — reported, not created — and its children are + reported with it instead of landing as root cards.""" + _install_fake_pkg(monkeypatch) + from app.services import process_catalogue_service as svc + + base = _FAKE_PROCESSES[0] + procs = [ + dict(base, id="BP-200", name="Shared Process", parent_id=None, realizes_capability_ids=[]), + dict(base, id="BP-201", name="Shared Process", parent_id=None, realizes_capability_ids=[]), + dict( + base, + id="BP-201.10", + name="Under 201", + level=2, + parent_id="BP-201", + realizes_capability_ids=[], + ), + ] + monkeypatch.setattr(common, "load_bundled_processes_raw", lambda: list(procs)) + user = await create_user(db, email="refused-proc@x.com") + mine = await create_card( + db, card_type="BusinessProcess", name="Shared Process", user_id=user.id + ) + + result = await svc.import_processes(db, user=user, catalogue_ids=["BP-201", "BP-201.10"]) + + assert result["created"] == [] + assert result["skipped"] == [] + assert [f["catalogue_id"] for f in result["failed"]] == ["BP-201", "BP-201.10"] + assert "Shared Process" in result["failed"][0]["reason"] + assert result["failed"][1]["reason"] == "parent not imported" + rows = (await db.execute(select(Card).where(Card.type == "BusinessProcess"))).scalars().all() + assert [(r.id, r.attributes.get("catalogueId")) for r in rows] == [(mine.id, None)] diff --git a/backend/tests/services/test_updated_at_invariant.py b/backend/tests/services/test_updated_at_invariant.py index 19dc4be2d..79e505276 100644 --- a/backend/tests/services/test_updated_at_invariant.py +++ b/backend/tests/services/test_updated_at_invariant.py @@ -293,6 +293,35 @@ async def test_migration_apply_records_a_create(self, db, env): assert events[0].data["source"] == "migration" assert events[0].data["source_type"] == "leanix" + async def test_catalogue_import_records_a_create(self, db, env): + """The three reference-catalogue imports create their cards through + ``create_catalogue_card``; before it they built ``Card`` rows by hand + and nothing reached the History tab.""" + from app.services import catalogue_common as common + from app.services.card_reference import ReferenceAllocator + + card, reason = await common.create_catalogue_card( + db, + env["user"], + type_key="Application", + name="Imported entry", + subtype=None, + description=None, + parent_id=None, + attributes={"catalogueId": "BC-1"}, + allocator=ReferenceAllocator(), + ) + assert reason == "" + assert card is not None + + events = await _events_for(db, card.id) + assert [(e.event_type, e.user_id) for e in events] == [("card.created", env["user"].id)] + assert events[0].data == { + "id": str(card.id), + "type": "Application", + "name": "Imported entry", + } + async def test_migration_apply_records_an_update(self, db, env): from app.models.migration import StagedRecord from app.services.migration.apply import _apply_single_card diff --git a/backend/tests/services/test_value_stream_catalogue_service.py b/backend/tests/services/test_value_stream_catalogue_service.py index ffc047d8a..9ed6b6654 100644 --- a/backend/tests/services/test_value_stream_catalogue_service.py +++ b/backend/tests/services/test_value_stream_catalogue_service.py @@ -19,7 +19,7 @@ from app.models.event import Event from app.models.relation import Relation from app.services import catalogue_common as common -from tests.conftest import create_card, create_user +from tests.conftest import create_card, create_card_type, create_user # --------------------------------------------------------------------------- # Fake catalogue @@ -506,3 +506,64 @@ async def test_an_existing_stage_moves_under_its_new_stream_and_says_so(db, monk assert [(e.event_type, e.data["changes"]) for e in events] == [ ("card.updated", {"parent_id": {"old": None, "new": stream_id}}) ] + + +@pytest.mark.asyncio +async def test_imported_streams_and_stages_record_their_creation_and_level(db, monkeypatch): + _install_fake_pkg(monkeypatch) + from app.models.event import Event + from app.services import value_stream_catalogue_service as svc + + await create_card_type(db, key="BusinessContext", label="Business Context", has_hierarchy=True) + user = await create_user(db, email="vs-path@x.com") + # A stage alone pulls its stream in first, so two cards land: stream, stage. + result = await svc.import_value_streams(db, user=user, catalogue_ids=["VS-10.10"]) + + assert [c["catalogue_id"] for c in result["created"]] == ["VS-10", "VS-10.10"] + assert result["failed"] == [] + stream, stage = [ + (await db.execute(select(Card).where(Card.id == uuid.UUID(c["card_id"])))).scalar_one() + for c in result["created"] + ] + assert stage.parent_id == stream.id + assert (stream.attributes["hierarchyLevel"], stage.attributes["hierarchyLevel"]) == (1, 2) + for card in (stream, stage): + events = (await db.execute(select(Event).where(Event.card_id == card.id))).scalars().all() + assert [(e.event_type, e.user_id) for e in events] == [("card.created", user.id)] + + +@pytest.mark.asyncio +async def test_a_refused_stream_and_its_stages_are_reported(db, monkeypatch): + """Two streams sharing a name never match a card by name (#1241), so + importing the second where a Business Context card of that name already + sits at root is refused by the write path — reported, not created — and + its stages are reported with it instead of landing as root cards.""" + _install_fake_pkg(monkeypatch) + from app.services import value_stream_catalogue_service as svc + + base = _FAKE_VALUE_STREAMS[0] + stage = base["stages"][0] + streams = [ + dict(base, id="VS-80", name="Shared Stream", stages=[]), + dict( + base, + id="VS-81", + name="Shared Stream", + stages=[ + dict(stage, id="VS-81.10", stage_name="Under 81", capability_ids=[], process_ids=[]) + ], + ), + ] + monkeypatch.setattr(common, "load_bundled_value_streams_raw", lambda: list(streams)) + user = await create_user(db, email="refused-vs@x.com") + mine = await create_card(db, card_type="BusinessContext", name="Shared Stream", user_id=user.id) + + result = await svc.import_value_streams(db, user=user, catalogue_ids=["VS-81", "VS-81.10"]) + + assert result["created"] == [] + assert result["skipped"] == [] + assert [f["catalogue_id"] for f in result["failed"]] == ["VS-81", "VS-81.10"] + assert "Shared Stream" in result["failed"][0]["reason"] + assert result["failed"][1]["reason"] == "parent not imported" + rows = (await db.execute(select(Card).where(Card.type == "BusinessContext"))).scalars().all() + assert [(r.id, r.attributes.get("catalogueId")) for r in rows] == [(mine.id, None)] diff --git a/docs/guide/capability-catalogue.ar.md b/docs/guide/capability-catalogue.ar.md index 8d58ec423..0c53791a0 100644 --- a/docs/guide/capability-catalogue.ar.md +++ b/docs/guide/capability-catalogue.ar.md @@ -39,6 +39,7 @@ - **يحافظ على التسلسل الهرمي للكتالوج** تلقائيًا — عندما يكون كل من الأصل والطفل محدَّدًا (أو يكون الأصل موجودًا بالفعل محليًا)، يُربَط `parent_id` للبطاقة الفرعية الجديدة بالبطاقة الصحيحة. - **يتجاوز المطابقات الموجودة** بصمت. يعرض مربّع حوار النتائج عدد ما تم إنشاؤه وعدد ما تم تجاوزه. - يطبع على `attributes` كل بطاقة جديدة `catalogueId` و`catalogueVersion` و`catalogueImportedAt` و`capabilityLevel` لتتمكّن من تتبّع مصدرها. +- ينشئ كل بطاقة بالطريقة التي ينشئها بها نموذج البطاقة: فتحصل على إدخال في السجل، ومستواها في تسلسلك الهرمي (يتبع `capabilityLevel` موضع البطاقة — فالقدرة المستوردة بدون أصلها في الكتالوج تكون بطاقة جذرية L1)، ورقم مرجع عندما تستخدم Business Capability المراجع التلقائية، ودرجة جودة بيانات. أمّا المدخل الذي تحمل اسمه بطاقة Business Capability أخرى في المستوى نفسه فيتم **الإبلاغ عنه ولا يُستورد**، وكذلك المدخلات الواقعة تحته؛ ويعرضها مربع حوار النتيجة مع السبب. تكرار نفس الاستيراد آمن — فهو عملية غير قابلة للتكرار التأثير (idempotent). diff --git a/docs/guide/capability-catalogue.da.md b/docs/guide/capability-catalogue.da.md index 36cfe3c4e..b53d9fef4 100644 --- a/docs/guide/capability-catalogue.da.md +++ b/docs/guide/capability-catalogue.da.md @@ -39,6 +39,7 @@ Ved bekræftelse vil Turbo EA: - **Bevare kataloghierarkiet** automatisk — når både forælderen og barnet er valgt (eller forælderen allerede findes lokalt), forbindes det nye barnekorts `parent_id` til det rigtige kort. - **Springe eksisterende match over** uden at gøre opmærksom på det. Resultatdialogen viser, hvor mange der blev oprettet, og hvor mange der blev sprunget over. - Stemple hvert nyt korts `attributes` med `catalogueId`, `catalogueVersion`, `catalogueImportedAt` og `capabilityLevel`, så du kan spore, hvor det kom fra. +- Opretter hvert kort på samme måde som kortformularen: det får en post i historikken, sit niveau i dit eget hierarki (`capabilityLevel` følger kortets placering — en kompetence importeret uden sin katalogforælder er et L1-rodkort), et referencenummer, når Business Capability bruger automatiske referencer, og en datakvalitetsscore. En post, hvis navn allerede bruges af et andet Business Capability-kort på samme niveau, **rapporteres og importeres ikke**, og det samme gælder posterne under den; resultatdialogen lister dem med årsagen. Det er sikkert at køre den samme import igen — den er idempotent. diff --git a/docs/guide/capability-catalogue.de.md b/docs/guide/capability-catalogue.de.md index 435225a6e..458016853 100644 --- a/docs/guide/capability-catalogue.de.md +++ b/docs/guide/capability-catalogue.de.md @@ -39,6 +39,7 @@ Bei Bestätigung führt Turbo EA Folgendes aus: - **Behält die Kataloghierarchie** automatisch — wenn sowohl der Eltern- als auch der Kindknoten ausgewählt ist (oder der Elternknoten lokal bereits existiert), wird `parent_id` der neuen Kindkarte korrekt verdrahtet. - **Überspringt vorhandene Treffer** stillschweigend. Der Ergebnisdialog zeigt, wie viele Karten erstellt und wie viele übersprungen wurden. - Stempelt jede neue Karte in `attributes` mit `catalogueId`, `catalogueVersion`, `catalogueImportedAt` und `capabilityLevel`, sodass die Herkunft nachvollziehbar bleibt. +- Legt jede Karte so an, wie es das Kartenformular tut: Sie erhält einen Eintrag im Verlauf, ihre Ebene in Ihrer eigenen Hierarchie (`capabilityLevel` folgt der Position der Karte — eine ohne ihr Katalog-Elternelement importierte Capability ist eine L1-Wurzelkarte), eine Referenznummer, wenn Business Capability automatische Referenzen verwendet, und einen Datenqualitätswert. Ein Eintrag, dessen Namen bereits eine andere Business-Capability-Karte auf derselben Ebene trägt, wird **gemeldet und nicht importiert**, ebenso die Einträge darunter; der Ergebnisdialog listet sie mit dem Grund auf. Den gleichen Import erneut auszuführen ist sicher — er ist idempotent. diff --git a/docs/guide/capability-catalogue.es.md b/docs/guide/capability-catalogue.es.md index 2f98e2b32..8d1ebb4f1 100644 --- a/docs/guide/capability-catalogue.es.md +++ b/docs/guide/capability-catalogue.es.md @@ -39,6 +39,7 @@ Tras la confirmación, Turbo EA: - **Preserva automáticamente la jerarquía del catálogo** — cuando padre e hijo están ambos seleccionados (o el padre ya existe localmente), el `parent_id` de la nueva tarjeta hija se conecta a la tarjeta correcta. - **Omite silenciosamente las coincidencias existentes**. El diálogo de resultado indica cuántas se crearon y cuántas se omitieron. - Estampa los `attributes` de cada tarjeta nueva con `catalogueId`, `catalogueVersion`, `catalogueImportedAt` y `capabilityLevel` para que pueda rastrear su procedencia. +- Crea cada tarjeta igual que el formulario de tarjeta: recibe una entrada en el historial, su nivel en tu propia jerarquía (`capabilityLevel` sigue la posición de la tarjeta — una capacidad importada sin su padre de catálogo es una tarjeta raíz L1), un número de referencia cuando Business Capability usa referencias automáticas y una puntuación de calidad de datos. Una entrada cuyo nombre ya usa otra tarjeta Business Capability en el mismo nivel se **reporta y no se importa**, junto con las entradas situadas debajo; el diálogo de resultado las lista con el motivo. Volver a ejecutar la misma importación es seguro — es idempotente. diff --git a/docs/guide/capability-catalogue.fr.md b/docs/guide/capability-catalogue.fr.md index 1e2a5c23e..ec02a97c7 100644 --- a/docs/guide/capability-catalogue.fr.md +++ b/docs/guide/capability-catalogue.fr.md @@ -39,6 +39,7 @@ Sur confirmation, Turbo EA : - **Préserve automatiquement la hiérarchie du catalogue** — lorsque le parent et l'enfant sont tous deux sélectionnés (ou que le parent existe déjà localement), le `parent_id` de la nouvelle fiche enfant est câblé sur la bonne fiche. - **Ignore silencieusement les correspondances existantes**. La boîte de dialogue de résultat indique combien de fiches ont été créées et combien ont été ignorées. - Estampille les `attributes` de chaque nouvelle fiche avec `catalogueId`, `catalogueVersion`, `catalogueImportedAt` et `capabilityLevel` afin que vous puissiez retracer son origine. +- Crée chaque fiche comme le fait le formulaire de fiche : elle reçoit une entrée dans l'historique, son niveau dans votre propre hiérarchie (`capabilityLevel` suit la position de la fiche — une capacité importée sans son parent de catalogue est une fiche racine L1), un numéro de référence lorsque Business Capability utilise les références automatiques, et un score de qualité des données. Une entrée dont le nom est déjà porté par une autre fiche Business Capability au même niveau est **signalée et non importée**, de même que les entrées situées en dessous ; la boîte de dialogue de résultat les liste avec la raison. Relancer le même import est sûr — il est idempotent. diff --git a/docs/guide/capability-catalogue.it.md b/docs/guide/capability-catalogue.it.md index 85f1d0de5..c853f142f 100644 --- a/docs/guide/capability-catalogue.it.md +++ b/docs/guide/capability-catalogue.it.md @@ -39,6 +39,7 @@ Alla conferma, Turbo EA: - **Preserva automaticamente la gerarchia del catalogo** — quando sia il genitore sia il figlio sono selezionati (oppure il genitore esiste già localmente), il `parent_id` della nuova carta figlio è collegato alla carta corretta. - **Salta in silenzio le corrispondenze esistenti**. La finestra di risultato mostra quante carte sono state create e quante saltate. - Marca gli `attributes` di ogni nuova carta con `catalogueId`, `catalogueVersion`, `catalogueImportedAt` e `capabilityLevel`, in modo da poter risalire alla loro origine. +- Crea ogni carta come fa il modulo della carta: riceve una voce nella cronologia, il suo livello nella tua gerarchia (`capabilityLevel` segue la posizione della carta — una capacità importata senza il suo genitore di catalogo è una carta radice L1), un numero di riferimento quando Business Capability usa i riferimenti automatici e un punteggio di qualità dei dati. Una voce il cui nome è già usato da un'altra carta Business Capability allo stesso livello viene **segnalata e non importata**, insieme alle voci sottostanti; la finestra di risultato le elenca con il motivo. Eseguire di nuovo la stessa importazione è sicuro — è idempotente. diff --git a/docs/guide/capability-catalogue.md b/docs/guide/capability-catalogue.md index d54589bf3..6d15f9edd 100644 --- a/docs/guide/capability-catalogue.md +++ b/docs/guide/capability-catalogue.md @@ -39,6 +39,7 @@ On confirmation, Turbo EA: - **Preserves the catalogue hierarchy** automatically — when both the parent and the child are selected (or the parent already exists locally), the new child card's `parent_id` is wired to the right card. - **Skips existing matches** silently. The result dialog shows how many were created and how many were skipped. - Stamps each new card's `attributes` with `catalogueId`, `catalogueVersion`, `catalogueImportedAt`, and `capabilityLevel` so you can trace where it came from. +- Creates each card the way the card form does, so it gets a History entry, its level in your own hierarchy (`capabilityLevel` follows the card's position — a capability imported without its catalogue parent is an L1 root card), a reference number when Business Capability uses automatic references, and a data-quality score. An entry whose name another Business Capability card already uses at the same level is **reported and not imported**, together with the entries beneath it; the result dialog lists them with the reason. Re-running the same import is safe — it's idempotent. diff --git a/docs/guide/capability-catalogue.pt.md b/docs/guide/capability-catalogue.pt.md index 589afa445..62395119b 100644 --- a/docs/guide/capability-catalogue.pt.md +++ b/docs/guide/capability-catalogue.pt.md @@ -39,6 +39,7 @@ Após a confirmação, o Turbo EA: - **Preserva automaticamente a hierarquia do catálogo** — quando o pai e o filho estão ambos selecionados (ou o pai já existe localmente), o `parent_id` da nova carta filha é ligado à carta certa. - **Ignora silenciosamente as correspondências existentes**. O diálogo de resultado indica quantas foram criadas e quantas foram ignoradas. - Carimba os `attributes` de cada nova carta com `catalogueId`, `catalogueVersion`, `catalogueImportedAt` e `capabilityLevel` para que possa rastrear a sua origem. +- Cria cada carta como o formulário de carta o faz: recebe uma entrada no histórico, o seu nível na sua própria hierarquia (`capabilityLevel` segue a posição da carta — uma capacidade importada sem o seu pai de catálogo é uma carta raiz L1), um número de referência quando Business Capability usa referências automáticas e uma pontuação de qualidade de dados. Uma entrada cujo nome já é usado por outra carta Business Capability no mesmo nível é **reportada e não importada**, bem como as entradas abaixo dela; o diálogo de resultado lista-as com o motivo. Voltar a executar a mesma importação é seguro — é idempotente. diff --git a/docs/guide/capability-catalogue.ru.md b/docs/guide/capability-catalogue.ru.md index 4867fbbc9..a80f35f34 100644 --- a/docs/guide/capability-catalogue.ru.md +++ b/docs/guide/capability-catalogue.ru.md @@ -39,6 +39,7 @@ Turbo EA поставляется с **[Business Capability Reference Catalogue] - **Автоматически сохраняет иерархию каталога** — когда родитель и потомок выбраны вместе (или родитель уже существует локально), `parent_id` новой карты-потомка корректно подключается к нужной карте. - **Молча пропускает существующие совпадения**. Диалог результата показывает, сколько было создано и сколько пропущено. - Проставляет в `attributes` каждой новой карты `catalogueId`, `catalogueVersion`, `catalogueImportedAt` и `capabilityLevel`, чтобы можно было отследить происхождение. +- Создаёт каждую карточку так же, как форма карточки: она получает запись в истории, свой уровень в вашей иерархии (`capabilityLevel` следует позиции карточки — способность, импортированная без родителя из каталога, становится корневой карточкой L1), номер ссылки, если для Business Capability включена автоматическая нумерация, и оценку качества данных. Запись, имя которой уже носит другая карточка Business Capability на том же уровне, **сообщается и не импортируется**, как и записи под ней; диалог результата перечисляет их с причиной. Повторный запуск того же импорта безопасен — он идемпотентен. diff --git a/docs/guide/capability-catalogue.zh.md b/docs/guide/capability-catalogue.zh.md index f729789ba..0850b5671 100644 --- a/docs/guide/capability-catalogue.zh.md +++ b/docs/guide/capability-catalogue.zh.md @@ -39,6 +39,7 @@ Turbo EA 自带 **[Business Capability Reference Catalogue](https://catalog.turb - **自动保留目录层级** —— 当父节点与子节点同时被选中(或父节点本地已存在)时,新子卡片的 `parent_id` 会被正确接到对应卡片上。 - **静默跳过已存在的匹配项**。结果对话框会显示有多少张被创建、多少张被跳过。 - 在每张新卡片的 `attributes` 上盖上 `catalogueId`、`catalogueVersion`、`catalogueImportedAt` 和 `capabilityLevel`,便于追溯来源。 +- 以卡片表单相同的方式创建每张卡片:它会获得一条历史记录、在你自己层级中的级别(`capabilityLevel` 跟随卡片的位置——没有目录父项而导入的能力是 L1 根卡片)、在 Business Capability 使用自动引用编号时的引用编号,以及数据质量评分。名称已被同一级别另一张 Business Capability 卡片使用的条目会**被报告而不导入**,其下的条目亦然;结果对话框会连同原因一并列出。 重复执行同一次导入是安全的——它是幂等的。 diff --git a/docs/guide/process-catalogue.ar.md b/docs/guide/process-catalogue.ar.md index d9f4c8af9..412f532d1 100644 --- a/docs/guide/process-catalogue.ar.md +++ b/docs/guide/process-catalogue.ar.md @@ -32,7 +32,7 @@ - **ينشئ تلقائيًا علاقات `relProcessToBC` (supports)** إلى كل بطاقة `BusinessCapability` موجودة مدرجة في `realizes_capability_ids` للعملية. يبلّغ مربّع حوار النتائج عن عدد العلاقات التلقائية التي تمّت؛ وتُتجاوَز بصمت الأهداف غير الموجودة بعد في مخزونك. تكرار الاستيراد بعد استيراد القدرات المفقودة لاحقًا آمن — تُخزَّن معرّفات المصدر تلك على البطاقة لتتمكّن من إعادة الربط يدويًا عند الحاجة. - يطبع على `attributes` كل بطاقة جديدة `catalogueId` و`catalogueVersion` و`catalogueImportedAt` و`processLevel` (`L1`..`L4`) ومن الكتالوج المصدر `frameworkRefs` و`industry` و`references` و`inScope` و`outOfScope` و`realizesCapabilityIds`. -يُبلَّغ عن أعداد ما تم تجاوزه وإنشاؤه وإعادة ربطه بالطريقة نفسها كما في كتالوج القدرات. عمليات الاستيراد غير قابلة للتكرار التأثير (idempotent) — لن تؤدّي إعادة التشغيل إلى تكرار البطاقات. +يُبلَّغ عن أعداد ما تم تجاوزه وإنشاؤه وإعادة ربطه بالطريقة نفسها كما في كتالوج القدرات. عمليات الاستيراد غير قابلة للتكرار التأثير (idempotent) — لن تؤدّي إعادة التشغيل إلى تكرار البطاقات. تُنشأ كل بطاقة بالطريقة التي ينشئها بها نموذج البطاقة — مع إدخال في السجل، ومستواها في تسلسلك الهرمي، ورقم مرجع عندما تستخدم Business Process المراجع التلقائية، ودرجة جودة بيانات — والمدخل الذي تحمل اسمه بطاقة Business Process أخرى في المستوى نفسه يتم الإبلاغ عنه ولا يُستورد، وكذلك المدخلات الواقعة تحته؛ ويعرضها مربع حوار النتيجة مع السبب. ## عرض التفاصيل diff --git a/docs/guide/process-catalogue.da.md b/docs/guide/process-catalogue.da.md index 4907b66cc..c729d7c59 100644 --- a/docs/guide/process-catalogue.da.md +++ b/docs/guide/process-catalogue.da.md @@ -32,7 +32,7 @@ Ved bekræftelse vil Turbo EA: - **Auto-oprette `relProcessToBC` (understøtter) relationer** til hvert eksisterende `BusinessCapability`-kort, der er listet i processens `realizes_capability_ids`. Resultatdialogen rapporterer, hvor mange auto-relationer der landede; mål, der endnu ikke findes i dit lager, springes stille over. Genkørsel af importen efter senere at have importeret de manglende kompetencer er sikker — disse kilde-ID'er gemmes på kortet, så du kan re-linke manuelt om nødvendigt. - Stemple hvert nyt korts `attributes` med `catalogueId`, `catalogueVersion`, `catalogueImportedAt`, `processLevel` (`L1`..`L4`), og kilden `frameworkRefs`, `industry`, `references`, `inScope`, `outOfScope`, `realizesCapabilityIds` fra kataloget. -Tællinger af sprunget over, oprettet og re-linket rapporteres på samme måde som for kompetencekataloget. Importer er idempotente — genkørsel duplikerer ikke kort. +Tællinger af sprunget over, oprettet og re-linket rapporteres på samme måde som for kompetencekataloget. Importer er idempotente — genkørsel duplikerer ikke kort. Hvert kort oprettes på samme måde som kortformularen gør det — med en post i historikken, sit niveau i dit hierarki, et referencenummer, når Business Process bruger automatiske referencer, og en datakvalitetsscore — og en post, hvis navn allerede bruges af et andet Business Process-kort på samme niveau, rapporteres og importeres ikke, og det samme gælder posterne under den; resultatdialogen lister dem med årsagen. ## Detaljevisning diff --git a/docs/guide/process-catalogue.de.md b/docs/guide/process-catalogue.de.md index 54d518805..63bc11a10 100644 --- a/docs/guide/process-catalogue.de.md +++ b/docs/guide/process-catalogue.de.md @@ -32,7 +32,7 @@ Bei der Bestätigung legt Turbo EA: - **Automatisch `relProcessToBC`-Beziehungen (unterstützt)** zu jeder existierenden `BusinessCapability`-Karte aus der Liste `realizes_capability_ids` des Prozesses an. Der Ergebnisdialog meldet, wie viele Beziehungen erstellt wurden; Ziele, die noch nicht im Inventar sind, werden stillschweigend übersprungen. Ein erneuter Import nach dem Anlegen der fehlenden Capabilities ist sicher — die Quell-IDs sind auf der Karte gespeichert, sodass Sie bei Bedarf manuell nachverknüpfen können. - jede neue Karte mit `catalogueId`, `catalogueVersion`, `catalogueImportedAt`, `processLevel` (`L1`..`L4`) sowie den `frameworkRefs`, `industry`, `references`, `inScope`, `outOfScope`, `realizesCapabilityIds` aus dem Katalog stempelt. -Übersprungene, erstellte und neu verknüpfte Anzahlen werden gleich wie im Capability-Katalog gemeldet. Imports sind idempotent — ein erneutes Ausführen erzeugt keine Duplikate. +Übersprungene, erstellte und neu verknüpfte Anzahlen werden gleich wie im Capability-Katalog gemeldet. Imports sind idempotent — ein erneutes Ausführen erzeugt keine Duplikate. Jede Karte wird so angelegt, wie es das Kartenformular tut — mit einem Eintrag im Verlauf, ihrer Ebene in Ihrer Hierarchie, einer Referenznummer, wenn Business Process automatische Referenzen verwendet, und einem Datenqualitätswert —, und ein Eintrag, dessen Namen bereits eine andere Business Process-Karte auf derselben Ebene trägt, wird gemeldet und nicht importiert, ebenso die Einträge darunter; der Ergebnisdialog listet sie mit dem Grund auf. ## Detailansicht diff --git a/docs/guide/process-catalogue.es.md b/docs/guide/process-catalogue.es.md index 1d63139b4..489f22443 100644 --- a/docs/guide/process-catalogue.es.md +++ b/docs/guide/process-catalogue.es.md @@ -32,7 +32,7 @@ Al confirmar, Turbo EA: - **crea automáticamente relaciones `relProcessToBC` (soporta)** hacia cada tarjeta `BusinessCapability` existente listada en `realizes_capability_ids` del proceso. El diálogo de resultado indica cuántas auto-relaciones se generaron; los destinos que aún no existen en el inventario se omiten en silencio. Volver a ejecutar el import tras añadir las capacidades faltantes es seguro — los IDs de origen quedan guardados en la tarjeta, lo que permite re-vincular manualmente más tarde si hace falta; - sella cada tarjeta nueva con `catalogueId`, `catalogueVersion`, `catalogueImportedAt`, `processLevel` (`L1`..`L4`) y los `frameworkRefs`, `industry`, `references`, `inScope`, `outOfScope`, `realizesCapabilityIds` del catálogo. -Los recuentos de saltadas, creadas y re-vinculadas se reportan igual que en el catálogo de capacidades. Los imports son idempotentes — repetirlos no crea duplicados. +Los recuentos de saltadas, creadas y re-vinculadas se reportan igual que en el catálogo de capacidades. Los imports son idempotentes — repetirlos no crea duplicados. Cada tarjeta se crea igual que con el formulario de tarjeta — con una entrada en el historial, su nivel en tu jerarquía, un número de referencia cuando Business Process usa referencias automáticas y una puntuación de calidad de datos — y una entrada cuyo nombre ya usa otra tarjeta Business Process en el mismo nivel se reporta y no se importa, junto con las entradas situadas debajo; el diálogo de resultado las lista con el motivo. ## Vista detalle diff --git a/docs/guide/process-catalogue.fr.md b/docs/guide/process-catalogue.fr.md index 32da67fd1..921835f9d 100644 --- a/docs/guide/process-catalogue.fr.md +++ b/docs/guide/process-catalogue.fr.md @@ -32,7 +32,7 @@ Dès qu'un processus est sélectionné, un bouton fixé en bas de page **Créer - **crée automatiquement des relations `relProcessToBC` (« supporte »)** vers chaque fiche `BusinessCapability` existante mentionnée dans `realizes_capability_ids` du processus. La boîte de dialogue de résultat indique combien d'auto-relations ont été créées ; les cibles encore absentes de l'inventaire sont ignorées en silence. Relancer l'import après avoir ajouté les capacités manquantes est sans danger — ces identifiants sources sont conservés sur la fiche pour un re-link manuel ultérieur ; - estampille chaque nouvelle fiche avec `catalogueId`, `catalogueVersion`, `catalogueImportedAt`, `processLevel` (`L1`..`L4`), et les `frameworkRefs`, `industry`, `references`, `inScope`, `outOfScope`, `realizesCapabilityIds` issus du catalogue. -Les compteurs « ignoré », « créé » et « ré-lié » sont rapportés comme pour le catalogue de capacités. Les imports sont idempotents — relancer ne crée pas de doublons. +Les compteurs « ignoré », « créé » et « ré-lié » sont rapportés comme pour le catalogue de capacités. Les imports sont idempotents — relancer ne crée pas de doublons. Chaque fiche est créée comme le fait le formulaire de fiche — avec une entrée dans l'historique, son niveau dans votre hiérarchie, un numéro de référence lorsque Business Process utilise les références automatiques et un score de qualité des données — et une entrée dont le nom est déjà porté par une autre fiche Business Process au même niveau est signalée et non importée, de même que les entrées situées en dessous ; la boîte de dialogue de résultat les liste avec la raison. ## Vue détail diff --git a/docs/guide/process-catalogue.it.md b/docs/guide/process-catalogue.it.md index cc4554101..ee2dbb315 100644 --- a/docs/guide/process-catalogue.it.md +++ b/docs/guide/process-catalogue.it.md @@ -32,7 +32,7 @@ Alla conferma, Turbo EA: - **crea automaticamente relazioni `relProcessToBC` (supporta)** verso ogni carta `BusinessCapability` esistente elencata in `realizes_capability_ids` del processo. Il dialogo di esito indica quante auto-relazioni sono state generate; i target ancora assenti dall'inventario vengono saltati in silenzio. Rieseguire l'import dopo aver aggiunto le capacità mancanti è sicuro — gli id di origine restano sulla carta, così potete ricollegarli manualmente in seguito; - timbra ogni carta nuova con `catalogueId`, `catalogueVersion`, `catalogueImportedAt`, `processLevel` (`L1`..`L4`) e i `frameworkRefs`, `industry`, `references`, `inScope`, `outOfScope`, `realizesCapabilityIds` provenienti dal catalogo. -I conteggi di saltati, creati e ri-collegati sono riportati come per il catalogo delle capacità. Gli import sono idempotenti — rieseguirli non genera duplicati. +I conteggi di saltati, creati e ri-collegati sono riportati come per il catalogo delle capacità. Gli import sono idempotenti — rieseguirli non genera duplicati. Ogni carta viene creata come fa il modulo della carta — con una voce nella cronologia, il suo livello nella tua gerarchia, un numero di riferimento quando Business Process usa i riferimenti automatici e un punteggio di qualità dei dati — e una voce il cui nome è già usato da un'altra carta Business Process allo stesso livello viene segnalata e non importata, insieme alle voci sottostanti; la finestra di risultato le elenca con il motivo. ## Vista dettaglio diff --git a/docs/guide/process-catalogue.md b/docs/guide/process-catalogue.md index 31af765dc..c4be923d3 100644 --- a/docs/guide/process-catalogue.md +++ b/docs/guide/process-catalogue.md @@ -32,7 +32,7 @@ On confirmation, Turbo EA: - **Auto-creates `relProcessToBC` (supports) relations** to every existing `BusinessCapability` card listed in the process's `realizes_capability_ids`. The result dialog reports how many auto-relations landed; targets that don't yet exist in your inventory are skipped silently. Re-running the import after later importing the missing capabilities is safe — those source IDs are stored on the card so you can re-link manually if needed. - Stamps each new card's `attributes` with `catalogueId`, `catalogueVersion`, `catalogueImportedAt`, `processLevel` (`L1`..`L4`), and the source `frameworkRefs`, `industry`, `references`, `inScope`, `outOfScope`, `realizesCapabilityIds` from the catalogue. -Skipped, created, and re-linked counts are reported the same way as for the capability catalogue. Imports are idempotent — re-running won't duplicate cards. +Skipped, created, and re-linked counts are reported the same way as for the capability catalogue. Imports are idempotent — re-running won't duplicate cards. Each card is created the way the card form creates it — with a History entry, its level in your hierarchy, a reference number when Business Process uses automatic references and a data-quality score — and an entry whose name another Business Process card already uses at the same level is reported and not imported, together with the entries beneath it; the result dialog lists them with the reason. ## Detail view diff --git a/docs/guide/process-catalogue.pt.md b/docs/guide/process-catalogue.pt.md index 421bb02dc..3ca31a942 100644 --- a/docs/guide/process-catalogue.pt.md +++ b/docs/guide/process-catalogue.pt.md @@ -32,7 +32,7 @@ Ao confirmar, o Turbo EA: - **cria automaticamente relações `relProcessToBC` (suporta)** para cada carta `BusinessCapability` existente listada em `realizes_capability_ids` do processo. O diálogo de resultado indica quantas auto-relações foram criadas; os destinos que ainda não existem no inventário são ignorados em silêncio. Voltar a executar o import depois de adicionar as capacidades em falta é seguro — os ids de origem ficam guardados no cartão, permitindo religar manualmente mais tarde se necessário; - carimba cada novo cartão com `catalogueId`, `catalogueVersion`, `catalogueImportedAt`, `processLevel` (`L1`..`L4`) e os `frameworkRefs`, `industry`, `references`, `inScope`, `outOfScope`, `realizesCapabilityIds` do catálogo. -Os totais de saltados, criados e religados são reportados tal como para o catálogo de capacidades. Os imports são idempotentes — repeti-los não gera duplicados. +Os totais de saltados, criados e religados são reportados tal como para o catálogo de capacidades. Os imports são idempotentes — repeti-los não gera duplicados. Cada carta é criada como o formulário de carta o faz — com uma entrada no histórico, o seu nível na sua hierarquia, um número de referência quando Business Process usa referências automáticas e uma pontuação de qualidade de dados — e uma entrada cujo nome já é usado por outra carta Business Process no mesmo nível é reportada e não importada, bem como as entradas abaixo dela; o diálogo de resultado lista-as com o motivo. ## Vista de detalhe diff --git a/docs/guide/process-catalogue.ru.md b/docs/guide/process-catalogue.ru.md index 886e18ff6..df24881a1 100644 --- a/docs/guide/process-catalogue.ru.md +++ b/docs/guide/process-catalogue.ru.md @@ -32,7 +32,7 @@ Turbo EA поставляется со **Справочным каталогом - **автоматически создаёт связи `relProcessToBC` («поддерживает»)** к каждой существующей карточке `BusinessCapability`, упомянутой в `realizes_capability_ids` процесса. Диалог результата сообщает, сколько автосвязей получилось; цели, которых ещё нет в инвентаре, тихо пропускаются. Повторный импорт после добавления недостающих способностей безопасен — исходные ID хранятся на карточке, поэтому при необходимости связи можно восстановить вручную; - ставит на каждой новой карточке метки `catalogueId`, `catalogueVersion`, `catalogueImportedAt`, `processLevel` (`L1`..`L4`) и значения `frameworkRefs`, `industry`, `references`, `inScope`, `outOfScope`, `realizesCapabilityIds` из каталога. -Счётчики пропущенных, созданных и переподвязанных карточек сообщаются так же, как в каталоге способностей. Импорты идемпотентны — повторный запуск не создаёт дубликатов. +Счётчики пропущенных, созданных и переподвязанных карточек сообщаются так же, как в каталоге способностей. Импорты идемпотентны — повторный запуск не создаёт дубликатов. Каждая карточка создаётся так же, как через форму карточки — с записью в истории, своим уровнем в вашей иерархии, номером ссылки, если для Business Process включена автоматическая нумерация, и оценкой качества данных, — а запись, имя которой уже носит другая карточка Business Process на том же уровне, сообщается и не импортируется, как и записи под ней; диалог результата перечисляет их с причиной. ## Подробный просмотр diff --git a/docs/guide/process-catalogue.zh.md b/docs/guide/process-catalogue.zh.md index d673d09cf..320ddaf83 100644 --- a/docs/guide/process-catalogue.zh.md +++ b/docs/guide/process-catalogue.zh.md @@ -32,7 +32,7 @@ Turbo EA 内置「**业务流程参考目录**」——一棵以 APQC-PCF 为基 - **自动创建 `relProcessToBC`(支持)关系**,指向流程 `realizes_capability_ids` 中列出的每一张已存在的 `BusinessCapability` 卡片。结果对话框会汇报落地的自动关系数量;尚未存在于库存中的目标会被静默跳过。导入缺失的能力之后再次运行该导入是安全的——源 ID 已记录在卡片上,必要时可手动重新关联; - 在每张新卡片上加盖 `catalogueId`、`catalogueVersion`、`catalogueImportedAt`、`processLevel`(`L1`..`L4`),以及来自目录的 `frameworkRefs`、`industry`、`references`、`inScope`、`outOfScope`、`realizesCapabilityIds`。 -跳过、创建和重新关联的计数与能力目录采用同样的方式汇报。导入是幂等的——重复运行不会生成重复卡片。 +跳过、创建和重新关联的计数与能力目录采用同样的方式汇报。导入是幂等的——重复运行不会生成重复卡片。每张卡片都以卡片表单相同的方式创建——带有历史记录、在你层级中的级别、在 Business Process 使用自动引用编号时的引用编号以及数据质量评分——名称已被同一级别另一张 Business Process 卡片使用的条目会被报告而不导入,其下的条目亦然;结果对话框会连同原因一并列出。 ## 详情视图 diff --git a/docs/guide/value-stream-catalogue.ar.md b/docs/guide/value-stream-catalogue.ar.md index df1c7f6f8..8fe70bce2 100644 --- a/docs/guide/value-stream-catalogue.ar.md +++ b/docs/guide/value-stream-catalogue.ar.md @@ -35,7 +35,7 @@ - يتجاوز أي إحالات مرجعية تكون بطاقتها الهدف غير موجودة بعد؛ وتُخزَّن معرّفات المصدر على سمات المرحلة (`capabilityIds` و`processIds`) لتتمكّن من ربطها لاحقًا باستيراد العناصر المفقودة. - يطبع على بطاقات المراحل `stageOrder` و`stageName` و`industryVariant` و`notes` وقائمتَي `capabilityIds` / `processIds` الأصليتين. -يُبلَّغ عن أعداد ما تم تجاوزه وإنشاؤه وإعادة ربطه بالطريقة نفسها كما في كتالوج القدرات. عمليات الاستيراد غير قابلة للتكرار التأثير (idempotent). +يُبلَّغ عن أعداد ما تم تجاوزه وإنشاؤه وإعادة ربطه بالطريقة نفسها كما في كتالوج القدرات. عمليات الاستيراد غير قابلة للتكرار التأثير (idempotent). تُنشأ كل بطاقة بالطريقة التي ينشئها بها نموذج البطاقة — مع إدخال في السجل، ومستواها في تسلسلك الهرمي، ورقم مرجع عندما تستخدم Business Context المراجع التلقائية، ودرجة جودة بيانات — والمدخل الذي تحمل اسمه بطاقة Business Context أخرى في المستوى نفسه يتم الإبلاغ عنه ولا يُستورد، وكذلك المدخلات الواقعة تحته؛ ويعرضها مربع حوار النتيجة مع السبب. ويخضع الاستيراد لصلاحية «إنشاء» على بطاقات Business Context، أي النوع الذي ينشئه. ## عرض التفاصيل diff --git a/docs/guide/value-stream-catalogue.da.md b/docs/guide/value-stream-catalogue.da.md index c1feff49b..c3dacba0c 100644 --- a/docs/guide/value-stream-catalogue.da.md +++ b/docs/guide/value-stream-catalogue.da.md @@ -35,7 +35,7 @@ Ved bekræftelse vil Turbo EA: - Springe alle krydsreferencer over, hvis målkortet endnu ikke findes; kilde-id'erne gemmes i trinnets egenskaber (`capabilityIds`, `processIds`), så du kan forbinde dem senere ved at importere de manglende artefakter. - Stemple trinkort med `stageOrder`, `stageName`, `industryVariant`, `notes` og de oprindelige `capabilityIds` / `processIds`-lister. -Antal sprungne, oprettede og gen-linkede rapporteres på samme måde som for kompetencekataloget. Importer er idempotente. +Antal sprungne, oprettede og gen-linkede rapporteres på samme måde som for kompetencekataloget. Importer er idempotente. Hvert kort oprettes på samme måde som kortformularen gør det — med en post i historikken, sit niveau i dit hierarki, et referencenummer, når Business Context bruger automatiske referencer, og en datakvalitetsscore — og en post, hvis navn allerede bruges af et andet Business Context-kort på samme niveau, rapporteres og importeres ikke, og det samme gælder posterne under den; resultatdialogen lister dem med årsagen. Importen kræver tilladelsen Opret på Business Context-kort, den type den opretter. ## Detaljevisning diff --git a/docs/guide/value-stream-catalogue.de.md b/docs/guide/value-stream-catalogue.de.md index 9452548ae..617f28f22 100644 --- a/docs/guide/value-stream-catalogue.de.md +++ b/docs/guide/value-stream-catalogue.de.md @@ -35,7 +35,7 @@ Bei der Bestätigung legt Turbo EA: - Querverweise, deren Zielkarte noch nicht existiert, werden übersprungen; die Quell-IDs bleiben in den Stage-Attributen (`capabilityIds`, `processIds`) erhalten, sodass Sie sie später nach dem Import der fehlenden Artefakte verknüpfen können. - Stage-Karten werden mit `stageOrder`, `stageName`, `industryVariant`, `notes` sowie den ursprünglichen `capabilityIds`- / `processIds`-Listen gestempelt. -Übersprungene, erstellte und neu verknüpfte Anzahlen werden gleich wie im Capability-Katalog gemeldet. Imports sind idempotent. +Übersprungene, erstellte und neu verknüpfte Anzahlen werden gleich wie im Capability-Katalog gemeldet. Imports sind idempotent. Jede Karte wird so angelegt, wie es das Kartenformular tut — mit einem Eintrag im Verlauf, ihrer Ebene in Ihrer Hierarchie, einer Referenznummer, wenn Business Context automatische Referenzen verwendet, und einem Datenqualitätswert —, und ein Eintrag, dessen Namen bereits eine andere Business Context-Karte auf derselben Ebene trägt, wird gemeldet und nicht importiert, ebenso die Einträge darunter; der Ergebnisdialog listet sie mit dem Grund auf. Der Import ist an die Berechtigung „Erstellen“ für Business-Context-Karten gebunden, den Typ, den er anlegt. ## Detailansicht diff --git a/docs/guide/value-stream-catalogue.es.md b/docs/guide/value-stream-catalogue.es.md index 7762db432..7cbf6a988 100644 --- a/docs/guide/value-stream-catalogue.es.md +++ b/docs/guide/value-stream-catalogue.es.md @@ -35,7 +35,7 @@ Al confirmar, Turbo EA: - omite las referencias cruzadas cuya tarjeta destino aún no existe; los identificadores de origen quedan guardados en los atributos de la etapa (`capabilityIds`, `processIds`) para que pueda enlazarlos después al importar los artefactos faltantes; - sella las tarjetas de etapa con `stageOrder`, `stageName`, `industryVariant`, `notes` y las listas originales `capabilityIds` / `processIds`. -Los recuentos de saltadas, creadas y re-vinculadas se reportan igual que en el catálogo de capacidades. Los imports son idempotentes. +Los recuentos de saltadas, creadas y re-vinculadas se reportan igual que en el catálogo de capacidades. Los imports son idempotentes. Cada tarjeta se crea igual que con el formulario de tarjeta — con una entrada en el historial, su nivel en tu jerarquía, un número de referencia cuando Business Context usa referencias automáticas y una puntuación de calidad de datos — y una entrada cuyo nombre ya usa otra tarjeta Business Context en el mismo nivel se reporta y no se importa, junto con las entradas situadas debajo; el diálogo de resultado las lista con el motivo. El import está sujeto al permiso Crear sobre tarjetas Business Context, el tipo que crea. ## Vista detalle diff --git a/docs/guide/value-stream-catalogue.fr.md b/docs/guide/value-stream-catalogue.fr.md index ccd6023b3..d5dae51da 100644 --- a/docs/guide/value-stream-catalogue.fr.md +++ b/docs/guide/value-stream-catalogue.fr.md @@ -35,7 +35,7 @@ Dès qu'une chaîne ou une étape est sélectionnée, un bouton fixé en bas de - ignore les renvois croisés dont la fiche cible n'existe pas encore ; les identifiants source restent stockés dans les attributs de l'étape (`capabilityIds`, `processIds`) afin que vous puissiez les câbler plus tard, en important les artefacts manquants ; - estampille les fiches d'étape avec `stageOrder`, `stageName`, `industryVariant`, `notes`, ainsi que les listes originales `capabilityIds` / `processIds`. -Les compteurs « ignoré », « créé » et « ré-lié » sont rapportés comme pour le catalogue de capacités. Les imports sont idempotents. +Les compteurs « ignoré », « créé » et « ré-lié » sont rapportés comme pour le catalogue de capacités. Les imports sont idempotents. Chaque fiche est créée comme le fait le formulaire de fiche — avec une entrée dans l'historique, son niveau dans votre hiérarchie, un numéro de référence lorsque Business Context utilise les références automatiques et un score de qualité des données — et une entrée dont le nom est déjà porté par une autre fiche Business Context au même niveau est signalée et non importée, de même que les entrées situées en dessous ; la boîte de dialogue de résultat les liste avec la raison. L'import est soumis à la permission Créer sur les fiches Business Context, le type qu'il crée. ## Vue détail diff --git a/docs/guide/value-stream-catalogue.it.md b/docs/guide/value-stream-catalogue.it.md index 24823fc3a..22b12dafe 100644 --- a/docs/guide/value-stream-catalogue.it.md +++ b/docs/guide/value-stream-catalogue.it.md @@ -35,7 +35,7 @@ Alla conferma, Turbo EA: - salta i riferimenti incrociati la cui carta di destinazione non esiste ancora; gli id di origine restano salvati negli attributi della fase (`capabilityIds`, `processIds`) così potrete collegarli più tardi importando gli artefatti mancanti; - timbra le carte di fase con `stageOrder`, `stageName`, `industryVariant`, `notes` e le liste originali `capabilityIds` / `processIds`. -I conteggi di saltati, creati e ri-collegati sono riportati come per il catalogo delle capacità. Gli import sono idempotenti. +I conteggi di saltati, creati e ri-collegati sono riportati come per il catalogo delle capacità. Gli import sono idempotenti. Ogni carta viene creata come fa il modulo della carta — con una voce nella cronologia, il suo livello nella tua gerarchia, un numero di riferimento quando Business Context usa i riferimenti automatici e un punteggio di qualità dei dati — e una voce il cui nome è già usato da un'altra carta Business Context allo stesso livello viene segnalata e non importata, insieme alle voci sottostanti; la finestra di risultato le elenca con il motivo. L'import è soggetto al permesso Crea sulle carte Business Context, il tipo che crea. ## Vista dettaglio diff --git a/docs/guide/value-stream-catalogue.md b/docs/guide/value-stream-catalogue.md index 9fce7539c..69dbcf23e 100644 --- a/docs/guide/value-stream-catalogue.md +++ b/docs/guide/value-stream-catalogue.md @@ -35,7 +35,7 @@ On confirmation, Turbo EA: - Skips any cross-references whose target card doesn't yet exist; the source IDs are stored on the stage's attributes (`capabilityIds`, `processIds`) so you can wire them later by importing the missing artefacts. - Stamps stage cards with `stageOrder`, `stageName`, `industryVariant`, `notes`, and the original `capabilityIds` / `processIds` lists. -Skipped, created, and re-linked counts are reported the same way as for the capability catalogue. Imports are idempotent. +Skipped, created, and re-linked counts are reported the same way as for the capability catalogue. Imports are idempotent. Each card is created the way the card form creates it — with a History entry, its level in your hierarchy, a reference number when Business Context uses automatic references and a data-quality score — and an entry whose name another Business Context card already uses at the same level is reported and not imported, together with the entries beneath it; the result dialog lists them with the reason. The import is gated by the Create permission on Business Context cards, the type it creates. ## Detail view diff --git a/docs/guide/value-stream-catalogue.pt.md b/docs/guide/value-stream-catalogue.pt.md index 568f36138..e97fc3196 100644 --- a/docs/guide/value-stream-catalogue.pt.md +++ b/docs/guide/value-stream-catalogue.pt.md @@ -35,7 +35,7 @@ Ao confirmar, o Turbo EA: - ignora referências cruzadas cuja carta-alvo ainda não exista; os ids de origem ficam guardados nos atributos da etapa (`capabilityIds`, `processIds`) para que possa ligá-los depois ao importar os artefactos em falta; - carimba as cartas de etapa com `stageOrder`, `stageName`, `industryVariant`, `notes` e as listas originais `capabilityIds` / `processIds`. -Os totais de saltados, criados e religados são reportados como para o catálogo de capacidades. As importações são idempotentes. +Os totais de saltados, criados e religados são reportados como para o catálogo de capacidades. As importações são idempotentes. Cada carta é criada como o formulário de carta o faz — com uma entrada no histórico, o seu nível na sua hierarquia, um número de referência quando Business Context usa referências automáticas e uma pontuação de qualidade de dados — e uma entrada cujo nome já é usado por outra carta Business Context no mesmo nível é reportada e não importada, bem como as entradas abaixo dela; o diálogo de resultado lista-as com o motivo. A importação está sujeita à permissão Criar em cartas Business Context, o tipo que cria. ## Vista de detalhe diff --git a/docs/guide/value-stream-catalogue.ru.md b/docs/guide/value-stream-catalogue.ru.md index a4bd3cbc9..406a73465 100644 --- a/docs/guide/value-stream-catalogue.ru.md +++ b/docs/guide/value-stream-catalogue.ru.md @@ -35,7 +35,7 @@ Turbo EA поставляется со **Справочным каталогом - пропускает перекрёстные ссылки, для которых целевая карточка ещё не существует; исходные идентификаторы сохраняются в атрибутах этапа (`capabilityIds`, `processIds`), чтобы позднее, после импорта недостающих артефактов, их можно было привязать; - ставит на карточках этапов метки `stageOrder`, `stageName`, `industryVariant`, `notes`, а также исходные списки `capabilityIds` / `processIds`. -Счётчики пропущенных, созданных и переподвязанных элементов сообщаются так же, как в каталоге способностей. Импорты идемпотентны. +Счётчики пропущенных, созданных и переподвязанных элементов сообщаются так же, как в каталоге способностей. Импорты идемпотентны. Каждая карточка создаётся так же, как через форму карточки — с записью в истории, своим уровнем в вашей иерархии, номером ссылки, если для Business Context включена автоматическая нумерация, и оценкой качества данных, — а запись, имя которой уже носит другая карточка Business Context на том же уровне, сообщается и не импортируется, как и записи под ней; диалог результата перечисляет их с причиной. Импорт требует разрешения «Создание» для карточек Business Context — типа, который он создаёт. ## Подробный просмотр diff --git a/docs/guide/value-stream-catalogue.zh.md b/docs/guide/value-stream-catalogue.zh.md index b084e6a1d..63f4d11fb 100644 --- a/docs/guide/value-stream-catalogue.zh.md +++ b/docs/guide/value-stream-catalogue.zh.md @@ -35,7 +35,7 @@ Turbo EA 内置「**价值流参考目录**」——一份精选的端到端价 - 跳过目标卡片尚不存在的交叉引用;源 ID 仍保存在阶段属性(`capabilityIds`、`processIds`)中,这样在以后导入缺失的工件后,您仍可手动建立连接; - 在阶段卡上加盖 `stageOrder`、`stageName`、`industryVariant`、`notes`,以及原始的 `capabilityIds` / `processIds` 列表。 -跳过、创建和重新关联的计数与能力目录采用同样的方式汇报。导入是幂等的。 +跳过、创建和重新关联的计数与能力目录采用同样的方式汇报。导入是幂等的。每张卡片都以卡片表单相同的方式创建——带有历史记录、在你层级中的级别、在 Business Context 使用自动引用编号时的引用编号以及数据质量评分——名称已被同一级别另一张 Business Context 卡片使用的条目会被报告而不导入,其下的条目亦然;结果对话框会连同原因一并列出。导入受 Business Context 卡片(即它所创建的类型)的「创建」权限控制。 ## 详情视图 diff --git a/frontend/src/features/admin/CardLayoutEditor.tsx b/frontend/src/features/admin/CardLayoutEditor.tsx index a84968f5e..632c70421 100644 --- a/frontend/src/features/admin/CardLayoutEditor.tsx +++ b/frontend/src/features/admin/CardLayoutEditor.tsx @@ -50,7 +50,11 @@ import { CSS } from "@dnd-kit/utilities"; import type { CardType, SectionDef, FieldDef, SectionConfig, TranslationMap } from "@/types"; import { useResolveLabel, useFieldLabel } from "@/hooks/useResolveLabel"; -import { isSectionCollapsedByDefault } from "@/features/cards/sectionConfig"; +import { + buildSectionOrder, + customSectionsOf, + isSectionCollapsedByDefault, +} from "@/features/cards/sectionConfig"; import { LOCALE_LABELS, SUPPORTED_LOCALES } from "@/i18n"; import { api } from "@/api/client"; import MaterialSymbol from "@/components/MaterialSymbol"; @@ -71,47 +75,8 @@ const BUILTIN_SECTIONS: { key: string; labelKey: string; icon: string; onlyIf?: // header shows the same weight badge as fields (mirrors __dataQuality buckets). const DQ_SECTION_KEYS = new Set(["description", "lifecycle", "relations"]); -const DEFAULT_ORDER = ["description", "eol", "lifecycle", "__custom__", "hierarchy", "successors", "tags", "relations"]; - // ── Helpers ────────────────────────────────────────────────────── -function getSectionOrder(cfg: Record, customSections: SectionDef[], hasHierarchy: boolean, hasSuccessors: boolean): string[] { - const raw = cfg?.__order as unknown as string[] | undefined; - if (raw && Array.isArray(raw) && raw.length > 0) { - const customKeys = customSections.map((_, i) => `custom:${i}`); - const existing = new Set(raw); - const result = [...raw]; - for (const k of customKeys) { if (!existing.has(k)) result.push(k); } - // Inject "successors" before "relations" if not already present - if (!existing.has("successors") && hasSuccessors) { - const relIdx = result.indexOf("relations"); - if (relIdx >= 0) result.splice(relIdx, 0, "successors"); - else result.push("successors"); - } - // Same for "tags" — it postdates the saved orders of existing installs, and - // CardDetailContent injects it the same way, so the editor must list it or - // the card would render a section the admin cannot see or configure. - if (!existing.has("tags")) { - const relIdx = result.indexOf("relations"); - if (relIdx >= 0) result.splice(relIdx, 0, "tags"); - else result.push("tags"); - } - return result.filter((k) => { - if (k === "hierarchy" && !hasHierarchy) return false; - if (k === "successors" && !hasSuccessors) return false; - return true; - }); - } - const order: string[] = []; - for (const key of DEFAULT_ORDER) { - if (key === "__custom__") customSections.forEach((_, i) => order.push(`custom:${i}`)); - else if (key === "hierarchy" && !hasHierarchy) { /* skip */ } - else if (key === "successors" && !hasSuccessors) { /* skip */ } - else order.push(key); - } - return order; -} - function getSectionInfo(key: string, customSections: SectionDef[], type: CardType) { if (key.startsWith("custom:")) { const idx = parseInt(key.split(":")[1], 10); @@ -1160,8 +1125,13 @@ export default function CardLayoutEditor({ const { t } = useTranslation(["admin", "common"]); const secCfg = (cardType.section_config || {}) as Record & { __order?: string[]; __dataQuality?: Record }; const dqConfig = secCfg.__dataQuality || {}; - const customSections = cardType.fields_schema.filter((s) => s.section !== "__description"); - const sectionOrder = getSectionOrder(secCfg, customSections, cardType.has_hierarchy, cardType.has_successors); + const customSections = customSectionsOf(cardType.fields_schema); + // The same order card detail renders (buildSectionOrder), so the editor + // never lists the sections in an order the card does not show. + const sectionOrder = buildSectionOrder(secCfg, customSections.length, { + hierarchy: !!cardType.has_hierarchy, + successors: !!cardType.has_successors, + }); const [expandedSections, setExpandedSections] = useState>(() => { const initial = new Set(); diff --git a/frontend/src/features/cards/CardDetail.branches.test.tsx b/frontend/src/features/cards/CardDetail.branches.test.tsx index f7d547f4e..9def4469b 100644 --- a/frontend/src/features/cards/CardDetail.branches.test.tsx +++ b/frontend/src/features/cards/CardDetail.branches.test.tsx @@ -39,7 +39,7 @@ vi.mock("@/features/cards/CardDetailContent", () => ({ default: (props: { card: { name: string; description?: string; attributes?: Record }; beforeTabs?: ReactNode; - initialTab?: number; + initialTab?: number | string; initialSubTab?: number; autoFieldKeys?: string[]; onAiSuggest?: () => void; @@ -260,10 +260,12 @@ describe("CardDetail — loading and deep links", () => { await waitFor(() => expect(location()).toBe("/cards/c1")); }); - it("falls back to the first tab for an unparseable tab number", async () => { + it("hands a non-numeric tab on as a key and drops an unparseable sub-tab", async () => { + // The content resolves the key against its own tab strip and opens the + // Card tab for one it does not carry (cardTabs.test.ts, resolveCardTab). renderPage("/cards/c1?tab=abc&subtab=zz"); await waitFor(() => expect(screen.getByTestId("content")).toHaveAttribute("data-subtab", "0")); - expect(screen.getByTestId("content")).toHaveAttribute("data-tab", "0"); + expect(screen.getByTestId("content")).toHaveAttribute("data-tab", "abc"); }); it("keeps the default permissions when the permission probe fails", async () => { diff --git a/frontend/src/features/cards/CardDetail.test.tsx b/frontend/src/features/cards/CardDetail.test.tsx index 6e96f3e02..c7b37da12 100644 --- a/frontend/src/features/cards/CardDetail.test.tsx +++ b/frontend/src/features/cards/CardDetail.test.tsx @@ -199,9 +199,9 @@ beforeEach(() => { // Helpers // --------------------------------------------------------------------------- -function renderCardDetail(cardId = "card-1") { +function renderCardDetail(cardId = "card-1", search = "") { return render( - + } /> } /> @@ -293,6 +293,22 @@ describe("CardDetail", () => { expect(screen.getByRole("tab", { name: /history/i })).toBeInTheDocument(); }); + it("opens the tab a link names by key (Admin → Resources links ?tab=resources)", async () => { + // The link used to open the Card tab: the page read ?tab as a number. + vi.mocked(api.get).mockImplementation((path: string) => { + if (path.includes("/my-permissions")) return Promise.resolve(mockPerms); + return Promise.resolve(mockCard); + }); + + renderCardDetail("card-1", "?tab=resources"); + + expect(await screen.findByTestId("resources-tab")).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: /resources/i })).toHaveAttribute( + "aria-selected", + "true", + ); + }); + it("renders extra tabs for BusinessProcess type", async () => { const bpmCard = { ...mockCard, type: "BusinessProcess" }; vi.mocked(api.get).mockImplementation((path: string) => { diff --git a/frontend/src/features/cards/CardDetail.tsx b/frontend/src/features/cards/CardDetail.tsx index ae413ddb4..7ae350b13 100644 --- a/frontend/src/features/cards/CardDetail.tsx +++ b/frontend/src/features/cards/CardDetail.tsx @@ -82,7 +82,7 @@ export default function CardDetail() { // Browser tab title; falls back to the route's «Card» label while loading, // and follows an inline rename without a reload. usePageSubject(card?.name); - const [initialTab, setInitialTab] = useState(0); + const [initialTab, setInitialTab] = useState(0); const [initialSubTab, setInitialSubTab] = useState(undefined); const [error, setError] = useState(""); const [perms, setPerms] = useState(DEFAULT_PERMISSIONS); @@ -203,11 +203,12 @@ export default function CardDetail() { setApprovalBlock(null); setSubtypeError(""); setLogoError(""); - // Read tab from URL search params (e.g. ?tab=1&subtab=1) + // Read tab from URL search params: a tab key (?tab=resources) or, for + // older links, an index (?tab=1&subtab=1). CardDetailContent resolves it. const urlTab = searchParams.get("tab"); const urlSubTab = searchParams.get("subtab"); if (urlTab) { - setInitialTab(parseInt(urlTab, 10) || 0); + setInitialTab(urlTab); // Clear the query params so they don't persist on navigation setSearchParams({}, { replace: true }); } else { diff --git a/frontend/src/features/cards/CardDetailContent.tabs.test.tsx b/frontend/src/features/cards/CardDetailContent.tabs.test.tsx new file mode 100644 index 000000000..00d299660 --- /dev/null +++ b/frontend/src/features/cards/CardDetailContent.tabs.test.tsx @@ -0,0 +1,357 @@ +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// The tab strip and the section order, driven through the real component with +// every tab body and section stubbed to a marker, so what is asserted is which +// tab opens and in which order the sections render — not what they contain. + +const flags = vi.hoisted(() => ({ + ppmEnabled: false, + grcEnabled: false, + counts: { risks: [] as unknown[], compliance: [] as unknown[], adrs: [] as unknown[] }, +})); + +vi.mock("@/api/client", () => ({ + api: { + get: vi.fn((url: string) => { + if (url.endsWith("/risks")) return Promise.resolve(flags.counts.risks); + if (url.endsWith("/compliance-findings")) return Promise.resolve(flags.counts.compliance); + if (url.startsWith("/adr/by-card/")) return Promise.resolve(flags.counts.adrs); + return Promise.resolve([]); + }), + patch: vi.fn(), + post: vi.fn(), + delete: vi.fn(), + }, + ApiError: class extends Error {}, +})); + +vi.mock("@/hooks/useMetamodel", () => ({ useMetamodel: vi.fn() })); +vi.mock("@/hooks/useCalculatedFields", () => ({ + useCalculatedFields: () => ({ calculatedFields: {}, isCalculated: () => false, loading: false }), +})); +vi.mock("@/hooks/useCurrency", () => ({ + useCurrency: () => ({ fmt: (v: number) => `$${v}`, fmtShort: (v: number) => `$${v}`, symbol: "$" }), +})); +vi.mock("@/hooks/usePpmEnabled", () => ({ usePpmEnabled: () => ({ ppmEnabled: flags.ppmEnabled }) })); +vi.mock("@/hooks/useGrcEnabled", () => ({ useGrcEnabled: () => ({ grcEnabled: flags.grcEnabled }) })); +const noteVisit = vi.hoisted(() => vi.fn()); +vi.mock("@/hooks/useCardTabActivity", () => ({ + useCardTabActivity: () => ({ hasUpdates: () => false, noteVisit }), +})); +vi.mock("@/hooks/AuthContext", () => ({ + useOptionalAuthUser: () => null, + useAuthContext: () => ({ user: { id: "u1", permissions: { "*": true } } }), +})); +vi.mock("@/hooks/usePermissions", () => ({ usePermissions: () => ({ can: () => true }) })); + +vi.mock("@/components/EolLinkSection", () => ({ default: () =>
})); +vi.mock("@/features/bpm/ProcessFlowTab", () => ({ + default: () =>
, +})); +vi.mock("@/features/bpm/ProcessAssessmentPanel", () => ({ + default: () =>
, +})); +vi.mock("@/features/cards/sections/SoAWTab", () => ({ + default: () =>
, +})); + +vi.mock("@/features/cards/sections", () => { + const marker = (id: string) => () =>
; + return { + AttributeSection: ({ section }: { section: { section: string } }) => ( +
+ ), + DescriptionSection: marker("sec-description"), + LifecycleSection: marker("sec-lifecycle"), + HierarchySection: marker("sec-hierarchy"), + SuccessorsSection: marker("sec-successors"), + RelationsSection: marker("sec-relations"), + LayeredDependencySection: marker("sec-ldv"), + TagsSection: marker("sec-tags"), + CommentsTab: marker("panel-comments"), + TodosTab: marker("panel-todos"), + StakeholdersTab: marker("panel-stakeholders"), + ResourcesTab: marker("panel-resources"), + AdrsTab: marker("panel-adrs"), + HistoryTab: marker("panel-history"), + RisksTab: marker("panel-risks"), + ComplianceTab: marker("panel-compliance"), + }; +}); + +import { useMetamodel } from "@/hooks/useMetamodel"; +import { registerExtension, resetExtensionHost, UI_SDK_VERSION } from "@/lib/extensionHost"; +import type { Card } from "@/types"; +import CardDetailContent from "./CardDetailContent"; + +function typeFor(key: string, over: Record = {}) { + return { + key, + label: key, + icon: "apps", + color: "#0f7eb5", + has_hierarchy: false, + has_successors: false, + subtypes: [], + fields_schema: [{ section: "Commercials", fields: [{ key: "a", label: "A", type: "text" }] }], + section_config: {}, + ...over, + }; +} + +function cardOf(type: string): Card { + return { + id: `card-${type}`, + name: "Thing", + type, + status: "ACTIVE", + approval_status: "DRAFT", + data_quality: 0, + lifecycle: {}, + attributes: {}, + tags: [], + stakeholders: [], + } as unknown as Card; +} + +const perms = { + can_view: true, + can_edit: true, + can_manage_adr_links: false, +} as unknown as Parameters[0]["perms"]; + +function useType(type: ReturnType) { + vi.mocked(useMetamodel).mockReturnValue({ + types: [type], + relationTypes: [], + loading: false, + getType: (k: string) => (k === type.key ? type : undefined), + getRelationsForType: () => [], + invalidateCache: vi.fn(), + } as unknown as ReturnType); +} + +function renderCard(card: Card, initialTab?: number | string, cardPerms = perms) { + return render( + + + {}} + initialTab={initialTab} + /> + } + /> + } /> + + , + ); +} + +const tabNames = () => screen.getAllByRole("tab").map((t) => t.textContent); +const selectedTab = () => + screen.getAllByRole("tab").find((t) => t.getAttribute("aria-selected") === "true") + ?.textContent; + +beforeEach(() => { + resetExtensionHost(); + noteVisit.mockClear(); + flags.ppmEnabled = false; + flags.grcEnabled = false; + flags.counts = { risks: [], compliance: [], adrs: [] }; +}); + +describe("CardDetailContent tab strip", () => { + it("gives a process its flow and assessment tabs and opens an index link on the flow", async () => { + useType(typeFor("BusinessProcess")); + renderCard(cardOf("BusinessProcess"), 1); + // The ADR tab shows while its count loads, so read the strip once it settles. + await waitFor(() => + expect(tabNames()).toEqual([ + "Card", + "Process Flow", + "Assessments", + "Comments", + "Todos", + "Stakeholders", + "Resources", + "History", + ]), + ); + expect(selectedTab()).toBe("Process Flow"); + expect(await screen.findByTestId("panel-processFlow")).toBeInTheDocument(); + await waitFor(() => expect(noteVisit).toHaveBeenCalledWith("processFlow")); + }); + + it("opens the tab a link names by key, such as Resources", async () => { + useType(typeFor("Application")); + renderCard(cardOf("Application"), "resources"); + expect(selectedTab()).toBe("Resources"); + expect(await screen.findByTestId("panel-resources")).toBeInTheDocument(); + }); + + it("opens the Card tab for a key the strip does not carry", () => { + useType(typeFor("Application")); + renderCard(cardOf("Application"), "assessments"); + expect(selectedTab()).toBe("Card"); + expect(screen.getByTestId("sec-description")).toBeInTheDocument(); + }); + + it("keeps the GRC and ADR tabs that have items once their counts load", async () => { + flags.grcEnabled = true; + flags.counts = { risks: [{ id: "r" }], compliance: [], adrs: [] }; + useType(typeFor("Application")); + renderCard(cardOf("Application")); + await waitFor(() => + expect(tabNames()).toEqual([ + "Card", + "Comments", + "Todos", + "Stakeholders", + "Resources", + "Risks", + "History", + ]), + ); + }); + + it("keeps the ADR tab on an empty card for someone who can link a decision", async () => { + useType(typeFor("Application")); + renderCard(cardOf("Application"), undefined, { + ...perms, + can_manage_adr_links: true, + } as typeof perms); + await waitFor(() => expect(tabNames()).toContain("ADRs")); + }); + + it("falls back to the Card tab when the open tab's count settles at 0", async () => { + flags.grcEnabled = true; + useType(typeFor("Application")); + renderCard(cardOf("Application"), "risks"); + expect(selectedTab()).toBe("Risks"); + await waitFor(() => expect(selectedTab()).toBe("Card")); + expect(screen.queryByTestId("panel-risks")).not.toBeInTheDocument(); + }); + + it("keeps the selected tab when a tab before it disappears", async () => { + flags.grcEnabled = true; + useType(typeFor("Application")); + renderCard(cardOf("Application"), "history"); + expect(selectedTab()).toBe("History"); + await waitFor(() => expect(tabNames()).not.toContain("Risks")); + expect(selectedTab()).toBe("History"); + expect(screen.getByTestId("panel-history")).toBeInTheDocument(); + }); + + it("gives an initiative SoAW and PPM tabs, and the PPM tab opens the PPM page", async () => { + flags.ppmEnabled = true; + useType(typeFor("Initiative")); + renderCard(cardOf("Initiative")); + await waitFor(() => + expect(tabNames()).toEqual([ + "Card", + "SoAW", + "Comments", + "Todos", + "Stakeholders", + "Resources", + "History", + "PPM", + ]), + ); + fireEvent.click(screen.getByRole("tab", { name: "SoAW" })); + expect(await screen.findByTestId("panel-soaw")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("tab", { name: "PPM" })); + expect(await screen.findByTestId("ppm-page")).toBeInTheDocument(); + expect(noteVisit).not.toHaveBeenCalledWith("ppm"); + }); + + it("switches panels by tab and notes each visit", async () => { + useType(typeFor("Application")); + renderCard(cardOf("Application")); + for (const [name, panel, key] of [ + ["Comments", "panel-comments", "comments"], + ["Todos", "panel-todos", "todos"], + ["Stakeholders", "panel-stakeholders", "stakeholders"], + ["History", "panel-history", "history"], + ]) { + fireEvent.click(screen.getByRole("tab", { name })); + expect(await screen.findByTestId(panel)).toBeInTheDocument(); + expect(noteVisit).toHaveBeenCalledWith(key); + } + }); + + it("appends an extension tab after the built-in ones and renders it when chosen", async () => { + registerExtension("acme", { + key: "acme", + sdkVersion: UI_SDK_VERSION, + cardTabs: [ + { id: "costs", label: "Costs", component: () =>
}, + { + id: "procs", + label: "Procs", + appliesTo: ["BusinessProcess"], + component: () =>
, + }, + ], + }); + useType(typeFor("Application")); + renderCard(cardOf("Application")); + expect(tabNames().slice(-2)).toEqual(["History", "Costs"]); + fireEvent.click(screen.getByRole("tab", { name: "Costs" })); + expect(await screen.findByTestId("ext-costs")).toBeInTheDocument(); + expect(noteVisit).not.toHaveBeenCalledWith(expect.stringContaining("ext:")); + }); +}); + +describe("CardDetailContent section order", () => { + const order = () => + Array.from(document.querySelectorAll("[data-testid^='sec-']")).map((n) => + n.getAttribute("data-testid"), + ); + + it("renders the built-in order with no stored one", () => { + useType(typeFor("Application", { has_hierarchy: true, has_successors: true })); + renderCard(cardOf("Application")); + expect(order()).toEqual([ + "sec-description", + "sec-eol", + "sec-lifecycle", + "sec-custom-Commercials", + "sec-hierarchy", + "sec-successors", + "sec-tags", + "sec-relations", + "sec-ldv", + ]); + }); + + it("follows a stored order, splicing tags in before relations", () => { + useType( + typeFor("Application", { + section_config: { __order: ["relations", "custom:0", "description"] }, + }), + ); + renderCard(cardOf("Application")); + expect(order()).toEqual([ + "sec-tags", + "sec-relations", + "sec-custom-Commercials", + "sec-description", + "sec-ldv", + ]); + }); + + it("leaves out a section the metamodel hides", () => { + useType(typeFor("Application", { section_config: { eol: { hidden: true } } })); + renderCard(cardOf("Application")); + expect(within(document.body).queryByTestId("sec-eol")).not.toBeInTheDocument(); + expect(screen.getByTestId("sec-lifecycle")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/features/cards/CardDetailContent.tsx b/frontend/src/features/cards/CardDetailContent.tsx index a9e4f86a6..38d78b73e 100644 --- a/frontend/src/features/cards/CardDetailContent.tsx +++ b/frontend/src/features/cards/CardDetailContent.tsx @@ -50,9 +50,22 @@ import { } from "@/lib/extensionHost"; import SoAWTab from "@/features/cards/sections/SoAWTab"; import { + allFieldsHidden, + buildSectionOrder, + calculatedFieldKeys, + customSectionsOf, + hiddenFieldKeys as hiddenFieldKeysOf, makeSectionConfigReader, sectionDefaultExpanded, } from "@/features/cards/sectionConfig"; +import { + CARD_TAB_LABEL_KEYS, + cardTabKeys, + notesVisit, + resolveCardTab, + visibleExtensionTabs, + type CardTabKey, +} from "@/features/cards/cardTabs"; import type { ArchitectureDecision, Card, @@ -69,8 +82,8 @@ interface Props { showBpmTabs?: boolean; /** Show PPM tab for Initiative cards (default true) */ showPpmTab?: boolean; - /** Initial tab index (default 0) */ - initialTab?: number; + /** Tab to open: its key (`"resources"`), or an index for older links (`1`). */ + initialTab?: number | string; /** Initial sub-tab for Process Flow tab */ initialSubTab?: number; /** Extra content rendered before tabs (e.g. archive banner, action buttons) */ @@ -159,10 +172,6 @@ export default function CardDetailContent({ }; }, [card.id, grcEnabled, canViewRisks, canViewCompliance]); - const showRisksTab = grcEnabled && canViewRisks && (risksCount === null || risksCount > 0); - const showComplianceTab = - grcEnabled && canViewCompliance && (complianceCount === null || complianceCount > 0); - // Card-scoped ADR count. Same `null = loading` convention as above, but ADRs // are not GRC-gated so this lives in its own effect. Unlike Risks the tab // also stays visible on an empty card for users who may link/create ADRs — @@ -190,15 +199,13 @@ export default function CardDetailContent({ }; }, [card.id, canViewAdr]); - const showAdrsTab = - canViewAdr && (adrCount === null || adrCount > 0 || perms.can_manage_adr_links); - - const [tab, setTab] = useState(initialTab); + // The tab the user asked for: a key, or an index from an older deep link. + const [requestedTab, setRequestedTab] = useState(initialTab); const [relRefresh, setRelRefresh] = useState(0); // Reset tab when card changes useEffect(() => { - setTab(initialTab); + setRequestedTab(initialTab); }, [card.id, initialTab]); const typeConfig = getType(card.type); @@ -215,20 +222,11 @@ export default function CardDetailContent({ ).filter((f) => !isCalculated(card.type, f.key) && !autoFieldKeys.includes(f.key)); // Calculated field keys (includes auto-computed PPM fields) - let calcFieldKeys: string[] = []; - try { - for (const section of typeConfig?.fields_schema || []) { - for (const field of section.fields || []) { - if (isCalculated(card.type, field.key)) calcFieldKeys.push(field.key); - } - } - } catch (err) { - console.error("[CardDetailContent] calcFieldKeys error", err); - calcFieldKeys = []; - } - if (autoFieldKeys.length > 0) { - calcFieldKeys = [...new Set([...calcFieldKeys, ...autoFieldKeys])]; - } + const calcFieldKeys = calculatedFieldKeys( + typeConfig?.fields_schema, + (key) => isCalculated(card.type, key), + autoFieldKeys, + ); // Extensions may hide specific card fields at render time (display-only, // ungated, never deletes stored values). Each registered provider renders as @@ -250,23 +248,14 @@ export default function CardDetailContent({ // Determine hidden fields: subtype hidden_fields ∪ every currently-registered // extension provider's reported keys. Reports from an extension that is no // longer registered are ignored (its slot is gone). - const hiddenFieldKeys: Set = (() => { - const set = new Set(); - if (card.subtype && typeConfig?.subtypes) { - const st = typeConfig.subtypes.find((s) => s.key === card.subtype); - for (const k of st?.hidden_fields ?? []) set.add(k); - } - const active = new Set(fieldVisibilityProviders.map((p) => p.extKey)); - for (const [extKey, keys] of Object.entries(extHiddenByKey)) { - if (!active.has(extKey)) continue; - for (const k of keys) set.add(k); - } - return set; - })(); - - const customSections = (typeConfig?.fields_schema || []).filter( - (s) => s.section !== "__description", + const hiddenFieldKeys = hiddenFieldKeysOf( + typeConfig?.subtypes, + card.subtype, + extHiddenByKey, + fieldVisibilityProviders.map((p) => p.extKey), ); + + const customSections = customSectionsOf(typeConfig?.fields_schema); const descExtraSection = (typeConfig?.fields_schema || []).find( (s) => s.section === "__description", ); @@ -282,42 +271,10 @@ export default function CardDetailContent({ const secExpanded = sectionCfg.expanded; const secHidden = sectionCfg.hidden; - // Build section order from config or default - const sectionOrder = (() => { - const raw = (sc as Record).__order as string[] | undefined; - if (raw && Array.isArray(raw) && raw.length > 0) { - const customKeys = customSections.map((_, i) => `custom:${i}`); - const existing = new Set(raw); - const result = [...raw]; - for (const k of customKeys) { - if (!existing.has(k)) result.push(k); - } - // Inject "successors" before "relations" if not already present - if (!existing.has("successors") && typeConfig?.has_successors) { - const relIdx = result.indexOf("relations"); - if (relIdx >= 0) result.splice(relIdx, 0, "successors"); - else result.push("successors"); - } - // Inject "tags" before "relations" if not already present - if (!existing.has("tags")) { - const relIdx = result.indexOf("relations"); - if (relIdx >= 0) result.splice(relIdx, 0, "tags"); - else result.push("tags"); - } - return result.filter((k) => { - if (k === "hierarchy" && !typeConfig?.has_hierarchy) return false; - if (k === "successors" && !typeConfig?.has_successors) return false; - return true; - }); - } - const order: string[] = ["description", "eol", "lifecycle"]; - customSections.forEach((_, i) => order.push(`custom:${i}`)); - if (typeConfig?.has_hierarchy) order.push("hierarchy"); - if (typeConfig?.has_successors) order.push("successors"); - order.push("tags"); - order.push("relations"); - return order; - })(); + const sectionOrder = buildSectionOrder(sc as Record, customSections.length, { + hierarchy: !!typeConfig?.has_hierarchy, + successors: !!typeConfig?.has_successors, + }); const handleUpdate = useCallback( async (updates: Record) => { @@ -467,13 +424,7 @@ export default function CardDetailContent({ const section = customSections[idx]; if (!section) return null; // Skip section if all its fields are hidden for the active subtype - if ( - hiddenFieldKeys.size > 0 && - section.fields.length > 0 && - section.fields.every((f) => hiddenFieldKeys.has(f.key)) - ) { - return null; - } + if (allFieldsHidden(section, hiddenFieldKeys)) return null; return ( - (plugin.cardTabs ?? []) - .filter((def) => !def.appliesTo || def.appliesTo.includes(card.type)) - .filter((def) => !def.permission || hasPermission(user?.permissions, def.permission)) - .map((def) => ({ extKey: key, def })), + const extensionTabs = visibleExtensionTabs(uiExtensions, card.type, (permission) => + hasPermission(user?.permissions, permission), ); - const extensionTabBase = historyIdx + 1 + (isPpm ? 1 : 0); + // A tab that vanished (a count settled at 0) or never existed opens the + // Card tab rather than leaving the strip with no selection. + const tab = resolveCardTab(requestedTab, [ + ...tabKeys, + ...extensionTabs.map((x) => x.value), + ]); const { hasUpdates, noteVisit } = useCardTabActivity(card.id, user?.id); - const tabKeyForIndex = (idx: number): string | null => { - if (idx === 0) return "card"; - if (isBpm && idx === 1) return "processFlow"; - if (isBpm && idx === 2) return "assessments"; - if (isSoaw && idx === soawTabIdx) return "soaw"; - if (idx === commentsIdx) return "comments"; - if (idx === todosIdx) return "todos"; - if (idx === stakeholdersIdx) return "stakeholders"; - if (idx === resourcesIdx) return "resources"; - if (showAdrsTab && idx === adrsIdx) return "adrs"; - if (showRisksTab && idx === risksIdx) return "risks"; - if (showComplianceTab && idx === complianceIdx) return "compliance"; - if (idx === historyIdx) return "history"; - if (isPpm && idx === ppmTabIdx) return "ppm"; - return null; - }; - // Note which tabs the user has opened during this visit. The dots stay // visible for the whole visit — noteVisit buffers the timestamps and the // hook flushes them to localStorage on unmount / beforeunload so the - // *next* visit starts fresh. PPM tab is excluded — clicking it navigates - // away rather than rendering inline. + // *next* visit starts fresh. useEffect(() => { - const key = tabKeyForIndex(tab); - if (key && key !== "ppm") noteVisit(key); - // tabKeyForIndex captures index offsets; re-running when any of them shift - // keeps the active key in sync. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tab, card.id, isBpm, isSoaw, showAdrsTab, showRisksTab, showComplianceTab, noteVisit]); + if (notesVisit(tab)) noteVisit(tab); + }, [tab, card.id, noteVisit]); const renderTabLabel = (key: string, label: string) => { if (!hasUpdates(key)) return label; @@ -627,39 +546,27 @@ export default function CardDetailContent({ { - if (isPpm && v === ppmTabIdx) { + onChange={(_, v: string) => { + if (v === "ppm") { navigate(`/ppm/${card.id}`); return; } - setTab(v); + setRequestedTab(v); }} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ borderBottom: 1, borderColor: "divider", mb: 2 }} > - - {isBpm && } - {isBpm && } - {isSoaw && } - - - - - {showAdrsTab && } - {showRisksTab && } - {showComplianceTab && ( - - )} - - {isPpm && } - {extensionTabs.map(({ extKey, def }) => ( - + {tabKeys.map((key: CardTabKey) => ( + + ))} + {extensionTabs.map(({ def, value }) => ( + ))} - {tab === 0 && ( + {tab === "card" && ( {missingMandatory.length > 0 && ( @@ -676,7 +583,7 @@ export default function CardDetailContent({ )} )} - {isBpm && tab === 1 && ( + {tab === "processFlow" && ( @@ -689,7 +596,7 @@ export default function CardDetailContent({ )} - {isSoaw && tab === soawTabIdx && ( + {tab === "soaw" && ( @@ -698,7 +605,7 @@ export default function CardDetailContent({ )} - {isBpm && tab === 2 && ( + {tab === "assessments" && ( @@ -707,7 +614,7 @@ export default function CardDetailContent({ )} - {tab === commentsIdx && ( + {tab === "comments" && ( @@ -720,7 +627,7 @@ export default function CardDetailContent({ )} - {tab === todosIdx && ( + {tab === "todos" && ( @@ -729,7 +636,7 @@ export default function CardDetailContent({ )} - {tab === stakeholdersIdx && ( + {tab === "stakeholders" && ( @@ -744,7 +651,7 @@ export default function CardDetailContent({ )} - {tab === resourcesIdx && ( + {tab === "resources" && ( @@ -757,7 +664,7 @@ export default function CardDetailContent({ )} - {showAdrsTab && tab === adrsIdx && ( + {tab === "adrs" && ( @@ -771,7 +678,7 @@ export default function CardDetailContent({ )} - {showRisksTab && tab === risksIdx && ( + {tab === "risks" && ( @@ -780,7 +687,7 @@ export default function CardDetailContent({ )} - {showComplianceTab && tab === complianceIdx && ( + {tab === "compliance" && ( @@ -789,7 +696,7 @@ export default function CardDetailContent({ )} - {tab === historyIdx && ( + {tab === "history" && ( @@ -798,9 +705,9 @@ export default function CardDetailContent({ )} - {extensionTabs.map(({ extKey, def }, i) => - tab === extensionTabBase + i ? ( - + {extensionTabs.map(({ extKey, def, value }) => + tab === value ? ( + diff --git a/frontend/src/features/cards/cardTabs.test.ts b/frontend/src/features/cards/cardTabs.test.ts new file mode 100644 index 000000000..678cc7639 --- /dev/null +++ b/frontend/src/features/cards/cardTabs.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, it } from "vitest"; +import { + CARD_TAB_KEYS, + CARD_TAB_LABEL_KEYS, + cardTabKeys, + extensionTabValue, + hasItemsOrLoading, + isCardTabKey, + notesVisit, + resolveCardTab, + visibleExtensionTabs, + type CardTabInputs, +} from "./cardTabs"; + +function inputs(over: Partial = {}): CardTabInputs { + return { + cardType: "Application", + showBpmTabs: true, + showPpmTab: true, + ppmEnabled: true, + grcEnabled: false, + canViewRisks: false, + risksCount: 0, + canViewCompliance: false, + complianceCount: 0, + canViewAdr: false, + adrCount: 0, + canManageAdrLinks: false, + ...over, + }; +} + +describe("hasItemsOrLoading", () => { + it("shows a tab while its count loads and while it has items", () => { + expect(hasItemsOrLoading(null)).toBe(true); + expect(hasItemsOrLoading(1)).toBe(true); + expect(hasItemsOrLoading(0)).toBe(false); + }); +}); + +describe("cardTabKeys", () => { + it("gives a plain card the six tabs every card has", () => { + expect(cardTabKeys(inputs())).toEqual([ + "card", + "comments", + "todos", + "stakeholders", + "resources", + "history", + ]); + }); + + it("puts a process's flow and assessments right after the Card tab", () => { + expect(cardTabKeys(inputs({ cardType: "BusinessProcess" })).slice(0, 4)).toEqual([ + "card", + "processFlow", + "assessments", + "comments", + ]); + }); + + it("leaves the process tabs out where the host hides them", () => { + expect( + cardTabKeys(inputs({ cardType: "BusinessProcess", showBpmTabs: false })), + ).not.toContain("processFlow"); + }); + + it("gives an initiative its SoAW tab after Card and its PPM tab last", () => { + const keys = cardTabKeys(inputs({ cardType: "Initiative" })); + expect(keys[1]).toBe("soaw"); + expect(keys.slice(-2)).toEqual(["history", "ppm"]); + }); + + it("keeps an initiative's SoAW tab but drops PPM when PPM is off or hosted", () => { + expect(cardTabKeys(inputs({ cardType: "Initiative", ppmEnabled: false }))).toEqual([ + "card", + "soaw", + "comments", + "todos", + "stakeholders", + "resources", + "history", + ]); + expect(cardTabKeys(inputs({ cardType: "Initiative", showPpmTab: false }))).not.toContain( + "ppm", + ); + }); + + it("orders ADRs, risks and compliance between Resources and History", () => { + const keys = cardTabKeys( + inputs({ + grcEnabled: true, + canViewAdr: true, + adrCount: 2, + canViewRisks: true, + risksCount: 1, + canViewCompliance: true, + complianceCount: 3, + }), + ); + expect(keys).toEqual([ + "card", + "comments", + "todos", + "stakeholders", + "resources", + "adrs", + "risks", + "compliance", + "history", + ]); + }); + + it("shows the ADR tab with no decisions only to someone who can link one", () => { + expect(cardTabKeys(inputs({ canViewAdr: true, adrCount: 0 }))).not.toContain("adrs"); + expect( + cardTabKeys(inputs({ canViewAdr: true, adrCount: 0, canManageAdrLinks: true })), + ).toContain("adrs"); + expect(cardTabKeys(inputs({ canViewAdr: true, adrCount: null }))).toContain("adrs"); + expect( + cardTabKeys(inputs({ canViewAdr: false, adrCount: 5, canManageAdrLinks: true })), + ).not.toContain("adrs"); + }); + + it("shows risks and compliance only with GRC on, the permission, and items", () => { + const grc = { grcEnabled: true, canViewRisks: true, canViewCompliance: true }; + expect(cardTabKeys(inputs({ ...grc, risksCount: null, complianceCount: null }))).toEqual( + expect.arrayContaining(["risks", "compliance"]), + ); + const none = cardTabKeys(inputs({ ...grc, risksCount: 0, complianceCount: 0 })); + expect(none).not.toContain("risks"); + expect(none).not.toContain("compliance"); + const off = cardTabKeys( + inputs({ ...grc, grcEnabled: false, risksCount: 4, complianceCount: 4 }), + ); + expect(off).not.toContain("risks"); + expect(off).not.toContain("compliance"); + const noRiskView = cardTabKeys( + inputs({ ...grc, canViewRisks: false, risksCount: 4, complianceCount: 0 }), + ); + expect(noRiskView).not.toContain("risks"); + const noComplianceView = cardTabKeys( + inputs({ ...grc, canViewCompliance: false, risksCount: 0, complianceCount: 4 }), + ); + expect(noComplianceView).not.toContain("compliance"); + }); +}); + +describe("resolveCardTab", () => { + const values = ["card", "processFlow", "assessments", "comments", "resources", "ext:x:y"]; + + it("opens a tab named by its key", () => { + expect(resolveCardTab("resources", values)).toBe("resources"); + expect(resolveCardTab("ext:x:y", values)).toBe("ext:x:y"); + }); + + it("opens a tab named by an index, as a number or digits", () => { + expect(resolveCardTab(1, values)).toBe("processFlow"); + expect(resolveCardTab("2", values)).toBe("assessments"); + expect(resolveCardTab(0, values)).toBe("card"); + }); + + it("falls back to the Card tab for anything it cannot place", () => { + expect(resolveCardTab(undefined, values)).toBe("card"); + expect(resolveCardTab("", values)).toBe("card"); + expect(resolveCardTab("risks", values)).toBe("card"); + expect(resolveCardTab(99, values)).toBe("card"); + expect(resolveCardTab("1a", values)).toBe("card"); + expect(resolveCardTab("-1", values)).toBe("card"); + }); +}); + +describe("notesVisit", () => { + it("records a visit to a built-in tab opened in place", () => { + expect(notesVisit("comments")).toBe(true); + expect(notesVisit("card")).toBe(true); + }); + + it("does not record PPM, which navigates away, or an extension tab", () => { + expect(notesVisit("ppm")).toBe(false); + expect(notesVisit("ext:acme:summary")).toBe(false); + }); +}); + +describe("tab keys and labels", () => { + it("knows every key and labels each from the cards namespace", () => { + expect(isCardTabKey("history")).toBe(true); + expect(isCardTabKey("nope")).toBe(false); + expect(CARD_TAB_KEYS.map((k) => CARD_TAB_LABEL_KEYS[k])).toEqual([ + "tabs.card", + "tabs.processFlow", + "tabs.assessments", + "tabs.soaw", + "tabs.comments", + "tabs.todos", + "tabs.stakeholders", + "tabs.resources", + "tabs.adrs", + "tabs.risks", + "tabs.compliance", + "tabs.history", + "tabs.ppm", + ]); + }); +}); + +describe("visibleExtensionTabs", () => { + it("keeps a tab for this card type and the user's permissions, in order", () => { + const extensions = [ + { + key: "acme", + plugin: { + cardTabs: [ + { id: "all", label: "All" }, + { id: "apps", label: "Apps", appliesTo: ["Application"] }, + { id: "procs", label: "Procs", appliesTo: ["BusinessProcess"] }, + { id: "secret", label: "Secret", permission: "ext.acme.secret" }, + { id: "open", label: "Open", permission: "ext.acme.open" }, + ], + }, + }, + { key: "bare", plugin: {} }, + ]; + const can = (p: string) => p === "ext.acme.open"; + expect( + visibleExtensionTabs(extensions, "Application", can).map((x) => [x.extKey, x.def.id, x.value]), + ).toEqual([ + ["acme", "all", "ext:acme:all"], + ["acme", "apps", "ext:acme:apps"], + ["acme", "open", "ext:acme:open"], + ]); + }); + + it("names an extension tab after its extension and id", () => { + expect(extensionTabValue("acme", "summary")).toBe("ext:acme:summary"); + }); +}); diff --git a/frontend/src/features/cards/cardTabs.ts b/frontend/src/features/cards/cardTabs.ts new file mode 100644 index 000000000..d33a21da2 --- /dev/null +++ b/frontend/src/features/cards/cardTabs.ts @@ -0,0 +1,157 @@ +/** + * Which tabs a card's detail page shows, in which order, and which one opens. + * + * The strip used to be addressed by index, with every tab's position worked + * out by hand from the ones before it. A deep link named by key + * (`?tab=resources`) therefore opened the Card tab, and a tab appearing or + * vanishing as its count loaded moved the selection onto its neighbour. Each + * tab is now addressed by its key; an index is accepted only for the links + * that still carry one (`?tab=1` for a process's flow). + */ + +export const CARD_TAB_KEYS = [ + "card", + "processFlow", + "assessments", + "soaw", + "comments", + "todos", + "stakeholders", + "resources", + "adrs", + "risks", + "compliance", + "history", + "ppm", +] as const; + +export type CardTabKey = (typeof CARD_TAB_KEYS)[number]; + +/** The i18n key (`cards` namespace) labelling each tab. */ +export const CARD_TAB_LABEL_KEYS: Record = { + card: "tabs.card", + processFlow: "tabs.processFlow", + assessments: "tabs.assessments", + soaw: "tabs.soaw", + comments: "tabs.comments", + todos: "tabs.todos", + stakeholders: "tabs.stakeholders", + resources: "tabs.resources", + adrs: "tabs.adrs", + risks: "tabs.risks", + compliance: "tabs.compliance", + history: "tabs.history", + ppm: "tabs.ppm", +}; + +export function isCardTabKey(value: string): value is CardTabKey { + return (CARD_TAB_KEYS as readonly string[]).includes(value); +} + +/** + * A tab listing a card's linked items. While its count is still loading + * (`null`) the tab shows, so it never flashes in on a card that has items; + * once the count settles at 0 it goes. + */ +export function hasItemsOrLoading(count: number | null): boolean { + return count === null || count > 0; +} + +export interface CardTabInputs { + cardType: string; + /** BusinessProcess: Process Flow + Assessments (off in the side panel). */ + showBpmTabs: boolean; + /** Initiative: the PPM tab (off where PPM itself hosts the card). */ + showPpmTab: boolean; + ppmEnabled: boolean; + grcEnabled: boolean; + canViewRisks: boolean; + risksCount: number | null; + canViewCompliance: boolean; + complianceCount: number | null; + canViewAdr: boolean; + adrCount: number | null; + /** An empty ADR tab stays for someone who can link the first decision. */ + canManageAdrLinks: boolean; +} + +/** The card's built-in tabs, left to right. */ +export function cardTabKeys(input: CardTabInputs): CardTabKey[] { + const keys: CardTabKey[] = ["card"]; + if (input.showBpmTabs && input.cardType === "BusinessProcess") { + keys.push("processFlow", "assessments"); + } + if (input.cardType === "Initiative") keys.push("soaw"); + keys.push("comments", "todos", "stakeholders", "resources"); + if (input.canViewAdr && (hasItemsOrLoading(input.adrCount) || input.canManageAdrLinks)) { + keys.push("adrs"); + } + if (input.grcEnabled && input.canViewRisks && hasItemsOrLoading(input.risksCount)) { + keys.push("risks"); + } + if ( + input.grcEnabled && + input.canViewCompliance && + hasItemsOrLoading(input.complianceCount) + ) { + keys.push("compliance"); + } + keys.push("history"); + if (input.showPpmTab && input.ppmEnabled && input.cardType === "Initiative") { + keys.push("ppm"); + } + return keys; +} + +/** + * The tab to show for a requested one: a key the strip carries, or an index + * into it (a number or a string of digits). Anything else opens the Card tab. + */ +export function resolveCardTab( + requested: number | string | undefined, + values: readonly string[], +): string { + if (requested === undefined || requested === "") return "card"; + if (typeof requested === "number" || /^\d+$/.test(requested)) { + return values[Number(requested)] ?? "card"; + } + return values.includes(requested) ? requested : "card"; +} + +/** + * Opening a tab clears its activity dot. The PPM tab is not opened in place + * (choosing it navigates to the PPM page), and extension tabs carry no dot. + */ +export function notesVisit(value: string): value is CardTabKey { + return isCardTabKey(value) && value !== "ppm"; +} + +export interface CardTabExtension { + key: string; + plugin: { cardTabs?: D[] }; +} + +export interface CardTabDef { + id: string; + appliesTo?: string[]; + permission?: string; +} + +/** The value an extension tab takes in the strip. */ +export function extensionTabValue(extKey: string, tabId: string): string { + return `ext:${extKey}:${tabId}`; +} + +/** Extension tabs for this card, after the built-in ones: by card type, then permission. */ +export function visibleExtensionTabs( + extensions: readonly CardTabExtension[], + cardType: string, + can: (permission: string) => boolean, +): { extKey: string; def: D; value: string }[] { + return extensions.flatMap(({ key, plugin }) => + (plugin.cardTabs ?? []) + .filter((def) => !def.appliesTo || def.appliesTo.includes(cardType)) + .filter((def) => !def.permission || can(def.permission)) + .map((def) => ({ extKey: key, def, value: extensionTabValue(key, def.id) })), + ); +} diff --git a/frontend/src/features/cards/sectionConfig.test.ts b/frontend/src/features/cards/sectionConfig.test.ts index 45b5f4766..820e5daa2 100644 --- a/frontend/src/features/cards/sectionConfig.test.ts +++ b/frontend/src/features/cards/sectionConfig.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect } from "vitest"; import { + allFieldsHidden, + buildSectionOrder, + calculatedFieldKeys, + customSectionsOf, + hiddenFieldKeys, isSectionCollapsedByDefault, makeSectionConfigReader, sectionDefaultExpanded, @@ -167,3 +172,151 @@ describe("makeSectionConfigReader", () => { expect(sec.hidden("lifecycle")).toBe(false); }); }); + +describe("buildSectionOrder", () => { + const none = { hierarchy: false, successors: false }; + const both = { hierarchy: true, successors: true }; + + it("uses the built-in order when none is stored", () => { + expect(buildSectionOrder({}, 2, both)).toEqual([ + "description", + "eol", + "lifecycle", + "custom:0", + "custom:1", + "hierarchy", + "successors", + "tags", + "relations", + ]); + }); + + it("leaves hierarchy and successors out of the built-in order for a type without them", () => { + expect(buildSectionOrder(undefined, 0, none)).toEqual([ + "description", + "eol", + "lifecycle", + "tags", + "relations", + ]); + }); + + it("treats an empty stored order as none", () => { + expect(buildSectionOrder({ __order: [] }, 0, none)).toEqual([ + "description", + "eol", + "lifecycle", + "tags", + "relations", + ]); + }); + + it("keeps a stored order and appends the custom sections it does not name", () => { + const sc = { __order: ["relations", "custom:1", "tags", "description"] }; + expect(buildSectionOrder(sc, 3, none)).toEqual([ + "relations", + "custom:1", + "tags", + "description", + "custom:0", + "custom:2", + ]); + }); + + it("splices successors, then tags, in just before relations", () => { + const sc = { __order: ["description", "relations", "lifecycle"] }; + expect(buildSectionOrder(sc, 0, both)).toEqual([ + "description", + "successors", + "tags", + "relations", + "lifecycle", + ]); + }); + + it("appends them when the stored order has no relations", () => { + const sc = { __order: ["description"] }; + expect(buildSectionOrder(sc, 0, both)).toEqual(["description", "successors", "tags"]); + }); + + it("never adds successors to a type that has none, and never moves a stored one", () => { + expect(buildSectionOrder({ __order: ["relations"] }, 0, none)).toEqual(["tags", "relations"]); + const stored = { __order: ["successors", "tags", "relations"] }; + expect(buildSectionOrder(stored, 0, both)).toEqual(["successors", "tags", "relations"]); + }); + + it("drops a stored hierarchy or successors the type no longer has", () => { + const sc = { __order: ["hierarchy", "successors", "tags", "relations"] }; + expect(buildSectionOrder(sc, 0, none)).toEqual(["tags", "relations"]); + expect(buildSectionOrder(sc, 0, { hierarchy: true, successors: false })).toEqual([ + "hierarchy", + "tags", + "relations", + ]); + }); +}); + +describe("customSectionsOf", () => { + it("drops the description extras section", () => { + const schema: SectionDef[] = [ + { section: "__description", fields: [] }, + { section: "Commercials", fields: [] }, + ]; + expect(customSectionsOf(schema).map((s) => s.section)).toEqual(["Commercials"]); + expect(customSectionsOf(undefined)).toEqual([]); + }); +}); + +describe("hiddenFieldKeys", () => { + const subtypes = [ + { key: "saas", hidden_fields: ["hosting", "region"] }, + { key: "onprem" }, + ]; + + it("hides the active subtype's fields", () => { + expect([...hiddenFieldKeys(subtypes, "saas", {}, [])]).toEqual(["hosting", "region"]); + expect([...hiddenFieldKeys(subtypes, "onprem", {}, [])]).toEqual([]); + expect([...hiddenFieldKeys(subtypes, null, {}, [])]).toEqual([]); + expect([...hiddenFieldKeys(undefined, "saas", {}, [])]).toEqual([]); + }); + + it("adds what registered extensions report and ignores the rest", () => { + const reported = { acme: ["cost"], gone: ["owner"] }; + expect([...hiddenFieldKeys(subtypes, "saas", reported, ["acme"])]).toEqual([ + "hosting", + "region", + "cost", + ]); + }); +}); + +describe("allFieldsHidden", () => { + const section = (keys: string[]): SectionDef => + ({ section: "S", fields: keys.map((key) => ({ key })) }) as unknown as SectionDef; + + it("is true only when every field of a non-empty section is hidden", () => { + expect(allFieldsHidden(section(["a", "b"]), new Set(["a", "b", "c"]))).toBe(true); + expect(allFieldsHidden(section(["a", "b"]), new Set(["a"]))).toBe(false); + expect(allFieldsHidden(section([]), new Set(["a"]))).toBe(false); + }); +}); + +describe("calculatedFieldKeys", () => { + const schema = [ + { section: "A", fields: [{ key: "total" }, { key: "name" }] }, + { section: "B", fields: [{ key: "score" }] }, + ] as unknown as SectionDef[]; + + it("lists calculated fields, then auto fields not already among them", () => { + const calc = (k: string) => k === "total" || k === "score"; + expect(calculatedFieldKeys(schema, calc, ["costActual", "total"])).toEqual([ + "total", + "score", + "costActual", + ]); + }); + + it("is empty with no schema and no auto fields", () => { + expect(calculatedFieldKeys(undefined, () => true, [])).toEqual([]); + }); +}); diff --git a/frontend/src/features/cards/sectionConfig.ts b/frontend/src/features/cards/sectionConfig.ts index 9156e95bc..e2e10db83 100644 --- a/frontend/src/features/cards/sectionConfig.ts +++ b/frontend/src/features/cards/sectionConfig.ts @@ -87,3 +87,107 @@ export function makeSectionConfigReader( hidden: (key: string) => !!cfg(key)?.hidden, }; } + +/** A type's custom attribute sections: every section but `__description`. */ +export function customSectionsOf(fieldsSchema: SectionDef[] | undefined): SectionDef[] { + return (fieldsSchema ?? []).filter((s) => s.section !== "__description"); +} + +/** The built-in order for a type that never stored `section_config.__order`. */ +export const DEFAULT_SECTION_ORDER = [ + "description", + "eol", + "lifecycle", + "__custom__", + "hierarchy", + "successors", + "tags", + "relations", +] as const; + +/** + * The order card detail renders a type's sections in — and the order the + * Card Layout editor lists them in. One function for both, so the editor + * cannot show an admin an order the card does not render. + * + * A stored `__order` wins. Custom sections it does not name go at its end; + * `successors` and `tags`, which postdate the orders of existing installs, + * are spliced in just before `relations` (or appended when there is none); + * `hierarchy` and `successors` drop out for a type that has no such section. + * With no stored order, `DEFAULT_SECTION_ORDER` applies. + */ +export function buildSectionOrder( + sectionConfig: Record | undefined, + customCount: number, + has: { hierarchy: boolean; successors: boolean }, +): string[] { + const customKeys = Array.from({ length: customCount }, (_, i) => `custom:${i}`); + const stored = sectionConfig?.__order; + if (Array.isArray(stored) && stored.length > 0) { + const named = new Set(stored); + const order: string[] = [...stored]; + for (const k of customKeys) if (!named.has(k)) order.push(k); + const beforeRelations = (key: string) => { + const at = order.indexOf("relations"); + if (at >= 0) order.splice(at, 0, key); + else order.push(key); + }; + if (!named.has("successors") && has.successors) beforeRelations("successors"); + if (!named.has("tags")) beforeRelations("tags"); + return order.filter( + (k) => (k !== "hierarchy" || has.hierarchy) && (k !== "successors" || has.successors), + ); + } + return DEFAULT_SECTION_ORDER.flatMap((key) => { + if (key === "__custom__") return customKeys; + if (key === "hierarchy" && !has.hierarchy) return []; + if (key === "successors" && !has.successors) return []; + return [key]; + }); +} + +/** + * Fields hidden on this card: the active subtype's `hidden_fields`, plus the + * keys each extension field-visibility provider currently registered has + * reported. A report from an extension no longer registered is ignored. + */ +export function hiddenFieldKeys( + subtypes: { key: string; hidden_fields?: string[] }[] | undefined, + subtype: string | null | undefined, + reportedByExtension: Record, + activeExtensions: Iterable, +): Set { + const hidden = new Set(); + if (subtype) { + for (const k of subtypes?.find((s) => s.key === subtype)?.hidden_fields ?? []) hidden.add(k); + } + const active = new Set(activeExtensions); + for (const [extKey, keys] of Object.entries(reportedByExtension)) { + if (active.has(extKey)) for (const k of keys) hidden.add(k); + } + return hidden; +} + +/** A section none of whose fields would show; a section with no fields is never "all hidden". */ +export function allFieldsHidden(section: SectionDef, hidden: Set): boolean { + return section.fields.length > 0 && section.fields.every((f) => hidden.has(f.key)); +} + +/** + * Field keys rendered read-only with a "calc" badge: the calculated fields of + * the type, then the auto-computed ones (PPM's cost roll-ups) not already + * among them. + */ +export function calculatedFieldKeys( + fieldsSchema: SectionDef[] | undefined, + isCalculated: (fieldKey: string) => boolean, + autoFieldKeys: readonly string[], +): string[] { + const keys: string[] = []; + for (const section of fieldsSchema ?? []) { + for (const field of section.fields ?? []) { + if (isCalculated(field.key)) keys.push(field.key); + } + } + return [...new Set([...keys, ...autoFieldKeys])]; +} diff --git a/frontend/src/features/cards/sections/HistoryTab.test.tsx b/frontend/src/features/cards/sections/HistoryTab.test.tsx index dc09f79de..0a97bee90 100644 --- a/frontend/src/features/cards/sections/HistoryTab.test.tsx +++ b/frontend/src/features/cards/sections/HistoryTab.test.tsx @@ -186,3 +186,106 @@ describe("HistoryTab — file attachment replace and edit (#1166)", () => { expect(screen.getByText("security")).toBeInTheDocument(); }); }); + +describe("HistoryTab — event detail lines", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + function event(id: string, event_type: string, data: Record) { + return { + id, + event_type, + created_at: "2026-10-10T09:00:00Z", + user_id: "u1", + user_display_name: "Ann", + data, + }; + } + + it("links a relation's peer card and shows the verb", async () => { + vi.mocked(api.get).mockResolvedValue([ + event("e1", "relation.created", { + directional_label: "is supported by", + direction: "outgoing", + peer_id: "peer-9", + peer_name: "Oracle DB", + peer_type: "Application", + }), + ]); + renderTab(); + expect(await screen.findByText("is supported by")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Oracle DB" })).toHaveAttribute( + "href", + "/cards/peer-9", + ); + }); + + it("links a risk by its reference and shows its level", async () => { + vi.mocked(api.get).mockResolvedValue([ + event("e2", "risk.added", { + reference: "R-000042", + title: "Single point of failure", + level: "High", + link: "/grc/risks/r42", + }), + ]); + renderTab(); + expect(await screen.findByRole("link", { name: "R-000042" })).toHaveAttribute( + "href", + "/grc/risks/r42", + ); + expect(screen.getByText("high")).toBeInTheDocument(); + expect(screen.getByText("Single point of failure")).toBeInTheDocument(); + }); + + it("links an added document in a new tab", async () => { + vi.mocked(api.get).mockResolvedValue([ + event("e3", "document.added", { name: "Runbook", url: "https://example.invalid/rb" }), + ]); + renderTab(); + const link = await screen.findByRole("link", { name: "Runbook" }); + expect(link).toHaveAttribute("href", "https://example.invalid/rb"); + expect(link).toHaveAttribute("target", "_blank"); + }); + + it("shows a withdrawn flow's revision and its reason in full", async () => { + vi.mocked(api.get).mockResolvedValue([ + event("e4", "process_flow.withdrawn", { revision: 4, reason: "Wrong lane owner" }), + ]); + renderTab(); + expect(await screen.findByText(/Wrong lane owner/)).toBeInTheDocument(); + expect(screen.getByText("Revision 4")).toBeInTheDocument(); + }); + + it("shows an event type this build does not know by its raw name", async () => { + vi.mocked(api.get).mockResolvedValue([event("e5", "card.teleported", { summary: "Moved" })]); + renderTab(); + expect(await screen.findByText("card.teleported")).toBeInTheDocument(); + expect(screen.getByText("Moved")).toBeInTheDocument(); + }); + + it("shows one row per changed attribute, dropping the unchanged ones", async () => { + vi.mocked(api.get).mockResolvedValue([ + event("e6", "card.updated", { + changes: { + attributes: { + old: { cost: 10, keep: "same" }, + new: { cost: 20, keep: "same" }, + }, + }, + }), + ]); + renderTab(); + expect(await screen.findByText("cost")).toBeInTheDocument(); + expect(screen.getByText("10")).toBeInTheDocument(); + expect(screen.getByText("20")).toBeInTheDocument(); + expect(screen.queryByText("keep")).not.toBeInTheDocument(); + }); + + it("says so when the card has no history", async () => { + vi.mocked(api.get).mockResolvedValue([]); + renderTab(); + expect(await screen.findByText(/no history/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/features/cards/sections/HistoryTab.tsx b/frontend/src/features/cards/sections/HistoryTab.tsx index 2c3235f80..d04c63337 100644 --- a/frontend/src/features/cards/sections/HistoryTab.tsx +++ b/frontend/src/features/cards/sections/HistoryTab.tsx @@ -13,88 +13,18 @@ import { getPhaseLabels } from "@/features/cards/sections/cardDetailUtils"; import { useMetamodel } from "@/hooks/useMetamodel"; import { useFieldLabel } from "@/hooks/useResolveLabel"; import { useDateFormat } from "@/hooks/useDateFormat"; -import type { EventEntry } from "@/types"; +import type { EventEntry, FieldDef } from "@/types"; +import { + NO_VALUE, + attributeLabels, + eventDetailModel, + eventMeta, + fieldLabelsFor, + parseChanges, + type EventDetail, +} from "@/features/cards/sections/historyChanges"; // ── Tab: History ──────────────────────────────────────────────── -const EVENT_META_ICONS: Record = { - "card.created": { icon: "add_circle", color: "#4caf50" }, - "card.updated": { icon: "edit", color: "#1976d2" }, - "card.archived": { icon: "archive", color: "#ff9800" }, - "card.restored": { icon: "restore", color: "#4caf50" }, - "card.deleted": { icon: "delete", color: "#f44336" }, - "card.approval_status.approve": { icon: "verified", color: "#4caf50" }, - "card.approval_status.reject": { icon: "cancel", color: "#f44336" }, - "card.approval_status.reset": { icon: "restart_alt", color: "#9e9e9e" }, - "stakeholder.added": { icon: "person_add", color: "#1976d2" }, - "stakeholder.role_changed": { icon: "manage_accounts", color: "#1976d2" }, - "stakeholder.removed": { icon: "person_remove", color: "#f44336" }, - "relation.created": { icon: "link", color: "#1976d2" }, - "relation.updated": { icon: "sync_alt", color: "#1976d2" }, - "relation.deleted": { icon: "link_off", color: "#f44336" }, - "risk.added": { icon: "report", color: "#ff9800" }, - "risk.updated": { icon: "edit_note", color: "#ff9800" }, - "risk.removed": { icon: "report_off", color: "#9e9e9e" }, - "document.added": { icon: "link", color: "#1976d2" }, - "document.updated": { icon: "edit_note", color: "#1976d2" }, - "document.removed": { icon: "link_off", color: "#f44336" }, - "file.uploaded": { icon: "upload_file", color: "#1976d2" }, - "file.replaced": { icon: "sync", color: "#1976d2" }, - "file.updated": { icon: "edit_note", color: "#1976d2" }, - "file.deleted": { icon: "delete", color: "#f44336" }, - "card_logo.updated": { icon: "image", color: "#1976d2" }, - "card_logo.deleted": { icon: "hide_image", color: "#f44336" }, - "comment.created": { icon: "chat", color: "#1976d2" }, - // Tagging rescores the card, which moves its Modified date — so it belongs - // on the timeline like any other edit (#995). - "tag.added": { icon: "label", color: "#1976d2" }, - "tag.removed": { icon: "label_off", color: "#f44336" }, - // BPM process-flow approval trail. Without these the events render as their - // raw dotted event_type, which makes the approval history of a process - // unreadable on the card — and an unreadable trail is not an audit trail. - "process_flow.submitted": { icon: "send", color: "#1976d2" }, - "process_flow.approved": { icon: "verified", color: "#4caf50" }, - "process_flow.rejected": { icon: "cancel", color: "#f44336" }, - "process_flow.withdrawn": { icon: "unpublished", color: "#f44336" }, -}; - -function getEventMeta(t: (key: string) => string): Record { - return { - "card.created": { label: t("history.events.created"), ...EVENT_META_ICONS["card.created"] }, - "card.updated": { label: t("history.events.updated"), ...EVENT_META_ICONS["card.updated"] }, - "card.archived": { label: t("history.events.archived"), ...EVENT_META_ICONS["card.archived"] }, - "card.restored": { label: t("history.events.restored"), ...EVENT_META_ICONS["card.restored"] }, - "card.deleted": { label: t("history.events.deleted"), ...EVENT_META_ICONS["card.deleted"] }, - "card.approval_status.approve": { label: t("history.events.approved"), ...EVENT_META_ICONS["card.approval_status.approve"] }, - "card.approval_status.reject": { label: t("history.events.rejected"), ...EVENT_META_ICONS["card.approval_status.reject"] }, - "card.approval_status.reset": { label: t("history.events.resetToDraft"), ...EVENT_META_ICONS["card.approval_status.reset"] }, - "stakeholder.added": { label: t("history.events.stakeholderAdded"), ...EVENT_META_ICONS["stakeholder.added"] }, - "stakeholder.role_changed": { label: t("history.events.stakeholderRoleChanged"), ...EVENT_META_ICONS["stakeholder.role_changed"] }, - "stakeholder.removed": { label: t("history.events.stakeholderRemoved"), ...EVENT_META_ICONS["stakeholder.removed"] }, - "relation.created": { label: t("history.events.relationCreated"), ...EVENT_META_ICONS["relation.created"] }, - "relation.updated": { label: t("history.events.relationUpdated"), ...EVENT_META_ICONS["relation.updated"] }, - "relation.deleted": { label: t("history.events.relationDeleted"), ...EVENT_META_ICONS["relation.deleted"] }, - "risk.added": { label: t("history.events.riskAdded"), ...EVENT_META_ICONS["risk.added"] }, - "risk.updated": { label: t("history.events.riskUpdated"), ...EVENT_META_ICONS["risk.updated"] }, - "risk.removed": { label: t("history.events.riskRemoved"), ...EVENT_META_ICONS["risk.removed"] }, - "document.added": { label: t("history.events.documentAdded"), ...EVENT_META_ICONS["document.added"] }, - "document.updated": { label: t("history.events.documentUpdated"), ...EVENT_META_ICONS["document.updated"] }, - "document.removed": { label: t("history.events.documentRemoved"), ...EVENT_META_ICONS["document.removed"] }, - "file.uploaded": { label: t("history.events.fileUploaded"), ...EVENT_META_ICONS["file.uploaded"] }, - "file.replaced": { label: t("history.events.fileReplaced"), ...EVENT_META_ICONS["file.replaced"] }, - "file.updated": { label: t("history.events.fileUpdated"), ...EVENT_META_ICONS["file.updated"] }, - "file.deleted": { label: t("history.events.fileDeleted"), ...EVENT_META_ICONS["file.deleted"] }, - "card_logo.updated": { label: t("history.events.cardLogoUpdated"), ...EVENT_META_ICONS["card_logo.updated"] }, - "card_logo.deleted": { label: t("history.events.cardLogoDeleted"), ...EVENT_META_ICONS["card_logo.deleted"] }, - "comment.created": { label: t("history.events.commentCreated"), ...EVENT_META_ICONS["comment.created"] }, - "tag.added": { label: t("history.events.tagAdded"), ...EVENT_META_ICONS["tag.added"] }, - "tag.removed": { label: t("history.events.tagRemoved"), ...EVENT_META_ICONS["tag.removed"] }, - "process_flow.submitted": { label: t("history.events.processFlowSubmitted"), ...EVENT_META_ICONS["process_flow.submitted"] }, - "process_flow.approved": { label: t("history.events.processFlowApproved"), ...EVENT_META_ICONS["process_flow.approved"] }, - "process_flow.rejected": { label: t("history.events.processFlowRejected"), ...EVENT_META_ICONS["process_flow.rejected"] }, - "process_flow.withdrawn": { label: t("history.events.processFlowWithdrawn"), ...EVENT_META_ICONS["process_flow.withdrawn"] }, - }; -} - function getFieldLabels(t: (key: string) => string): Record { return { name: t("common:labels.name"), @@ -121,78 +51,45 @@ function getApprovalStatusLabels(t: (key: string) => string): Record): string { - if (val == null || val === "") return "\u2014"; - if (typeof val === "string") return val; - if (typeof val === "number" || typeof val === "boolean") return String(val); - if (Array.isArray(val)) return val.map((v) => fmtVal(v, phaseLabels)).join(", "); - if (typeof val === "object") { - const entries = Object.entries(val as Record).filter(([, v]) => v != null && v !== ""); - if (entries.length === 0) return "\u2014"; - return entries.map(([k, v]) => `${phaseLabels[k] || k}: ${v}`).join(", "); - } - return String(val); -} - -interface ChangeRow { field: string; oldVal: string; newVal: string } - -const RISK_LEVEL_COLOR: Record = { - critical: "#d32f2f", - high: "#f57c00", - medium: "#fbc02d", - low: "#388e3c", -}; - -interface EventDetailProps { - data: Record | undefined; - eventType: string; - fallbackSummary: string | null; +interface EventDetailViewProps { + detail: EventDetail; typeIconFor: (typeKey: string | null | undefined) => { icon: string; color: string } | null; t: (key: string, opts?: Record) => string; } -/** Renders a richer one-liner for events that ship structured context - * (relations, risks, documents, files). Falls back to the plain summary - * for everything else. */ -function EventDetail({ data, eventType, fallbackSummary, typeIconFor, t }: EventDetailProps) { - if (!data) return fallbackSummary ? : null; +/** Renders the one-liner `eventDetailModel` decided on (relations, risks, + * documents, files, flow approvals, or the plain summary). */ +function EventDetailView({ detail, typeIconFor, t }: EventDetailViewProps) { + if (!detail) return null; + if (detail.kind === "plain") return ; - if (eventType.startsWith("relation.")) { - const directional = (data.directional_label as string) || (data.relation_label as string) || (data.type as string); - const peerId = data.peer_id as string | undefined; - const peerName = (data.peer_name as string) || peerId || ""; - const peerType = data.peer_type as string | null | undefined; - const direction = (data.direction as string) || "outgoing"; - const peerIcon = typeIconFor(peerType); + if (detail.kind === "relation") { + const peerIcon = typeIconFor(detail.peerType); return ( - {directional} - + {detail.label} + {peerIcon && ( )} - {peerId ? ( - - {peerName} + {detail.peerId ? ( + + {detail.peerName} ) : ( - {peerName} + {detail.peerName} )} - {peerType && ( - {peerType} + {detail.peerType && ( + {detail.peerType} )} ); } - if (eventType.startsWith("risk.")) { - const reference = data.reference as string | undefined; - const title = data.title as string | undefined; - const level = (data.level as string | undefined)?.toLowerCase(); - const link = data.link as string | undefined; - const levelColor = level ? RISK_LEVEL_COLOR[level] : undefined; + if (detail.kind === "risk") { + const { reference, link, level, levelColor, title } = detail; return ( {reference && ( @@ -222,77 +119,30 @@ function EventDetail({ data, eventType, fallbackSummary, typeIconFor, t }: Event ); } - if ( - eventType === "document.added" || - eventType === "document.updated" || - eventType === "document.removed" - ) { - const name = (data.name as string) || (data.url as string) || fallbackSummary || ""; - const url = data.url as string | undefined; - if (eventType !== "document.removed" && url) { - return ( - - - {name} - - - ); - } - return ; - } - - if (eventType === "card_logo.updated" || eventType === "card_logo.deleted") { - // No `changes` payload: image bytes have no business in a diff. - return ; - } - - if (eventType.startsWith("file.")) { - const fileLine = (d: Record) => { - const name = (d.name as string) || ""; - const size = d.size as number | undefined; - return size != null ? `${name} · ${(size / 1024).toFixed(1)} KB` : name; - }; - const current = fileLine(data) || fallbackSummary || ""; - // A replace carries the version it superseded as `previous` (not as - // `changes`: a raw byte count is not a field diff anyone wants to read). - const previous = data.previous as Record | undefined; - if (eventType === "file.replaced" && previous) { - return ; - } - return ; - } - - if (eventType.startsWith("stakeholder.")) { - // Stakeholder events ship a clean summary already (user · role[ · old → new]). - return fallbackSummary ? : null; - } - - if (eventType.startsWith("process_flow.")) { - // All four flow events carry the revision; only a withdrawal carries a - // reason. The reason is rendered in full and never truncated — an audit - // trail that hides the justification defeats its own purpose. - const revision = data.revision as number | undefined; - const reason = (data.reason as string | undefined)?.trim(); - if (revision == null && !reason) { - return fallbackSummary ? : null; - } + if (detail.kind === "link") { return ( - - {revision != null && ( - - {t("history.processFlowRevision", { revision })} - - )} - {reason && ( - - {t("history.withdrawalReason")}: - - )} - + + + {detail.text} + + ); } - return fallbackSummary ? : null; + return ( + + {detail.revision != null && ( + + {t("history.processFlowRevision", { revision: detail.revision })} + + )} + {detail.reason && ( + + {t("history.withdrawalReason")}: + + )} + + ); } function PlainSummary({ text }: { text: string }) { @@ -303,107 +153,26 @@ function PlainSummary({ text }: { text: string }) { ); } -/** - * Resolve a raw change-payload key (e.g. `attr_costTotalAnnual` from the - * survey-apply path, or a bare attribute key from the regular PATCH path) - * to a human-friendly label using whichever source matches first: - * 1. Static i18n labels for built-in fields (`name`, `description`, etc.) - * 2. Lifecycle phase keys (`plan`, `phaseIn`, `active`, …) - * 3. Metamodel attribute label resolved via the card type's fields_schema - * 4. The raw key as a last-resort fallback - */ -function resolveFieldLabel( - rawKey: string, - fieldLabels: Record, - phaseLabels: Record, - attrLabels: Record, -): string { - if (fieldLabels[rawKey]) return fieldLabels[rawKey]; - if (phaseLabels[rawKey]) return phaseLabels[rawKey]; - // The survey-apply path emits attribute changes keyed as `attr_` - // so the audit event distinguishes attribute updates from core-field - // updates. Strip the prefix before looking up the metamodel label. - const attrKey = rawKey.startsWith("attr_") ? rawKey.slice(5) : rawKey; - if (attrLabels[attrKey]) return attrLabels[attrKey]; - return rawKey; -} - -function parseChanges( - changes: Record, - fieldLabels: Record, - phaseLabels: Record, - attrLabels: Record, - statusLabels: Record, -): ChangeRow[] { - const rows: ChangeRow[] = []; - for (const [field, change] of Object.entries(changes)) { - if (!change || typeof change !== "object" || !("old" in change) || !("new" in change)) { - // Legacy stringified format -- skip non-parsable - continue; - } - const c = change as { old: unknown; new: unknown }; - if (field === "attributes" && typeof c.old === "object" && typeof c.new === "object") { - const oldA = (c.old || {}) as Record; - const newA = (c.new || {}) as Record; - for (const key of new Set([...Object.keys(oldA), ...Object.keys(newA)])) { - if (JSON.stringify(oldA[key]) !== JSON.stringify(newA[key])) { - rows.push({ - field: resolveFieldLabel(key, fieldLabels, phaseLabels, attrLabels), - oldVal: fmtVal(oldA[key], phaseLabels), - newVal: fmtVal(newA[key], phaseLabels), - }); - } - } - } else if (field === "lifecycle" && typeof c.old === "object" && typeof c.new === "object") { - const oldL = (c.old || {}) as Record; - const newL = (c.new || {}) as Record; - for (const key of new Set([...Object.keys(oldL), ...Object.keys(newL)])) { - if (oldL[key] !== newL[key]) { - rows.push({ field: phaseLabels[key] || key, oldVal: fmtVal(oldL[key], phaseLabels), newVal: fmtVal(newL[key], phaseLabels) }); - } - } - } else if (field === "approval_status") { - // Fall back to the raw value so a status this build has not heard of - // still renders, rather than blanking the row. - rows.push({ - field: resolveFieldLabel(field, fieldLabels, phaseLabels, attrLabels), - oldVal: statusLabels[String(c.old)] ?? fmtVal(c.old, phaseLabels), - newVal: statusLabels[String(c.new)] ?? fmtVal(c.new, phaseLabels), - }); - } else { - rows.push({ - field: resolveFieldLabel(field, fieldLabels, phaseLabels, attrLabels), - oldVal: fmtVal(c.old, phaseLabels), - newVal: fmtVal(c.new, phaseLabels), - }); - } - } - return rows; -} - function HistoryTab({ fsId, cardType }: { fsId: string; cardType?: string }) { const { t } = useTranslation(["cards", "common"]); const theme = useTheme(); const { formatDateTime } = useDateFormat(); - const eventMeta = getEventMeta(t); const fieldLabels = getFieldLabels(t); const phaseLabels = getPhaseLabels(t); const statusLabels = getApprovalStatusLabels(t); + const resourceLabels = { + url: t("resources.addLinkDialog.url"), + type: t("resources.addLinkDialog.type"), + category: t("resources.uploadFileDialog.category"), + }; const { getType } = useMetamodel(); const fieldLabel = useFieldLabel(); - // Build a `{attributeKey: localizedLabel}` map for the card's type so - // change rows can show "Total Annual Cost" instead of `costTotalAnnual`. - const attrLabels: Record = {}; - if (cardType) { - const ct = getType(cardType); - if (ct) { - for (const section of ct.fields_schema || []) { - for (const f of section.fields || []) { - attrLabels[f.key] = fieldLabel(f); - } - } - } - } + // `{attributeKey: localizedLabel}` for the card's type, so change rows show + // "Total Annual Cost" instead of `costTotalAnnual`. + const attrLabels = attributeLabels( + cardType ? getType(cardType)?.fields_schema : undefined, + (f) => fieldLabel(f as FieldDef), + ); const typeIconFor = (typeKey: string | null | undefined) => { if (!typeKey) return null; const ct = getType(typeKey); @@ -422,23 +191,15 @@ function HistoryTab({ fsId, cardType }: { fsId: string; cardType?: string }) { return ( {events.map((e) => { - const meta = eventMeta[e.event_type] || { label: e.event_type, icon: "info", color: "#9e9e9e" }; + const meta = eventMeta(e.event_type, t); const changes = e.data?.changes as Record | undefined; - // A document link's `url` / `type` and a file's `category` are the - // resource's own fields, not the card's: label them as the Resources - // dialogs do, so a card attribute that happens to be keyed `type` is - // not borrowed for the row (#1166). - const labels = - e.event_type === "document.updated" || e.event_type === "file.updated" - ? { - ...fieldLabels, - url: t("resources.addLinkDialog.url"), - type: t("resources.addLinkDialog.type"), - category: t("resources.uploadFileDialog.category"), - } - : fieldLabels; const rows = changes - ? parseChanges(changes, labels, phaseLabels, attrLabels, statusLabels) + ? parseChanges(changes, { + fields: fieldLabelsFor(e.event_type, fieldLabels, resourceLabels), + phases: phaseLabels, + attributes: attrLabels, + statuses: statusLabels, + }) : []; const summary = typeof e.data?.summary === "string" ? (e.data.summary as string) : null; @@ -470,10 +231,8 @@ function HistoryTab({ fsId, cardType }: { fsId: string; cardType?: string }) { clickable peer cards for relations, links to the risk page for risk events, etc. Falls back to plain summary text. */} {rows.length === 0 && ( - @@ -494,7 +253,7 @@ function HistoryTab({ fsId, cardType }: { fsId: string; cardType?: string }) { {row.field} - {row.oldVal !== "\u2014" && ( + {row.oldVal !== NO_VALUE && ( `t(${key})`; + +function labels(over: Partial = {}): ChangeLabels { + return { + fields: { name: "Name", approval_status: "Approval" }, + phases: { plan: "Plan", active: "Active" }, + attributes: { costTotal: "Total cost", type: "Kind" }, + statuses: { DRAFT: "Draft", APPROVED: "Approved" }, + ...over, + }; +} + +describe("eventMeta", () => { + it("labels a known event from its i18n key, with its icon and colour", () => { + expect(eventMeta("card.updated", echo)).toEqual({ + label: "t(history.events.updated)", + icon: "edit", + color: "#1976d2", + }); + expect(eventMeta("process_flow.withdrawn", echo)).toEqual({ + label: "t(history.events.processFlowWithdrawn)", + icon: "unpublished", + color: "#f44336", + }); + }); + + it("shows an unknown event as its raw type with a grey info icon", () => { + expect(eventMeta("something.new", echo)).toEqual({ + label: "something.new", + icon: "info", + color: "#9e9e9e", + }); + }); + + it("knows the 33 event types the backend emits on a card", () => { + expect(Object.keys(EVENT_META)).toHaveLength(33); + expect(EVENT_META["tag.removed"]).toEqual({ + labelKey: "history.events.tagRemoved", + icon: "label_off", + color: "#f44336", + }); + }); +}); + +describe("fmtVal", () => { + const phases = { plan: "Plan" }; + + it("reads an empty value as a dash", () => { + expect(NO_VALUE).toBe("—"); + expect(fmtVal(null, phases)).toBe("—"); + expect(fmtVal(undefined, phases)).toBe("—"); + expect(fmtVal("", phases)).toBe("—"); + expect(fmtVal({}, phases)).toBe("—"); + expect(fmtVal({ a: null, b: "" }, phases)).toBe("—"); + }); + + it("writes scalars as text", () => { + expect(fmtVal("x", phases)).toBe("x"); + expect(fmtVal(0, phases)).toBe("0"); + expect(fmtVal(12.5, phases)).toBe("12.5"); + expect(fmtVal(false, phases)).toBe("false"); + }); + + it("joins a list, and a dict as key: value with phase labels", () => { + expect(fmtVal(["a", 2, null], phases)).toBe("a, 2, —"); + expect(fmtVal({ plan: "2026-01-01", other: 3, gone: null }, phases)).toBe( + "Plan: 2026-01-01, other: 3", + ); + }); +}); + +describe("resolveFieldLabel", () => { + const fields = { name: "Name" }; + const phases = { plan: "Plan" }; + const attrs = { cost: "Cost", name: "Attr name" }; + + it("prefers a built-in field, then a phase, then an attribute, then the key", () => { + expect(resolveFieldLabel("name", fields, phases, attrs)).toBe("Name"); + expect(resolveFieldLabel("plan", fields, phases, attrs)).toBe("Plan"); + expect(resolveFieldLabel("cost", fields, phases, attrs)).toBe("Cost"); + expect(resolveFieldLabel("unknown", fields, phases, attrs)).toBe("unknown"); + }); + + it("strips the survey path's attr_ prefix before an attribute lookup", () => { + expect(resolveFieldLabel("attr_cost", fields, phases, attrs)).toBe("Cost"); + expect(resolveFieldLabel("attr_missing", fields, phases, attrs)).toBe("attr_missing"); + }); +}); + +describe("parseChanges", () => { + it("lists a scalar field with its label", () => { + expect(parseChanges({ name: { old: "A", new: "B" } }, labels())).toEqual([ + { field: "Name", oldVal: "A", newVal: "B" }, + ]); + }); + + it("skips an entry that is not {old, new}", () => { + const changes = { + legacy: "A -> B", + half: { old: 1 }, + other: { new: 1 }, + empty: null, + name: { old: null, new: "B" }, + }; + expect(parseChanges(changes, labels())).toEqual([ + { field: "Name", oldVal: "—", newVal: "B" }, + ]); + }); + + it("expands attributes into one row per changed key, compared as JSON", () => { + const changes = { + attributes: { + old: { costTotal: 10, tags: ["a"], same: { x: 1 }, gone: "x" }, + new: { costTotal: 20, tags: ["a"], same: { x: 1 }, added: true }, + }, + }; + expect(parseChanges(changes, labels())).toEqual([ + { field: "Total cost", oldVal: "10", newVal: "20" }, + { field: "gone", oldVal: "x", newVal: "—" }, + { field: "added", oldVal: "—", newVal: "true" }, + ]); + }); + + it("treats a null whole-dict side as empty", () => { + expect(parseChanges({ attributes: { old: null, new: { costTotal: 5 } } }, labels())).toEqual([ + { field: "Total cost", oldVal: "—", newVal: "5" }, + ]); + }); + + it("does not expand an attributes change that is not two dicts", () => { + expect(parseChanges({ attributes: { old: "a", new: "b" } }, labels())).toEqual([ + { field: "attributes", oldVal: "a", newVal: "b" }, + ]); + }); + + it("expands lifecycle into one row per changed phase, named by the phase", () => { + const changes = { + lifecycle: { + old: { plan: "2026-01-01", active: "2026-06-01" }, + new: { plan: "2026-01-01", active: "2026-07-01", endOfLife: "2030-01-01" }, + }, + }; + expect(parseChanges(changes, labels())).toEqual([ + { field: "Active", oldVal: "2026-06-01", newVal: "2026-07-01" }, + { field: "endOfLife", oldVal: "—", newVal: "2030-01-01" }, + ]); + }); + + it("names approval states as the badge does, falling back to the raw value", () => { + expect( + parseChanges({ approval_status: { old: "DRAFT", new: "BROKEN" } }, labels()), + ).toEqual([{ field: "Approval", oldVal: "Draft", newVal: "BROKEN" }]); + expect( + parseChanges({ approval_status: { old: null, new: "APPROVED" } }, labels()), + ).toEqual([{ field: "Approval", oldVal: "—", newVal: "Approved" }]); + }); +}); + +describe("fieldLabelsFor", () => { + const fields = { name: "Name" }; + const resource = { url: "URL", type: "Link type", category: "Category" }; + + it("labels a document or file edit's own fields as the Resources dialogs do", () => { + expect(fieldLabelsFor("document.updated", fields, resource)).toEqual({ + name: "Name", + url: "URL", + type: "Link type", + category: "Category", + }); + expect(fieldLabelsFor("file.updated", fields, resource).category).toBe("Category"); + }); + + it("leaves every other event's labels alone", () => { + expect(fieldLabelsFor("card.updated", fields, resource)).toBe(fields); + expect(fieldLabelsFor("document.added", fields, resource)).toBe(fields); + }); +}); + +describe("eventDetailModel", () => { + it("falls back to the summary when there is no data", () => { + expect(eventDetailModel("card.updated", undefined, "Edited")).toEqual({ + kind: "plain", + text: "Edited", + }); + expect(eventDetailModel("card.updated", undefined, null)).toBeNull(); + }); + + it("describes a relation by its verb, direction and peer", () => { + expect( + eventDetailModel( + "relation.created", + { + directional_label: "is used by", + relation_label: "uses", + type: "relAppToItc", + direction: "incoming", + peer_id: "p1", + peer_name: "SAP", + peer_type: "Application", + }, + null, + ), + ).toEqual({ + kind: "relation", + label: "is used by", + outgoing: false, + peerId: "p1", + peerName: "SAP", + peerType: "Application", + }); + }); + + it("falls back through the relation label, the type, and the peer id", () => { + expect( + eventDetailModel("relation.deleted", { relation_label: "uses", peer_id: "p1" }, null), + ).toEqual({ + kind: "relation", + label: "uses", + outgoing: true, + peerId: "p1", + peerName: "p1", + peerType: undefined, + }); + expect(eventDetailModel("relation.updated", { type: "relX" }, null)).toEqual({ + kind: "relation", + label: "relX", + outgoing: true, + peerId: undefined, + peerName: "", + peerType: undefined, + }); + }); + + it("describes a risk with its reference, link, lower-cased level and colour", () => { + expect( + eventDetailModel( + "risk.added", + { reference: "R-000001", title: "Outage", level: "HIGH", link: "/grc/risks/1" }, + null, + ), + ).toEqual({ + kind: "risk", + reference: "R-000001", + link: "/grc/risks/1", + level: "high", + levelColor: RISK_LEVEL_COLOR.high, + title: "Outage", + }); + expect(RISK_LEVEL_COLOR).toEqual({ + critical: "#d32f2f", + high: "#f57c00", + medium: "#fbc02d", + low: "#388e3c", + }); + expect(eventDetailModel("risk.removed", { title: "T" }, null)).toEqual({ + kind: "risk", + reference: undefined, + link: undefined, + level: undefined, + levelColor: undefined, + title: "T", + }); + }); + + it("links a document unless it was removed", () => { + const data = { name: "Spec", url: "https://example.invalid/spec" }; + expect(eventDetailModel("document.added", data, null)).toEqual({ + kind: "link", + text: "Spec", + url: "https://example.invalid/spec", + }); + expect(eventDetailModel("document.updated", data, null)?.kind).toBe("link"); + expect(eventDetailModel("document.removed", data, null)).toEqual({ + kind: "plain", + text: "Spec", + }); + }); + + it("names a document by its name, then its URL, then the summary", () => { + expect(eventDetailModel("document.removed", { url: "https://x.invalid" }, "s")).toEqual({ + kind: "plain", + text: "https://x.invalid", + }); + expect(eventDetailModel("document.removed", {}, "Summary")).toEqual({ + kind: "plain", + text: "Summary", + }); + expect(eventDetailModel("document.removed", {}, null)).toEqual({ kind: "plain", text: "" }); + }); + + it("shows a logo change by its summary only", () => { + expect(eventDetailModel("card_logo.updated", { mime: "image/png" }, "New logo")).toEqual({ + kind: "plain", + text: "New logo", + }); + expect(eventDetailModel("card_logo.deleted", {}, null)).toEqual({ kind: "plain", text: "" }); + }); + + it("describes a file by name and size, and a replace as previous → current", () => { + expect(eventDetailModel("file.uploaded", { name: "a.pdf", size: 2048 }, null)).toEqual({ + kind: "plain", + text: "a.pdf · 2.0 KB", + }); + expect(eventDetailModel("file.deleted", { name: "a.pdf" }, null)).toEqual({ + kind: "plain", + text: "a.pdf", + }); + expect( + eventDetailModel( + "file.replaced", + { name: "b.pdf", size: 1536, previous: { name: "a.pdf", size: 512 } }, + null, + ), + ).toEqual({ kind: "plain", text: "a.pdf · 0.5 KB → b.pdf · 1.5 KB" }); + expect(eventDetailModel("file.replaced", { name: "b.pdf" }, null)).toEqual({ + kind: "plain", + text: "b.pdf", + }); + expect(eventDetailModel("file.uploaded", {}, "Uploaded")).toEqual({ + kind: "plain", + text: "Uploaded", + }); + }); + + it("shows a flow approval's revision and a withdrawal's reason, trimmed", () => { + expect(eventDetailModel("process_flow.approved", { revision: 3 }, null)).toEqual({ + kind: "processFlow", + revision: 3, + reason: undefined, + }); + expect( + eventDetailModel("process_flow.withdrawn", { revision: 0, reason: " wrong lane " }, null), + ).toEqual({ kind: "processFlow", revision: 0, reason: "wrong lane" }); + expect(eventDetailModel("process_flow.withdrawn", { reason: " " }, "s")).toEqual({ + kind: "plain", + text: "s", + }); + expect(eventDetailModel("process_flow.submitted", {}, null)).toBeNull(); + }); + + it("uses the summary for stakeholder and other events", () => { + expect(eventDetailModel("stakeholder.added", { user: "u" }, "Ann · Owner")).toEqual({ + kind: "plain", + text: "Ann · Owner", + }); + expect(eventDetailModel("comment.created", { id: "c" }, null)).toBeNull(); + }); +}); + +describe("attributeLabels", () => { + it("labels every field of every section", () => { + const schema = [ + { fields: [{ key: "a" }, { key: "b" }] }, + { fields: [{ key: "c" }] }, + {}, + ]; + expect(attributeLabels(schema, (f) => f.key.toUpperCase())).toEqual({ + a: "A", + b: "B", + c: "C", + }); + expect(attributeLabels(undefined, () => "x")).toEqual({}); + }); +}); diff --git a/frontend/src/features/cards/sections/historyChanges.ts b/frontend/src/features/cards/sections/historyChanges.ts new file mode 100644 index 000000000..ad0b2cd32 --- /dev/null +++ b/frontend/src/features/cards/sections/historyChanges.ts @@ -0,0 +1,376 @@ +/** + * What a card's History tab says about each event: its label, icon and + * colour, the field-level change rows it lists, and the one-line detail for + * an event that carries no field diff. `HistoryTab` renders these; the rules + * live here so each can be pinned on its own. + * + * A `changes` payload must be `{field: {old, new}}`, with `attributes` and + * `lifecycle` carrying the WHOLE old/new dict (CLAUDE.md, *A write path that + * can move cards.updated_at must also persist a card event*). Anything else + * is dropped here, silently — this is the only reader. + */ + +/** What an empty value reads as. */ +export const NO_VALUE = "—"; + +export interface EventMeta { + /** i18n key, `cards` namespace. */ + labelKey: string; + icon: string; + color: string; +} + +export const EVENT_META: Record = { + "card.created": { labelKey: "history.events.created", icon: "add_circle", color: "#4caf50" }, + "card.updated": { labelKey: "history.events.updated", icon: "edit", color: "#1976d2" }, + "card.archived": { labelKey: "history.events.archived", icon: "archive", color: "#ff9800" }, + "card.restored": { labelKey: "history.events.restored", icon: "restore", color: "#4caf50" }, + "card.deleted": { labelKey: "history.events.deleted", icon: "delete", color: "#f44336" }, + "card.approval_status.approve": { + labelKey: "history.events.approved", + icon: "verified", + color: "#4caf50", + }, + "card.approval_status.reject": { + labelKey: "history.events.rejected", + icon: "cancel", + color: "#f44336", + }, + "card.approval_status.reset": { + labelKey: "history.events.resetToDraft", + icon: "restart_alt", + color: "#9e9e9e", + }, + "stakeholder.added": { + labelKey: "history.events.stakeholderAdded", + icon: "person_add", + color: "#1976d2", + }, + "stakeholder.role_changed": { + labelKey: "history.events.stakeholderRoleChanged", + icon: "manage_accounts", + color: "#1976d2", + }, + "stakeholder.removed": { + labelKey: "history.events.stakeholderRemoved", + icon: "person_remove", + color: "#f44336", + }, + "relation.created": { labelKey: "history.events.relationCreated", icon: "link", color: "#1976d2" }, + "relation.updated": { + labelKey: "history.events.relationUpdated", + icon: "sync_alt", + color: "#1976d2", + }, + "relation.deleted": { + labelKey: "history.events.relationDeleted", + icon: "link_off", + color: "#f44336", + }, + "risk.added": { labelKey: "history.events.riskAdded", icon: "report", color: "#ff9800" }, + "risk.updated": { labelKey: "history.events.riskUpdated", icon: "edit_note", color: "#ff9800" }, + "risk.removed": { labelKey: "history.events.riskRemoved", icon: "report_off", color: "#9e9e9e" }, + "document.added": { labelKey: "history.events.documentAdded", icon: "link", color: "#1976d2" }, + "document.updated": { + labelKey: "history.events.documentUpdated", + icon: "edit_note", + color: "#1976d2", + }, + "document.removed": { + labelKey: "history.events.documentRemoved", + icon: "link_off", + color: "#f44336", + }, + "file.uploaded": { + labelKey: "history.events.fileUploaded", + icon: "upload_file", + color: "#1976d2", + }, + "file.replaced": { labelKey: "history.events.fileReplaced", icon: "sync", color: "#1976d2" }, + "file.updated": { labelKey: "history.events.fileUpdated", icon: "edit_note", color: "#1976d2" }, + "file.deleted": { labelKey: "history.events.fileDeleted", icon: "delete", color: "#f44336" }, + "card_logo.updated": { + labelKey: "history.events.cardLogoUpdated", + icon: "image", + color: "#1976d2", + }, + "card_logo.deleted": { + labelKey: "history.events.cardLogoDeleted", + icon: "hide_image", + color: "#f44336", + }, + "comment.created": { labelKey: "history.events.commentCreated", icon: "chat", color: "#1976d2" }, + // Tagging rescores the card, which moves its Modified date — so it belongs + // on the timeline like any other edit (#995). + "tag.added": { labelKey: "history.events.tagAdded", icon: "label", color: "#1976d2" }, + "tag.removed": { labelKey: "history.events.tagRemoved", icon: "label_off", color: "#f44336" }, + // BPM process-flow approval trail. Without these the events render as their + // raw dotted event_type, which makes the approval history of a process + // unreadable on the card — and an unreadable trail is not an audit trail. + "process_flow.submitted": { + labelKey: "history.events.processFlowSubmitted", + icon: "send", + color: "#1976d2", + }, + "process_flow.approved": { + labelKey: "history.events.processFlowApproved", + icon: "verified", + color: "#4caf50", + }, + "process_flow.rejected": { + labelKey: "history.events.processFlowRejected", + icon: "cancel", + color: "#f44336", + }, + "process_flow.withdrawn": { + labelKey: "history.events.processFlowWithdrawn", + icon: "unpublished", + color: "#f44336", + }, +}; + +/** An event type this build has no entry for: its raw type, an info icon, grey. */ +export function eventMeta( + eventType: string, + t: (key: string) => string, +): { label: string; icon: string; color: string } { + const meta = EVENT_META[eventType]; + if (!meta) return { label: eventType, icon: "info", color: "#9e9e9e" }; + return { label: t(meta.labelKey), icon: meta.icon, color: meta.color }; +} + +/** A change value as one line of text; lifecycle phase keys read as their labels. */ +export function fmtVal(val: unknown, phaseLabels: Record): string { + if (val == null || val === "") return NO_VALUE; + if (typeof val === "string") return val; + if (typeof val === "number" || typeof val === "boolean") return String(val); + if (Array.isArray(val)) return val.map((v) => fmtVal(v, phaseLabels)).join(", "); + if (typeof val === "object") { + const entries = Object.entries(val as Record).filter( + ([, v]) => v != null && v !== "", + ); + if (entries.length === 0) return NO_VALUE; + return entries.map(([k, v]) => `${phaseLabels[k] || k}: ${v}`).join(", "); + } + return String(val); +} + +/** + * A change key's label, from the first source that knows it: a built-in + * field, a lifecycle phase, the card type's attribute labels (after stripping + * the `attr_` prefix the survey-apply path writes), else the key itself. + */ +export function resolveFieldLabel( + rawKey: string, + fieldLabels: Record, + phaseLabels: Record, + attrLabels: Record, +): string { + if (fieldLabels[rawKey]) return fieldLabels[rawKey]; + if (phaseLabels[rawKey]) return phaseLabels[rawKey]; + const attrKey = rawKey.startsWith("attr_") ? rawKey.slice(5) : rawKey; + return attrLabels[attrKey] || rawKey; +} + +export interface ChangeRow { + field: string; + oldVal: string; + newVal: string; +} + +export interface ChangeLabels { + fields: Record; + phases: Record; + attributes: Record; + /** Approval status enum → its label, as the approval badge shows it. */ + statuses: Record; +} + +function isDict(value: unknown): boolean { + return typeof value === "object"; +} + +/** + * One row per changed field. `attributes` and `lifecycle` expand into one row + * per key whose value differs; an entry that is not `{old, new}` is skipped. + */ +export function parseChanges(changes: Record, labels: ChangeLabels): ChangeRow[] { + const rows: ChangeRow[] = []; + const label = (key: string) => + resolveFieldLabel(key, labels.fields, labels.phases, labels.attributes); + const fmt = (v: unknown) => fmtVal(v, labels.phases); + for (const [field, change] of Object.entries(changes)) { + if (!change || typeof change !== "object" || !("old" in change) || !("new" in change)) { + continue; + } + const c = change as { old: unknown; new: unknown }; + if ((field === "attributes" || field === "lifecycle") && isDict(c.old) && isDict(c.new)) { + const oldD = (c.old || {}) as Record; + const newD = (c.new || {}) as Record; + for (const key of new Set([...Object.keys(oldD), ...Object.keys(newD)])) { + if (field === "attributes") { + if (JSON.stringify(oldD[key]) !== JSON.stringify(newD[key])) { + rows.push({ field: label(key), oldVal: fmt(oldD[key]), newVal: fmt(newD[key]) }); + } + } else if (oldD[key] !== newD[key]) { + rows.push({ + field: labels.phases[key] || key, + oldVal: fmt(oldD[key]), + newVal: fmt(newD[key]), + }); + } + } + } else if (field === "approval_status") { + // A status this build has not heard of still renders as its raw value. + rows.push({ + field: label(field), + oldVal: labels.statuses[String(c.old)] ?? fmt(c.old), + newVal: labels.statuses[String(c.new)] ?? fmt(c.new), + }); + } else { + rows.push({ field: label(field), oldVal: fmt(c.old), newVal: fmt(c.new) }); + } + } + return rows; +} + +/** + * The labels a change row uses for this event. A document link's `url` / + * `type` and a file's `category` are the resource's own fields, not the + * card's, so they take the Resources dialogs' labels and a card attribute + * that happens to be keyed `type` is never borrowed (#1166). + */ +export function fieldLabelsFor( + eventType: string, + fieldLabels: Record, + resourceLabels: { url: string; type: string; category: string }, +): Record { + if (eventType === "document.updated" || eventType === "file.updated") { + return { ...fieldLabels, ...resourceLabels }; + } + return fieldLabels; +} + +export const RISK_LEVEL_COLOR: Record = { + critical: "#d32f2f", + high: "#f57c00", + medium: "#fbc02d", + low: "#388e3c", +}; + +export type EventDetail = + | { + kind: "relation"; + label: string; + outgoing: boolean; + peerId?: string; + peerName: string; + peerType?: string | null; + } + | { kind: "risk"; reference?: string; link?: string; level?: string; levelColor?: string; title?: string } + | { kind: "link"; text: string; url: string } + | { kind: "processFlow"; revision?: number; reason?: string } + | { kind: "plain"; text: string } + | null; + +function plain(text: string | null): EventDetail { + return text ? { kind: "plain", text } : null; +} + +function fileLine(d: Record): string { + const name = (d.name as string) || ""; + const size = d.size as number | undefined; + return size != null ? `${name} · ${(size / 1024).toFixed(1)} KB` : name; +} + +/** + * The detail line under an event that carries structured context but no + * field diff (relations, risks, documents, files, flow approvals); otherwise + * the event's plain summary, or nothing. + */ +export function eventDetailModel( + eventType: string, + data: Record | undefined, + summary: string | null, +): EventDetail { + if (!data) return plain(summary); + + if (eventType.startsWith("relation.")) { + const peerId = data.peer_id as string | undefined; + return { + kind: "relation", + label: + (data.directional_label as string) || + (data.relation_label as string) || + (data.type as string), + outgoing: ((data.direction as string) || "outgoing") === "outgoing", + peerId, + peerName: (data.peer_name as string) || peerId || "", + peerType: data.peer_type as string | null | undefined, + }; + } + + if (eventType.startsWith("risk.")) { + const level = (data.level as string | undefined)?.toLowerCase(); + return { + kind: "risk", + reference: data.reference as string | undefined, + link: data.link as string | undefined, + level, + levelColor: level ? RISK_LEVEL_COLOR[level] : undefined, + title: data.title as string | undefined, + }; + } + + if ( + eventType === "document.added" || + eventType === "document.updated" || + eventType === "document.removed" + ) { + const text = (data.name as string) || (data.url as string) || summary || ""; + const url = data.url as string | undefined; + if (eventType !== "document.removed" && url) return { kind: "link", text, url }; + return { kind: "plain", text }; + } + + if (eventType === "card_logo.updated" || eventType === "card_logo.deleted") { + // No `changes` payload: image bytes have no business in a diff. + return { kind: "plain", text: summary || "" }; + } + + if (eventType.startsWith("file.")) { + const current = fileLine(data) || summary || ""; + // A replace carries the version it superseded as `previous` (not as + // `changes`: a raw byte count is not a field diff anyone wants to read). + const previous = data.previous as Record | undefined; + if (eventType === "file.replaced" && previous) { + return { kind: "plain", text: `${fileLine(previous)} → ${current}` }; + } + return { kind: "plain", text: current }; + } + + if (eventType.startsWith("process_flow.")) { + // All four flow events carry the revision; only a withdrawal carries a + // reason, rendered in full — an audit trail that hides the justification + // defeats its own purpose. + const revision = data.revision as number | undefined; + const reason = (data.reason as string | undefined)?.trim(); + if (revision == null && !reason) return plain(summary); + return { kind: "processFlow", revision, reason: reason || undefined }; + } + + // Stakeholder events, and any other, ship a clean summary already. + return plain(summary); +} + +/** `{attribute key: label}` for every field of a card type. */ +export function attributeLabels( + fieldsSchema: { fields?: { key: string }[] }[] | undefined, + label: (field: { key: string }) => string, +): Record { + const labels: Record = {}; + for (const section of fieldsSchema ?? []) { + for (const field of section.fields ?? []) labels[field.key] = label(field); + } + return labels; +} diff --git a/frontend/src/features/reference-catalogue/CataloguePage.test.tsx b/frontend/src/features/reference-catalogue/CataloguePage.test.tsx index 812b6d392..1fe8a7eaa 100644 --- a/frontend/src/features/reference-catalogue/CataloguePage.test.tsx +++ b/frontend/src/features/reference-catalogue/CataloguePage.test.tsx @@ -280,6 +280,54 @@ describe("CataloguePage — import", () => { expect(await screen.findByTestId("location")).toHaveTextContent("/inventory?type=BusinessCapability"); }); + it("lists the entries the server refused, with their reasons", async () => { + const reason = 'A card of type BusinessCapability named "Sales" already exists at this level (existing card: ca4d0000-0000-4000-8000-000000000009).'; + mockApi.on("post", "/capability-catalogue/import", (_path, body) => { + const ids = (body as { catalogue_ids: string[] }).catalogue_ids; + const result: ImportResult = { + created: ids.filter((id) => id !== "BC-2").map((id) => ({ catalogue_id: id, card_id: `card-${id}` })), + skipped: [], + relinked: [], + failed: [{ catalogue_id: "BC-2", reason }], + catalogue_version: "2026.4", + }; + return result; + }); + const { user } = renderPage(); + const dialog = await selectAndOpenImport(user); + await user.click(within(dialog).getByRole("button", { name: t("confirmCreate") })); + + const done = await screen.findByRole("dialog", { name: t("importDoneTitle") }); + expect(done).toHaveTextContent(t("importDoneBody", { created: 2, skipped: 0, relinked: 0 })); + expect(done).toHaveTextContent(t("importFailedBody", { count: 1 })); + expect(done).toHaveTextContent(`BC-2 — ${reason}`); + // The catalogue reloads as after any import, so the cards that did land show as existing. + await waitFor(() => expect(mockApi.callsOf("get", "/capability-catalogue?locale=en")).toHaveLength(2)); + }); + + it("renders a result from a backend that sends no failed list", async () => { + mockApi.on("post", "/capability-catalogue/import", (_path, body) => { + const ids = (body as { catalogue_ids: string[] }).catalogue_ids; + // A backend older than 2.158.11: no `failed` key at all. + const result: Omit = { + created: ids.map((id) => ({ catalogue_id: id, card_id: `card-${id}` })), + skipped: [], + relinked: [], + catalogue_version: "2026.4", + }; + return result; + }); + const { user } = renderPage(); + const dialog = await selectAndOpenImport(user); + await user.click(within(dialog).getByRole("button", { name: t("confirmCreate") })); + + const done = await screen.findByRole("dialog", { name: t("importDoneTitle") }); + expect(done).toHaveTextContent(t("importDoneBody", { created: 3, skipped: 0, relinked: 0 })); + // Only the summary alert: no warning block and no list of refused entries. + expect(within(done).getAllByRole("alert")).toHaveLength(1); + expect(within(done).queryAllByRole("list")).toHaveLength(0); + }); + it("closes the summary and forgets it", async () => { const { user } = renderPage(); const dialog = await selectAndOpenImport(user); diff --git a/frontend/src/features/reference-catalogue/CataloguePage.tsx b/frontend/src/features/reference-catalogue/CataloguePage.tsx index 55ab1651c..325de4e44 100644 --- a/frontend/src/features/reference-catalogue/CataloguePage.tsx +++ b/frontend/src/features/reference-catalogue/CataloguePage.tsx @@ -173,10 +173,11 @@ export default function CataloguePage({ config }: Props) { } setImportProgress({ done: 0, total: batches.length }); - const aggregate: ImportResult = { + const aggregate: ImportResult & { failed: NonNullable } = { created: [], skipped: [], relinked: [], + failed: [], catalogue_version: null, auto_relations_created: 0, }; @@ -190,6 +191,8 @@ export default function CataloguePage({ config }: Props) { aggregate.created.push(...r.created); aggregate.skipped.push(...r.skipped); aggregate.relinked.push(...r.relinked); + // A backend older than 2.158.11 sends no `failed` list. + aggregate.failed.push(...(r.failed ?? [])); aggregate.catalogue_version = r.catalogue_version; if (r.auto_relations_created) { aggregate.auto_relations_created = @@ -254,6 +257,7 @@ export default function CataloguePage({ config }: Props) { const v = payload.version; const totalCount = v.node_count ?? v.process_count ?? v.value_stream_count ?? nodes.length; + const failedEntries = importResult?.failed ?? []; const sourceLabel = v.source === "remote" ? t(`cards:${ns}.sourceRemote`, { version: v.bundled_version }) @@ -433,6 +437,23 @@ export default function CataloguePage({ config }: Props) { relinked: importResult.relinked.length, })} + {/* The server refuses an entry whose name another card of the + type already uses at the same level, and reports the entries + beneath a refused parent too; each comes back with its reason. */} + {failedEntries.length > 0 && ( + + {t(`cards:${ns}.importFailedBody`, { count: failedEntries.length })} + + {failedEntries.map((f) => ( +
  • + + {f.catalogue_id} — {f.reason} + +
  • + ))} +
    +
    + )} {/* Per-catalogue services emit `auto_relations_created` — surface it as a secondary line so the user can see how many cross-references landed automatically. */} diff --git a/frontend/src/features/reference-catalogue/types.ts b/frontend/src/features/reference-catalogue/types.ts index bd823a2da..b2e6d1017 100644 --- a/frontend/src/features/reference-catalogue/types.ts +++ b/frontend/src/features/reference-catalogue/types.ts @@ -84,6 +84,10 @@ export interface ImportResult { created: { catalogue_id: string; card_id: string }[]; skipped: { catalogue_id: string; card_id: string; reason: string }[]; relinked: { catalogue_id: string; card_id: string; new_parent_card_id: string }[]; + /** Entries the card write path refused (a name another card already uses at + * the same level) and the entries beneath a refused parent, with the reason + * the server gave. Absent from a backend older than 2.158.11. */ + failed?: { catalogue_id: string; reason: string }[]; catalogue_version: string | null; /** Optional — only the process and value-stream services emit it. */ auto_relations_created?: number; diff --git a/frontend/src/hooks/useCardTabActivity.ts b/frontend/src/hooks/useCardTabActivity.ts index fb322487d..85bb6387e 100644 --- a/frontend/src/hooks/useCardTabActivity.ts +++ b/frontend/src/hooks/useCardTabActivity.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { api } from "@/api/client"; import type { EventEntry } from "@/types"; +import type { CardTabKey } from "@/features/cards/cardTabs"; const STORAGE_KEY = "turbo-ea.cardTabsSeen"; const LRU_CAP = 200; @@ -10,7 +11,8 @@ const HISTORY_PAGE_SIZE = 50; // baseline for tabs the user has never explicitly opened. const FIRST_VISIT_KEY = "__first"; -const EVENT_TAB_MAP: Record = { +// Keys are card tabs (`cardTabs.ts`), so a tab renamed there fails to compile here. +const EVENT_TAB_MAP: Record = { "comment.created": "comments", "stakeholder.added": "stakeholders", "stakeholder.role_changed": "stakeholders", diff --git a/frontend/src/i18n/locales/ar/cards.json b/frontend/src/i18n/locales/ar/cards.json index 9ad72525e..6058194a6 100644 --- a/frontend/src/i18n/locales/ar/cards.json +++ b/frontend/src/i18n/locales/ar/cards.json @@ -490,6 +490,8 @@ "catalogue.confirmCreate": "إنشاء", "catalogue.importDoneTitle": "اكتمل الاستيراد", "catalogue.importDoneBody": "{{created}} تم إنشاؤها، {{skipped}} موجودة بالفعل، {{relinked}} أُعيد ربطها تحت عناصر أصل جديدة.", + "catalogue.importFailedBody_one": "تعذّر استيراد {{count}} مدخل:", + "catalogue.importFailedBody_other": "تعذّر استيراد {{count}} مدخلات:", "catalogue.importingProgress": "جارٍ إنشاء الدفعة {{done}} من {{total}}…", "catalogue.importPartialFailure": "تم استيراد {{succeeded}} من {{total}} عنصرًا قبل الفشل: {{error}}", "catalogue.openInventory": "فتح المخزون", @@ -548,6 +550,8 @@ "processCatalogue.confirmCreate": "إنشاء", "processCatalogue.importDoneTitle": "اكتمل الاستيراد", "processCatalogue.importDoneBody": "{{created}} تم إنشاؤها، {{skipped}} موجودة بالفعل، {{relinked}} أُعيد ربطها تحت عناصر أصل جديدة.", + "processCatalogue.importFailedBody_one": "تعذّر استيراد {{count}} مدخل:", + "processCatalogue.importFailedBody_other": "تعذّر استيراد {{count}} مدخلات:", "processCatalogue.importingProgress": "جارٍ إنشاء الدفعة {{done}} من {{total}}…", "processCatalogue.importPartialFailure": "تم استيراد {{succeeded}} من {{total}} عنصرًا قبل الفشل: {{error}}", "processCatalogue.openInventory": "فتح المخزون", @@ -607,6 +611,8 @@ "valueStreamCatalogue.confirmCreate": "إنشاء", "valueStreamCatalogue.importDoneTitle": "اكتمل الاستيراد", "valueStreamCatalogue.importDoneBody": "{{created}} تم إنشاؤها، {{skipped}} موجودة بالفعل، {{relinked}} أُعيد ربطها تحت عناصر أصل جديدة.", + "valueStreamCatalogue.importFailedBody_one": "تعذّر استيراد {{count}} مدخل:", + "valueStreamCatalogue.importFailedBody_other": "تعذّر استيراد {{count}} مدخلات:", "valueStreamCatalogue.importingProgress": "جارٍ إنشاء الدفعة {{done}} من {{total}}…", "valueStreamCatalogue.importPartialFailure": "تم استيراد {{succeeded}} من {{total}} عنصرًا قبل الفشل: {{error}}", "valueStreamCatalogue.openInventory": "فتح المخزون", diff --git a/frontend/src/i18n/locales/da/cards.json b/frontend/src/i18n/locales/da/cards.json index 660dc7e8c..be0f56eec 100644 --- a/frontend/src/i18n/locales/da/cards.json +++ b/frontend/src/i18n/locales/da/cards.json @@ -490,6 +490,8 @@ "catalogue.confirmCreate": "Opret", "catalogue.importDoneTitle": "Import fuldført", "catalogue.importDoneBody": "{{created}} oprettet, {{skipped}} fandtes allerede, {{relinked}} gen-tilknyttet under nye forældre.", + "catalogue.importFailedBody_one": "{{count}} post kunne ikke importeres:", + "catalogue.importFailedBody_other": "{{count}} poster kunne ikke importeres:", "catalogue.importingProgress": "Opretter batch {{done}} af {{total}}…", "catalogue.importPartialFailure": "Importerede {{succeeded}} af {{total}} elementer før fejl: {{error}}", "catalogue.openInventory": "Åbn lager", @@ -548,6 +550,8 @@ "processCatalogue.confirmCreate": "Opret", "processCatalogue.importDoneTitle": "Import fuldført", "processCatalogue.importDoneBody": "{{created}} oprettet, {{skipped}} fandtes allerede, {{relinked}} gen-tilknyttet under nye forældre.", + "processCatalogue.importFailedBody_one": "{{count}} post kunne ikke importeres:", + "processCatalogue.importFailedBody_other": "{{count}} poster kunne ikke importeres:", "processCatalogue.importingProgress": "Opretter batch {{done}} af {{total}}…", "processCatalogue.importPartialFailure": "Importerede {{succeeded}} af {{total}} elementer før fejl: {{error}}", "processCatalogue.openInventory": "Åbn lager", @@ -607,6 +611,8 @@ "valueStreamCatalogue.confirmCreate": "Opret", "valueStreamCatalogue.importDoneTitle": "Import fuldført", "valueStreamCatalogue.importDoneBody": "{{created}} oprettet, {{skipped}} fandtes allerede, {{relinked}} gen-tilknyttet under nye forældre.", + "valueStreamCatalogue.importFailedBody_one": "{{count}} post kunne ikke importeres:", + "valueStreamCatalogue.importFailedBody_other": "{{count}} poster kunne ikke importeres:", "valueStreamCatalogue.importingProgress": "Opretter batch {{done}} af {{total}}…", "valueStreamCatalogue.importPartialFailure": "Importerede {{succeeded}} af {{total}} elementer før fejl: {{error}}", "valueStreamCatalogue.openInventory": "Åbn lager", diff --git a/frontend/src/i18n/locales/de/cards.json b/frontend/src/i18n/locales/de/cards.json index 202e81381..e514e1113 100644 --- a/frontend/src/i18n/locales/de/cards.json +++ b/frontend/src/i18n/locales/de/cards.json @@ -453,6 +453,8 @@ "catalogue.confirmCreate": "Erstellen", "catalogue.importDoneTitle": "Import abgeschlossen", "catalogue.importDoneBody": "{{created}} erstellt, {{skipped}} bereits vorhanden, {{relinked}} unter neue Eltern verknüpft.", + "catalogue.importFailedBody_one": "{{count}} Eintrag konnte nicht importiert werden:", + "catalogue.importFailedBody_other": "{{count}} Einträge konnten nicht importiert werden:", "catalogue.importingProgress": "Erstelle Stapel {{done}} von {{total}}…", "catalogue.importPartialFailure": "{{succeeded}} von {{total}} Einträgen importiert, bevor ein Fehler auftrat: {{error}}", "catalogue.openInventory": "Bestand öffnen", @@ -511,6 +513,8 @@ "processCatalogue.confirmCreate": "Erstellen", "processCatalogue.importDoneTitle": "Import abgeschlossen", "processCatalogue.importDoneBody": "{{created}} erstellt, {{skipped}} bereits vorhanden, {{relinked}} unter neue Eltern verknüpft.", + "processCatalogue.importFailedBody_one": "{{count}} Eintrag konnte nicht importiert werden:", + "processCatalogue.importFailedBody_other": "{{count}} Einträge konnten nicht importiert werden:", "processCatalogue.importingProgress": "Erstelle Stapel {{done}} von {{total}}…", "processCatalogue.importPartialFailure": "{{succeeded}} von {{total}} Einträgen importiert, bevor ein Fehler auftrat: {{error}}", "processCatalogue.openInventory": "Bestand öffnen", @@ -570,6 +574,8 @@ "valueStreamCatalogue.confirmCreate": "Erstellen", "valueStreamCatalogue.importDoneTitle": "Import abgeschlossen", "valueStreamCatalogue.importDoneBody": "{{created}} erstellt, {{skipped}} bereits vorhanden, {{relinked}} unter neue Eltern verknüpft.", + "valueStreamCatalogue.importFailedBody_one": "{{count}} Eintrag konnte nicht importiert werden:", + "valueStreamCatalogue.importFailedBody_other": "{{count}} Einträge konnten nicht importiert werden:", "valueStreamCatalogue.importingProgress": "Erstelle Stapel {{done}} von {{total}}…", "valueStreamCatalogue.importPartialFailure": "{{succeeded}} von {{total}} Einträgen importiert, bevor ein Fehler auftrat: {{error}}", "valueStreamCatalogue.openInventory": "Bestand öffnen", diff --git a/frontend/src/i18n/locales/en/cards.json b/frontend/src/i18n/locales/en/cards.json index 00c6e4d6c..f83e78f97 100644 --- a/frontend/src/i18n/locales/en/cards.json +++ b/frontend/src/i18n/locales/en/cards.json @@ -490,6 +490,8 @@ "catalogue.confirmCreate": "Create", "catalogue.importDoneTitle": "Import complete", "catalogue.importDoneBody": "{{created}} created, {{skipped}} already existed, {{relinked}} re-linked under new parents.", + "catalogue.importFailedBody_one": "{{count}} entry could not be imported:", + "catalogue.importFailedBody_other": "{{count}} entries could not be imported:", "catalogue.importingProgress": "Creating batch {{done}} of {{total}}…", "catalogue.importPartialFailure": "Imported {{succeeded}} of {{total}} items before failure: {{error}}", "catalogue.openInventory": "Open inventory", @@ -548,6 +550,8 @@ "processCatalogue.confirmCreate": "Create", "processCatalogue.importDoneTitle": "Import complete", "processCatalogue.importDoneBody": "{{created}} created, {{skipped}} already existed, {{relinked}} re-linked under new parents.", + "processCatalogue.importFailedBody_one": "{{count}} entry could not be imported:", + "processCatalogue.importFailedBody_other": "{{count}} entries could not be imported:", "processCatalogue.importingProgress": "Creating batch {{done}} of {{total}}…", "processCatalogue.importPartialFailure": "Imported {{succeeded}} of {{total}} items before failure: {{error}}", "processCatalogue.openInventory": "Open inventory", @@ -607,6 +611,8 @@ "valueStreamCatalogue.confirmCreate": "Create", "valueStreamCatalogue.importDoneTitle": "Import complete", "valueStreamCatalogue.importDoneBody": "{{created}} created, {{skipped}} already existed, {{relinked}} re-linked under new parents.", + "valueStreamCatalogue.importFailedBody_one": "{{count}} entry could not be imported:", + "valueStreamCatalogue.importFailedBody_other": "{{count}} entries could not be imported:", "valueStreamCatalogue.importingProgress": "Creating batch {{done}} of {{total}}…", "valueStreamCatalogue.importPartialFailure": "Imported {{succeeded}} of {{total}} items before failure: {{error}}", "valueStreamCatalogue.openInventory": "Open inventory", diff --git a/frontend/src/i18n/locales/es/cards.json b/frontend/src/i18n/locales/es/cards.json index f0ae3a94b..7d2af4c10 100644 --- a/frontend/src/i18n/locales/es/cards.json +++ b/frontend/src/i18n/locales/es/cards.json @@ -453,6 +453,8 @@ "catalogue.confirmCreate": "Crear", "catalogue.importDoneTitle": "Importación completa", "catalogue.importDoneBody": "{{created}} creadas, {{skipped}} ya existían, {{relinked}} re-vinculadas bajo nuevos padres.", + "catalogue.importFailedBody_one": "{{count}} entrada no se pudo importar:", + "catalogue.importFailedBody_other": "{{count}} entradas no se pudieron importar:", "catalogue.importingProgress": "Creando lote {{done}} de {{total}}…", "catalogue.importPartialFailure": "Se importaron {{succeeded}} de {{total}} elementos antes del fallo: {{error}}", "catalogue.openInventory": "Abrir inventario", @@ -511,6 +513,8 @@ "processCatalogue.confirmCreate": "Crear", "processCatalogue.importDoneTitle": "Importación completa", "processCatalogue.importDoneBody": "{{created}} creadas, {{skipped}} ya existían, {{relinked}} re-vinculadas bajo nuevos padres.", + "processCatalogue.importFailedBody_one": "{{count}} entrada no se pudo importar:", + "processCatalogue.importFailedBody_other": "{{count}} entradas no se pudieron importar:", "processCatalogue.importingProgress": "Creando lote {{done}} de {{total}}…", "processCatalogue.importPartialFailure": "Se importaron {{succeeded}} de {{total}} elementos antes del fallo: {{error}}", "processCatalogue.openInventory": "Abrir inventario", @@ -570,6 +574,8 @@ "valueStreamCatalogue.confirmCreate": "Crear", "valueStreamCatalogue.importDoneTitle": "Importación completa", "valueStreamCatalogue.importDoneBody": "{{created}} creadas, {{skipped}} ya existían, {{relinked}} re-vinculadas bajo nuevos padres.", + "valueStreamCatalogue.importFailedBody_one": "{{count}} entrada no se pudo importar:", + "valueStreamCatalogue.importFailedBody_other": "{{count}} entradas no se pudieron importar:", "valueStreamCatalogue.importingProgress": "Creando lote {{done}} de {{total}}…", "valueStreamCatalogue.importPartialFailure": "Se importaron {{succeeded}} de {{total}} elementos antes del fallo: {{error}}", "valueStreamCatalogue.openInventory": "Abrir inventario", diff --git a/frontend/src/i18n/locales/fr/cards.json b/frontend/src/i18n/locales/fr/cards.json index 021389baa..903bc879d 100644 --- a/frontend/src/i18n/locales/fr/cards.json +++ b/frontend/src/i18n/locales/fr/cards.json @@ -453,6 +453,8 @@ "catalogue.confirmCreate": "Créer", "catalogue.importDoneTitle": "Import terminé", "catalogue.importDoneBody": "{{created}} créées, {{skipped}} déjà existantes, {{relinked}} re-rattachées à de nouveaux parents.", + "catalogue.importFailedBody_one": "{{count}} entrée n'a pas pu être importée :", + "catalogue.importFailedBody_other": "{{count}} entrées n'ont pas pu être importées :", "catalogue.importingProgress": "Création du lot {{done}} sur {{total}}…", "catalogue.importPartialFailure": "{{succeeded}} sur {{total}} éléments importés avant l’échec : {{error}}", "catalogue.openInventory": "Ouvrir l'inventaire", @@ -511,6 +513,8 @@ "processCatalogue.confirmCreate": "Créer", "processCatalogue.importDoneTitle": "Import terminé", "processCatalogue.importDoneBody": "{{created}} créées, {{skipped}} déjà existantes, {{relinked}} re-rattachées à de nouveaux parents.", + "processCatalogue.importFailedBody_one": "{{count}} entrée n'a pas pu être importée :", + "processCatalogue.importFailedBody_other": "{{count}} entrées n'ont pas pu être importées :", "processCatalogue.importingProgress": "Création du lot {{done}} sur {{total}}…", "processCatalogue.importPartialFailure": "{{succeeded}} sur {{total}} éléments importés avant l’échec : {{error}}", "processCatalogue.openInventory": "Ouvrir l'inventaire", @@ -570,6 +574,8 @@ "valueStreamCatalogue.confirmCreate": "Créer", "valueStreamCatalogue.importDoneTitle": "Import terminé", "valueStreamCatalogue.importDoneBody": "{{created}} créées, {{skipped}} déjà existantes, {{relinked}} re-rattachées à de nouveaux parents.", + "valueStreamCatalogue.importFailedBody_one": "{{count}} entrée n'a pas pu être importée :", + "valueStreamCatalogue.importFailedBody_other": "{{count}} entrées n'ont pas pu être importées :", "valueStreamCatalogue.importingProgress": "Création du lot {{done}} sur {{total}}…", "valueStreamCatalogue.importPartialFailure": "{{succeeded}} sur {{total}} éléments importés avant l’échec : {{error}}", "valueStreamCatalogue.openInventory": "Ouvrir l'inventaire", diff --git a/frontend/src/i18n/locales/it/cards.json b/frontend/src/i18n/locales/it/cards.json index 3157bc776..d7f441e29 100644 --- a/frontend/src/i18n/locales/it/cards.json +++ b/frontend/src/i18n/locales/it/cards.json @@ -453,6 +453,8 @@ "catalogue.confirmCreate": "Crea", "catalogue.importDoneTitle": "Importazione completata", "catalogue.importDoneBody": "{{created}} create, {{skipped}} già esistenti, {{relinked}} ri-collegate sotto nuovi genitori.", + "catalogue.importFailedBody_one": "{{count}} voce non è stata importata:", + "catalogue.importFailedBody_other": "{{count}} voci non sono state importate:", "catalogue.importingProgress": "Creazione del batch {{done}} di {{total}}…", "catalogue.importPartialFailure": "Importati {{succeeded}} di {{total}} elementi prima dell’errore: {{error}}", "catalogue.openInventory": "Apri inventario", @@ -511,6 +513,8 @@ "processCatalogue.confirmCreate": "Crea", "processCatalogue.importDoneTitle": "Importazione completata", "processCatalogue.importDoneBody": "{{created}} create, {{skipped}} già esistenti, {{relinked}} ri-collegate sotto nuovi genitori.", + "processCatalogue.importFailedBody_one": "{{count}} voce non è stata importata:", + "processCatalogue.importFailedBody_other": "{{count}} voci non sono state importate:", "processCatalogue.importingProgress": "Creazione del batch {{done}} di {{total}}…", "processCatalogue.importPartialFailure": "Importati {{succeeded}} di {{total}} elementi prima dell’errore: {{error}}", "processCatalogue.openInventory": "Apri inventario", @@ -570,6 +574,8 @@ "valueStreamCatalogue.confirmCreate": "Crea", "valueStreamCatalogue.importDoneTitle": "Importazione completata", "valueStreamCatalogue.importDoneBody": "{{created}} create, {{skipped}} già esistenti, {{relinked}} ri-collegate sotto nuovi genitori.", + "valueStreamCatalogue.importFailedBody_one": "{{count}} voce non è stata importata:", + "valueStreamCatalogue.importFailedBody_other": "{{count}} voci non sono state importate:", "valueStreamCatalogue.importingProgress": "Creazione del batch {{done}} di {{total}}…", "valueStreamCatalogue.importPartialFailure": "Importati {{succeeded}} di {{total}} elementi prima dell’errore: {{error}}", "valueStreamCatalogue.openInventory": "Apri inventario", diff --git a/frontend/src/i18n/locales/pt/cards.json b/frontend/src/i18n/locales/pt/cards.json index 617615bb2..53f1c2bae 100644 --- a/frontend/src/i18n/locales/pt/cards.json +++ b/frontend/src/i18n/locales/pt/cards.json @@ -453,6 +453,8 @@ "catalogue.confirmCreate": "Criar", "catalogue.importDoneTitle": "Importação concluída", "catalogue.importDoneBody": "{{created}} criadas, {{skipped}} já existiam, {{relinked}} re-vinculadas sob novos pais.", + "catalogue.importFailedBody_one": "{{count}} entrada não pôde ser importada:", + "catalogue.importFailedBody_other": "{{count}} entradas não puderam ser importadas:", "catalogue.importingProgress": "Criando lote {{done}} de {{total}}…", "catalogue.importPartialFailure": "{{succeeded}} de {{total}} itens importados antes da falha: {{error}}", "catalogue.openInventory": "Abrir inventário", @@ -511,6 +513,8 @@ "processCatalogue.confirmCreate": "Criar", "processCatalogue.importDoneTitle": "Importação concluída", "processCatalogue.importDoneBody": "{{created}} criadas, {{skipped}} já existiam, {{relinked}} re-vinculadas sob novos pais.", + "processCatalogue.importFailedBody_one": "{{count}} entrada não pôde ser importada:", + "processCatalogue.importFailedBody_other": "{{count}} entradas não puderam ser importadas:", "processCatalogue.importingProgress": "Criando lote {{done}} de {{total}}…", "processCatalogue.importPartialFailure": "{{succeeded}} de {{total}} itens importados antes da falha: {{error}}", "processCatalogue.openInventory": "Abrir inventário", @@ -570,6 +574,8 @@ "valueStreamCatalogue.confirmCreate": "Criar", "valueStreamCatalogue.importDoneTitle": "Importação concluída", "valueStreamCatalogue.importDoneBody": "{{created}} criadas, {{skipped}} já existiam, {{relinked}} re-vinculadas sob novos pais.", + "valueStreamCatalogue.importFailedBody_one": "{{count}} entrada não pôde ser importada:", + "valueStreamCatalogue.importFailedBody_other": "{{count}} entradas não puderam ser importadas:", "valueStreamCatalogue.importingProgress": "Criando lote {{done}} de {{total}}…", "valueStreamCatalogue.importPartialFailure": "{{succeeded}} de {{total}} itens importados antes da falha: {{error}}", "valueStreamCatalogue.openInventory": "Abrir inventário", diff --git a/frontend/src/i18n/locales/ru/cards.json b/frontend/src/i18n/locales/ru/cards.json index ad631b087..c28ebca34 100644 --- a/frontend/src/i18n/locales/ru/cards.json +++ b/frontend/src/i18n/locales/ru/cards.json @@ -453,6 +453,8 @@ "catalogue.confirmCreate": "Создать", "catalogue.importDoneTitle": "Импорт завершён", "catalogue.importDoneBody": "Создано: {{created}}, уже существовало: {{skipped}}, переподключено к новым родителям: {{relinked}}.", + "catalogue.importFailedBody_one": "{{count}} запись не удалось импортировать:", + "catalogue.importFailedBody_other": "{{count}} записей не удалось импортировать:", "catalogue.importingProgress": "Создание партии {{done}} из {{total}}…", "catalogue.importPartialFailure": "Импортировано {{succeeded}} из {{total}} элементов до сбоя: {{error}}", "catalogue.openInventory": "Открыть инвентарь", @@ -511,6 +513,8 @@ "processCatalogue.confirmCreate": "Создать", "processCatalogue.importDoneTitle": "Импорт завершён", "processCatalogue.importDoneBody": "Создано: {{created}}, уже существовало: {{skipped}}, переподключено к новым родителям: {{relinked}}.", + "processCatalogue.importFailedBody_one": "{{count}} запись не удалось импортировать:", + "processCatalogue.importFailedBody_other": "{{count}} записей не удалось импортировать:", "processCatalogue.importingProgress": "Создание партии {{done}} из {{total}}…", "processCatalogue.importPartialFailure": "Импортировано {{succeeded}} из {{total}} элементов до сбоя: {{error}}", "processCatalogue.openInventory": "Открыть инвентарь", @@ -570,6 +574,8 @@ "valueStreamCatalogue.confirmCreate": "Создать", "valueStreamCatalogue.importDoneTitle": "Импорт завершён", "valueStreamCatalogue.importDoneBody": "Создано: {{created}}, уже существовало: {{skipped}}, переподключено к новым родителям: {{relinked}}.", + "valueStreamCatalogue.importFailedBody_one": "{{count}} запись не удалось импортировать:", + "valueStreamCatalogue.importFailedBody_other": "{{count}} записей не удалось импортировать:", "valueStreamCatalogue.importingProgress": "Создание партии {{done}} из {{total}}…", "valueStreamCatalogue.importPartialFailure": "Импортировано {{succeeded}} из {{total}} элементов до сбоя: {{error}}", "valueStreamCatalogue.openInventory": "Открыть инвентарь", diff --git a/frontend/src/i18n/locales/zh/cards.json b/frontend/src/i18n/locales/zh/cards.json index fab2e9b2d..72b8c9e1a 100644 --- a/frontend/src/i18n/locales/zh/cards.json +++ b/frontend/src/i18n/locales/zh/cards.json @@ -453,6 +453,8 @@ "catalogue.confirmCreate": "创建", "catalogue.importDoneTitle": "导入完成", "catalogue.importDoneBody": "已创建 {{created}} 项,已存在 {{skipped}} 项,重新关联到新父级 {{relinked}} 项。", + "catalogue.importFailedBody_one": "{{count}} 个条目无法导入:", + "catalogue.importFailedBody_other": "{{count}} 个条目无法导入:", "catalogue.importingProgress": "正在创建第 {{done}}/{{total}} 批…", "catalogue.importPartialFailure": "在失败前已导入 {{succeeded}}/{{total}} 项:{{error}}", "catalogue.openInventory": "打开清单", @@ -511,6 +513,8 @@ "processCatalogue.confirmCreate": "创建", "processCatalogue.importDoneTitle": "导入完成", "processCatalogue.importDoneBody": "已创建 {{created}} 项,已存在 {{skipped}} 项,重新关联到新父级 {{relinked}} 项。", + "processCatalogue.importFailedBody_one": "{{count}} 个条目无法导入:", + "processCatalogue.importFailedBody_other": "{{count}} 个条目无法导入:", "processCatalogue.importingProgress": "正在创建第 {{done}}/{{total}} 批…", "processCatalogue.importPartialFailure": "在失败前已导入 {{succeeded}}/{{total}} 项:{{error}}", "processCatalogue.openInventory": "打开清单", @@ -570,6 +574,8 @@ "valueStreamCatalogue.confirmCreate": "创建", "valueStreamCatalogue.importDoneTitle": "导入完成", "valueStreamCatalogue.importDoneBody": "已创建 {{created}} 项,已存在 {{skipped}} 项,重新关联到新父级 {{relinked}} 项。", + "valueStreamCatalogue.importFailedBody_one": "{{count}} 个条目无法导入:", + "valueStreamCatalogue.importFailedBody_other": "{{count}} 个条目无法导入:", "valueStreamCatalogue.importingProgress": "正在创建第 {{done}}/{{total}} 批…", "valueStreamCatalogue.importPartialFailure": "在失败前已导入 {{succeeded}}/{{total}} 项:{{error}}", "valueStreamCatalogue.openInventory": "打开清单", diff --git a/scripts/mutation/floors.toml b/scripts/mutation/floors.toml index d2f8d7390..cb8e8136e 100644 --- a/scripts/mutation/floors.toml +++ b/scripts/mutation/floors.toml @@ -99,3 +99,6 @@ mcp = 97 "frontend/src/features/ppm/ppmGanttModel.ts" = 96 # the project Gantt's rows, dates and dependency rules "frontend/src/features/web-portals/portalViewerState.ts" = 97 # which fields and relations a public portal shows "frontend/src/features/ppm/ppmPortfolioModel.ts" = 98 # the PPM portfolio board's filters, bars, totals and export +"frontend/src/features/cards/cardTabs.ts" = 89 # which tabs card detail shows, and in which order +"frontend/src/features/cards/sections/historyChanges.ts" = 90 # how the History tab reads an event +"frontend/src/features/cards/sectionConfig.ts" = 94 # the card page's section order and hidden fields