diff --git a/CHANGELOG.md b/CHANGELOG.md index de2fdd525..93b3bf18a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ 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.33.1] - 2026-07-28 + +### Fixed +- **Relations attached to a card that has children were missing from the Matrix report.** A card with sub-cards was only a heading spanning them, with no row or column of its own, so anything linked to the card itself had nowhere to appear — the card showed on the axis, the intersection stayed blank, and the relation was still counted in the totals. In the demo dataset that hid 17 of the 25 Organization–Application relations. Such a card now gets its own line labelled «(itself)» under its heading, and a collapsed group counts its own relations along with its children's. +- **«Hide unrelated cards» could hide a card that was related.** A card whose own relations were its only ones — none on its sub-cards — was dropped by the very toggle meant to keep it. +- **The Matrix report stopped showing relations whose type is not the one the metamodel declares for those two card types.** Relations left behind by a renamed relation type, brought in by an import, or declared for another pair were silently dropped, and a pair the metamodel says nothing about showed an empty grid. What belongs in the grid is decided by the cards a relation connects, as it was before 2.33.0. + +## [2.33.0] - 2026-07-28 + +### Added +- **The Matrix report can show what a relation means, not just that one exists.** Two new cell displays put the relation's own values in the grid: compact colour-coded letters in the dense view, or the value names in full in a widened one, both with a legend. What appears comes from the attributes your relation types declare, in your language — CRUD flags read C R U D, an ownership relation shows Owner and User, and a value you add in the metamodel appears with no further setup. +- **The Matrix report can be filtered by relation.** A filter bar narrows the grid to a relation type, a direction (whether the row card is the source or the target), or particular attribute values — including relations where a value was never set. Cards that no longer match empty out, so the existing hide-toggle leaves only the ones that do. +- **The Matrix report points at coverage gaps.** Two tiles count the cards on each axis with no relation at all, and a Show-only-gaps view reduces the grid to exactly those — the capabilities nobody supports, the data objects nobody maintains. +- **Matrix report usability.** Find-row and find-column search, a button to swap the two axes, relation details in the cell popover, and tooltips on the column headers. +- **The Matrix report exports to Excel.** Two sheets: the grid as it appears on screen, and one row per relation with its values spread across columns. + +### Fixed +- **Boolean attributes on a relation could never be set.** The relation attribute editor only rendered dropdown values, so any flag a relation type declared — the built-in Create / Read / Update / Delete pairs among them — was invisible and unusable. Flags now render as checkboxes and appear as badges on the relation. +- **The Matrix report under-reported its relation count.** Several relations between the same two cards were counted as one, so a cell could never show more than a single link and the totals were low. The count on a same-type matrix also included each card's own diagonal. +- **Three Matrix report labels showed a raw translation key** instead of the translated text. + ## [2.32.1] - 2026-07-27 ### Fixed diff --git a/VERSION b/VERSION index 7780cec29..ba13d3caf 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.32.1 +2.33.1 diff --git a/backend/app/api/v1/reports.py b/backend/app/api/v1/reports.py index f9f6956cc..110844722 100644 --- a/backend/app/api/v1/reports.py +++ b/backend/app/api/v1/reports.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import json import logging import re import uuid @@ -8,9 +9,9 @@ import httpx from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy import case, func, select +from sqlalchemy import and_, case, func, not_, or_, select from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload +from sqlalchemy.orm import aliased, selectinload from app.api.deps import get_current_user from app.database import get_db @@ -1144,50 +1145,244 @@ async def app_portfolio( } +#: Hard ceiling on the number of relation edges the matrix will serialise. A +#: grid past this point is unreadable anyway; the response flags the cut so the +#: UI can tell the user to narrow the filter rather than quietly lying. +MATRIX_MAX_EDGES = 200_000 + +#: Sentinel meaning "this attribute has no value", mirroring EMPTY_FILTER_KEY in +#: ``frontend/src/components/FilterSelect.tsx``. +MATRIX_EMPTY_FILTER = "__empty__" + + +def _parse_matrix_attr_filters( + raw: list[str], pair_schemas: dict[str, list[dict]] +) -> dict[tuple[str, str], tuple[str, set[str]]]: + """Parse ``attr=.:`` filters. + + Returns ``{(relation_type_key, field_key): (field_type, {values})}``. Values + sharing a key OR together; different keys AND together (applied by the + caller). The relation-type prefix is required because the same field key + legitimately appears on several relation types with different option sets. + + Nothing here knows any attribute by name — the field and its type are looked + up in the relation type's own ``attributes_schema``. A filter naming an + unknown relation type or field is an error, never a silent no-op: a typo + that quietly widens the result set is worse than a 400. + """ + parsed: dict[tuple[str, str], tuple[str, set[str]]] = {} + for item in raw: + spec, sep, value = item.partition(":") + rt_key, dot, field_key = spec.partition(".") + if not sep or not dot or not rt_key or not field_key: + raise HTTPException( + status_code=400, + detail=f"Invalid attribute filter {item!r}; expected relationType.field:value", + ) + schema = pair_schemas.get(rt_key) + if schema is None: + raise HTTPException( + status_code=400, + detail=f"Attribute filter {item!r} names a relation type not valid for these axes", + ) + field = next((f for f in schema if f.get("key") == field_key), None) + if field is None: + raise HTTPException( + status_code=400, + detail=f"Attribute filter {item!r} names a field not declared on {rt_key}", + ) + entry = parsed.setdefault((rt_key, field_key), (field.get("type") or "text", set())) + entry[1].add(value) + return parsed + + +def _matrix_attr_clause(field_key: str, field_type: str, value: str): + """One JSONB predicate for a single attribute filter value.""" + if value == MATRIX_EMPTY_FILTER: + return or_( + Relation.attributes.is_(None), + not_(Relation.attributes.has_key(field_key)), # noqa: W601 — JSONB ? operator + Relation.attributes[field_key].astext.is_(None), + ) + if field_type == "boolean": + return Relation.attributes.contains({field_key: value == "true"}) + if field_type == "number": + try: + return Relation.attributes.contains({field_key: float(value)}) + except ValueError: + return Relation.attributes[field_key].astext == value + return Relation.attributes.contains({field_key: value}) + + @router.get("/matrix") async def matrix( db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), row_type: str = Query("Application"), col_type: str = Query("BusinessCapability"), + relation_types: str | None = Query( + None, description="Comma-separated relation-type keys to restrict the grid to" + ), + attr: list[str] = Query( + default_factory=list, + description="Attribute filter, `relationTypeKey.fieldKey:value`. Repeatable.", + ), + direction: str = Query("any", pattern="^(any|forward|reverse)$"), ): - """Matrix report: cross-reference grid.""" + """Matrix report: cross-reference grid between two card types. + + Each intersection carries the relations behind it — their relation type, + their orientation relative to the row axis, and their attribute values — so + the grid can show what the link *means*, not merely that one exists. + + ``direction`` is structural: ``forward`` keeps relations whose source is the + row card, ``reverse`` those whose target is. Semantic notions of direction + that a metamodel may express as a relation attribute are just attribute + values, filtered through ``attr`` like any other. + + Only the *edges* are filtered server-side; the row and column card lists are + always complete. Pruning cards here would break their ``parent_id`` chains + and silently flatten the hierarchy the client rebuilds — the client already + hides cards with no surviving edge, and can do so without a refetch. + """ await PermissionService.require_permission(db, user, "reports.ea_dashboard") - rows_result = await db.execute( - select(Card).where(Card.type == row_type, Card.status == "ACTIVE").order_by(Card.name) + + known_types = set( + ( + await db.execute(select(CardType.key).where(CardType.key.in_([row_type, col_type]))) + ).scalars() ) - rows = rows_result.scalars().all() + for key in (row_type, col_type): + if key not in known_types: + raise HTTPException(status_code=400, detail=f"Unknown card type {key!r}") + + # The whole relation-type table: small, and needed twice over. `declared` + # is the subset the metamodel says connects these two axes — at most two, + # one per orientation — which is what the attribute filters are validated + # against. `all_schemas` interprets the attributes of whatever type a + # relation actually carries, which is not always a declared one. + rt_rows = ( + await db.execute( + select( + RelationType.key, + RelationType.attributes_schema, + RelationType.source_type_key, + RelationType.target_type_key, + ).order_by(RelationType.sort_order, RelationType.key) + ) + ).all() + all_schemas = {key: (schema or []) for key, schema, _, _ in rt_rows} + declared_keys = [ + key + for key, _, src, tgt in rt_rows + if (src == row_type and tgt == col_type) or (src == col_type and tgt == row_type) + ] + pair_schemas = {key: all_schemas[key] for key in declared_keys} + + # An explicit filter is checked against the real relation-type keys rather + # than against the ones declared for this pair: a relation whose type was + # declared elsewhere, renamed, or imported still connects these two cards + # and still belongs in the grid. Only a key that exists nowhere is a typo. + requested_types: set[str] | None = None + if relation_types is not None: + requested_types = {k.strip() for k in relation_types.split(",") if k.strip()} + unknown = requested_types - set(all_schemas) + if unknown: + raise HTTPException( + status_code=400, detail=f"Unknown relation type(s): {sorted(unknown)}" + ) - cols_result = await db.execute( - select(Card).where(Card.type == col_type, Card.status == "ACTIVE").order_by(Card.name) + attr_filters = _parse_matrix_attr_filters(attr, pair_schemas) + + # Column-level selects: only id / name / parent_id are ever used, so there + # is no reason to hydrate whole Card rows for both axes. + card_cols = select(Card.id, Card.name, Card.parent_id).where(Card.status == "ACTIVE") + rows = (await db.execute(card_cols.where(Card.type == row_type).order_by(Card.name))).all() + cols = ( + rows + if col_type == row_type + else (await db.execute(card_cols.where(Card.type == col_type).order_by(Card.name))).all() ) - cols = cols_result.scalars().all() - # Get all relations between these types - row_ids = [card.id for card in rows] - col_ids = [card.id for card in cols] - rels_result = await db.execute( - select(Relation).where( - ((Relation.source_id.in_(row_ids)) & (Relation.target_id.in_(col_ids))) - | ((Relation.source_id.in_(col_ids)) & (Relation.target_id.in_(row_ids))) + edges: list[tuple[str, str, str, str, dict]] = [] + if rows and cols: + # Matched on the endpoint card types, not on the relation type: what + # makes a relation belong in this grid is that it connects one card of + # each axis. Restricting to the types the metamodel declares for the + # pair would silently drop relations left behind by a renamed type, or + # brought in by an import, and would empty the grid entirely for a pair + # the metamodel says nothing about. + src_card, tgt_card = aliased(Card), aliased(Card) + stmt = ( + select(Relation.type, Relation.source_id, Relation.target_id, Relation.attributes) + .join(src_card, Relation.source_id == src_card.id) + .join(tgt_card, Relation.target_id == tgt_card.id) + .where( + src_card.status == "ACTIVE", + tgt_card.status == "ACTIVE", + or_( + and_(src_card.type == row_type, tgt_card.type == col_type), + and_(src_card.type == col_type, tgt_card.type == row_type), + ), + ) ) + if requested_types is not None: + stmt = stmt.where(Relation.type.in_(sorted(requested_types))) + for (rt_key, field_key), (field_type, values) in attr_filters.items(): + clauses = [_matrix_attr_clause(field_key, field_type, v) for v in sorted(values)] + # Scoped to its own relation type so a filter on one type never + # silently discards relations of the other type on these axes. + stmt = stmt.where(or_(Relation.type != rt_key, or_(*clauses))) + + row_id_set = {str(row.id) for row in rows} + for rel_type, source_id, target_id, attributes in (await db.execute(stmt)).all(): + sid, tid = str(source_id), str(target_id) + if sid in row_id_set: + row_id, col_id, orientation = sid, tid, "f" + else: + row_id, col_id, orientation = tid, sid, "r" + if direction == "forward" and orientation != "f": + continue + if direction == "reverse" and orientation != "r": + continue + # Attributes are read through the relation's own schema, whichever + # type it is; a type the metamodel no longer knows contributes none. + declared = {f.get("key") for f in all_schemas.get(rel_type, [])} + kept = {k: v for k, v in (attributes or {}).items() if k in declared and v is not None} + edges.append((row_id, col_id, rel_type, orientation, kept)) + + truncated = len(edges) > MATRIX_MAX_EDGES + if truncated: + edges = edges[:MATRIX_MAX_EDGES] + + # Intern the distinct attribute bags: a CRUD-style relation has at most a + # handful of combinations across thousands of edges, so referencing them by + # index keeps the payload proportional to the *variety*, not the volume. + attr_sets: list[dict] = [{}] + attr_index: dict[str, int] = {"{}": 0} + + # Index space for the edges: the types the metamodel declares for this pair, + # then any other type the data actually turned up, so every edge can name + # its type even when the metamodel has moved on. + rt_keys = declared_keys + sorted( + {rel_type for _, _, rel_type, _, _ in edges} - set(declared_keys) ) - rels = rels_result.scalars().all() + rt_index = {key: i for i, key in enumerate(rt_keys)} - # Build intersection set – normalise to (row_id, col_id) direction - row_id_set = {str(card.id) for card in rows} - intersections = set() - for r in rels: - sid, tid = str(r.source_id), str(r.target_id) - if sid in row_id_set: - intersections.add((sid, tid)) - else: - intersections.add((tid, sid)) - - # When same type on both axes, add self-relations on the diagonal + cells: dict[tuple[str, str], list[list]] = {} if row_type == col_type: - for card in rows: - intersections.add((str(card.id), str(card.id))) + # Self-matrix: the diagonal is structural, not a relationship, so it is + # present but carries no edges. + for row in rows: + cells[(str(row.id), str(row.id))] = [] + for row_id, col_id, rel_type, orientation, kept in edges: + serialised = json.dumps(kept, sort_keys=True, default=str) + idx = attr_index.get(serialised) + if idx is None: + idx = len(attr_sets) + attr_index[serialised] = idx + attr_sets.append(kept) + cells.setdefault((row_id, col_id), []).append([rt_index[rel_type], orientation, idx]) return { "rows": [ @@ -1206,7 +1401,12 @@ async def matrix( } for c in cols ], - "intersections": [{"row_id": r, "col_id": c} for r, c in intersections], + "relation_types": rt_keys, + "attr_sets": attr_sets, + "intersections": [ + {"row_id": row_id, "col_id": col_id, "e": e} for (row_id, col_id), e in cells.items() + ], + "truncated": truncated, } diff --git a/backend/app/services/seed_demo.py b/backend/app/services/seed_demo.py index 33fad49b3..92143e222 100644 --- a/backend/app/services/seed_demo.py +++ b/backend/app/services/seed_demo.py @@ -3519,45 +3519,45 @@ def _rel(type_: str, src: str, tgt: str, attrs: dict | None = None, desc: str | {"crudCreate": False, "crudRead": True, "crudUpdate": False, "crudDelete": False}, ), # ── Application → Interface (relAppToInterface) ────────────── - _rel("relAppToInterface", "app_sap_s4", "if_sap_tc_bom"), - _rel("relAppToInterface", "app_teamcenter", "if_sap_tc_bom"), - _rel("relAppToInterface", "app_sap_s4", "if_sap_mes"), - _rel("relAppToInterface", "app_opcenter", "if_sap_mes"), - _rel("relAppToInterface", "app_azure_iot", "if_iot_kafka"), - _rel("relAppToInterface", "app_kafka", "if_iot_kafka"), - _rel("relAppToInterface", "app_kafka", "if_kafka_ts"), - _rel("relAppToInterface", "app_timescale", "if_kafka_ts"), - _rel("relAppToInterface", "app_sf_sales", "if_sf_sap_order"), - _rel("relAppToInterface", "app_sap_s4", "if_sf_sap_order"), - _rel("relAppToInterface", "app_nexaportal", "if_portal_iot"), - _rel("relAppToInterface", "app_nexacloud", "if_portal_iot"), - _rel("relAppToInterface", "app_nexamobile", "if_mobile_iot"), - _rel("relAppToInterface", "app_nexacloud", "if_mobile_iot"), - _rel("relAppToInterface", "app_sap_s4", "if_sap_pbi"), - _rel("relAppToInterface", "app_powerbi", "if_sap_pbi"), - _rel("relAppToInterface", "app_azure_ad", "if_ad_okta"), - _rel("relAppToInterface", "app_okta", "if_ad_okta"), - _rel("relAppToInterface", "app_opcenter", "if_mes_quality"), - _rel("relAppToInterface", "app_quality_insp", "if_mes_quality"), - _rel("relAppToInterface", "app_teamcenter", "if_plm_erp_bom"), - _rel("relAppToInterface", "app_sap_s4", "if_plm_erp_bom"), - _rel("relAppToInterface", "app_nexacloud", "if_iot_grafana"), - _rel("relAppToInterface", "app_grafana", "if_iot_grafana"), - _rel("relAppToInterface", "app_servicenow", "if_sn_teams"), - _rel("relAppToInterface", "app_teams", "if_sn_teams"), - _rel("relAppToInterface", "app_splunk", "if_all_splunk"), - _rel("relAppToInterface", "app_github_actions", "if_gh_sonar"), - _rel("relAppToInterface", "app_sonarqube", "if_gh_sonar"), - _rel("relAppToInterface", "app_nexacloud", "if_iot_anomaly"), - _rel("relAppToInterface", "app_anomaly_ai", "if_iot_anomaly"), - _rel("relAppToInterface", "app_sap_s4", "if_sap_snow"), - _rel("relAppToInterface", "app_snowflake", "if_sap_snow"), - _rel("relAppToInterface", "app_hubspot", "if_hub_sf"), - _rel("relAppToInterface", "app_sf_sales", "if_hub_sf"), - _rel("relAppToInterface", "app_docusign", "if_docu_sf"), - _rel("relAppToInterface", "app_sf_sales", "if_docu_sf"), - _rel("relAppToInterface", "app_coupa", "if_coupa_sap"), - _rel("relAppToInterface", "app_sap_s4", "if_coupa_sap"), + _rel("relAppToInterface", "app_sap_s4", "if_sap_tc_bom", {"flowDirection": "forward"}), + _rel("relAppToInterface", "app_teamcenter", "if_sap_tc_bom", {"flowDirection": "reverse"}), + _rel("relAppToInterface", "app_sap_s4", "if_sap_mes", {"flowDirection": "forward"}), + _rel("relAppToInterface", "app_opcenter", "if_sap_mes", {"flowDirection": "reverse"}), + _rel("relAppToInterface", "app_azure_iot", "if_iot_kafka", {"flowDirection": "forward"}), + _rel("relAppToInterface", "app_kafka", "if_iot_kafka", {"flowDirection": "reverse"}), + _rel("relAppToInterface", "app_kafka", "if_kafka_ts", {"flowDirection": "forward"}), + _rel("relAppToInterface", "app_timescale", "if_kafka_ts", {"flowDirection": "reverse"}), + _rel("relAppToInterface", "app_sf_sales", "if_sf_sap_order", {"flowDirection": "forward"}), + _rel("relAppToInterface", "app_sap_s4", "if_sf_sap_order", {"flowDirection": "reverse"}), + _rel("relAppToInterface", "app_nexaportal", "if_portal_iot", {"flowDirection": "forward"}), + _rel("relAppToInterface", "app_nexacloud", "if_portal_iot", {"flowDirection": "reverse"}), + _rel("relAppToInterface", "app_nexamobile", "if_mobile_iot", {"flowDirection": "forward"}), + _rel("relAppToInterface", "app_nexacloud", "if_mobile_iot", {"flowDirection": "reverse"}), + _rel("relAppToInterface", "app_sap_s4", "if_sap_pbi", {"flowDirection": "forward"}), + _rel("relAppToInterface", "app_powerbi", "if_sap_pbi", {"flowDirection": "reverse"}), + _rel("relAppToInterface", "app_azure_ad", "if_ad_okta", {"flowDirection": "forward"}), + _rel("relAppToInterface", "app_okta", "if_ad_okta", {"flowDirection": "reverse"}), + _rel("relAppToInterface", "app_opcenter", "if_mes_quality", {"flowDirection": "forward"}), + _rel("relAppToInterface", "app_quality_insp", "if_mes_quality", {"flowDirection": "reverse"}), + _rel("relAppToInterface", "app_teamcenter", "if_plm_erp_bom", {"flowDirection": "forward"}), + _rel("relAppToInterface", "app_sap_s4", "if_plm_erp_bom", {"flowDirection": "reverse"}), + _rel("relAppToInterface", "app_nexacloud", "if_iot_grafana", {"flowDirection": "forward"}), + _rel("relAppToInterface", "app_grafana", "if_iot_grafana", {"flowDirection": "reverse"}), + _rel("relAppToInterface", "app_servicenow", "if_sn_teams", {"flowDirection": "forward"}), + _rel("relAppToInterface", "app_teams", "if_sn_teams", {"flowDirection": "reverse"}), + _rel("relAppToInterface", "app_splunk", "if_all_splunk", {"flowDirection": "forward"}), + _rel("relAppToInterface", "app_github_actions", "if_gh_sonar", {"flowDirection": "forward"}), + _rel("relAppToInterface", "app_sonarqube", "if_gh_sonar", {"flowDirection": "reverse"}), + _rel("relAppToInterface", "app_nexacloud", "if_iot_anomaly", {"flowDirection": "forward"}), + _rel("relAppToInterface", "app_anomaly_ai", "if_iot_anomaly", {"flowDirection": "reverse"}), + _rel("relAppToInterface", "app_sap_s4", "if_sap_snow", {"flowDirection": "forward"}), + _rel("relAppToInterface", "app_snowflake", "if_sap_snow", {"flowDirection": "reverse"}), + _rel("relAppToInterface", "app_hubspot", "if_hub_sf", {"flowDirection": "forward"}), + _rel("relAppToInterface", "app_sf_sales", "if_hub_sf", {"flowDirection": "reverse"}), + _rel("relAppToInterface", "app_docusign", "if_docu_sf", {"flowDirection": "forward"}), + _rel("relAppToInterface", "app_sf_sales", "if_docu_sf", {"flowDirection": "reverse"}), + _rel("relAppToInterface", "app_coupa", "if_coupa_sap", {"flowDirection": "forward"}), + _rel("relAppToInterface", "app_sap_s4", "if_coupa_sap", {"flowDirection": "reverse"}), # ── Application → Business Context (relAppToBizCtx) ────────── _rel("relAppToBizCtx", "app_teamcenter", "bctx_npi"), _rel("relAppToBizCtx", "app_teamcenter", "bctx_design_review"), diff --git a/backend/tests/api/test_reports_extended.py b/backend/tests/api/test_reports_extended.py index 0b9800b03..91b2841a0 100644 --- a/backend/tests/api/test_reports_extended.py +++ b/backend/tests/api/test_reports_extended.py @@ -416,6 +416,498 @@ async def test_matrix_permission_denied(self, client, db, env): ) assert resp.status_code == 403 + async def test_matrix_unknown_card_type(self, client, db, env): + """An axis naming a card type that does not exist is a 400, not an empty grid.""" + resp = await client.get( + "/api/v1/reports/matrix", + params={"row_type": "Application", "col_type": "NoSuchType"}, + headers=auth_headers(env["admin"]), + ) + assert resp.status_code == 400 + + +# --------------------------------------------------------------------------- +# Matrix — relation semantics (type, direction, attributes) and filtering +# --------------------------------------------------------------------------- + + +@pytest.fixture +async def matrix_env(db, env): + """Adds attributed relation types and a small App x DataObject landscape. + + The attribute keys here are deliberately arbitrary: the endpoint reads them + out of each relation type's ``attributes_schema``, so these tests would pass + just the same with any other admin-defined dimension. + """ + admin = env["admin"] + await create_card_type(db, key="DataObject", label="Data Object") + + await create_relation_type( + db, + key="app_to_do", + label="uses", + source_type_key="Application", + target_type_key="DataObject", + attributes_schema=[ + {"key": "flagA", "label": "Flag A", "type": "boolean"}, + {"key": "flagB", "label": "Flag B", "type": "boolean"}, + { + "key": "usage", + "label": "Usage", + "type": "single_select", + "options": [{"key": "owner", "label": "Owner"}, {"key": "user", "label": "User"}], + }, + ], + ) + # The reverse ordered pair is a distinct relation type — the metamodel + # allows one per *ordered* pair, so an axis pair can involve two. + await create_relation_type( + db, + key="do_to_app", + label="feeds", + source_type_key="DataObject", + target_type_key="Application", + ) + + app_a = await create_card(db, card_type="Application", name="App A", user_id=admin.id) + app_b = await create_card(db, card_type="Application", name="App B", user_id=admin.id) + do_x = await create_card(db, card_type="DataObject", name="DO X", user_id=admin.id) + do_y = await create_card(db, card_type="DataObject", name="DO Y", user_id=admin.id) + return {**env, "app_a": app_a, "app_b": app_b, "do_x": do_x, "do_y": do_y} + + +async def _matrix(client, user, **params): + resp = await client.get( + "/api/v1/reports/matrix", + params={"row_type": "Application", "col_type": "DataObject", **params}, + headers=auth_headers(user), + ) + assert resp.status_code == 200, resp.text + return resp.json() + + +def _cell(data, row_id, col_id): + for ix in data["intersections"]: + if ix["row_id"] == str(row_id) and ix["col_id"] == str(col_id): + return ix + raise AssertionError(f"no cell for {row_id} x {col_id}") + + +class TestMatrixRelationSemantics: + async def test_edge_carries_relation_type_direction_and_attributes( + self, client, db, matrix_env + ): + e = matrix_env + await create_relation( + db, + type_key="app_to_do", + source_id=e["app_a"].id, + target_id=e["do_x"].id, + attributes={"flagA": True, "usage": "owner"}, + ) + + data = await _matrix(client, e["admin"]) + + assert data["relation_types"] == ["app_to_do", "do_to_app"] + edges = _cell(data, e["app_a"].id, e["do_x"].id)["e"] + assert len(edges) == 1 + rt_idx, orientation, attr_idx = edges[0] + assert data["relation_types"][rt_idx] == "app_to_do" + assert orientation == "f" # the row card is the relation's source + assert data["attr_sets"][attr_idx] == {"flagA": True, "usage": "owner"} + + async def test_reverse_orientation_when_row_card_is_the_target(self, client, db, matrix_env): + e = matrix_env + await create_relation( + db, type_key="do_to_app", source_id=e["do_x"].id, target_id=e["app_a"].id + ) + + data = await _matrix(client, e["admin"]) + + _, orientation, attr_idx = _cell(data, e["app_a"].id, e["do_x"].id)["e"][0] + assert orientation == "r" + assert data["attr_sets"][attr_idx] == {} + + async def test_counts_every_relation_between_the_same_pair(self, client, db, matrix_env): + """Two relation types linking the same two cards are two edges, not one. + + The endpoint used to collapse intersections into a set, so a cell could + never report more than one relation. + """ + e = matrix_env + await create_relation( + db, type_key="app_to_do", source_id=e["app_a"].id, target_id=e["do_x"].id + ) + await create_relation( + db, type_key="do_to_app", source_id=e["do_x"].id, target_id=e["app_a"].id + ) + + data = await _matrix(client, e["admin"]) + + assert len(_cell(data, e["app_a"].id, e["do_x"].id)["e"]) == 2 + + async def test_attribute_sets_are_interned(self, client, db, matrix_env): + """Repeated attribute bags are stored once and referenced by index.""" + e = matrix_env + for target in (e["do_x"].id, e["do_y"].id): + await create_relation( + db, + type_key="app_to_do", + source_id=e["app_a"].id, + target_id=target, + attributes={"usage": "owner"}, + ) + await create_relation( + db, + type_key="app_to_do", + source_id=e["app_b"].id, + target_id=e["do_x"].id, + attributes={"usage": "owner"}, + ) + + data = await _matrix(client, e["admin"]) + + # index 0 is the always-present empty bag, index 1 the shared one + assert data["attr_sets"] == [{}, {"usage": "owner"}] + indices = {ix["e"][0][2] for ix in data["intersections"] if ix["e"]} + assert indices == {1} + + async def test_attributes_not_declared_in_the_schema_are_dropped(self, client, db, matrix_env): + e = matrix_env + await create_relation( + db, + type_key="app_to_do", + source_id=e["app_a"].id, + target_id=e["do_x"].id, + attributes={"usage": "user", "strayKey": "leftover"}, + ) + + data = await _matrix(client, e["admin"]) + + attr_idx = _cell(data, e["app_a"].id, e["do_x"].id)["e"][0][2] + assert data["attr_sets"][attr_idx] == {"usage": "user"} + + async def test_self_matrix_diagonal_carries_no_edges(self, client, db, env): + """The diagonal of a same-type matrix is structure, not a relationship.""" + admin = env["admin"] + solo = await create_card(db, card_type="Application", name="Solo", user_id=admin.id) + + resp = await client.get( + "/api/v1/reports/matrix", + params={"row_type": "Application", "col_type": "Application"}, + headers=auth_headers(admin), + ) + data = resp.json() + + assert _cell(data, solo.id, solo.id)["e"] == [] + + +class TestMatrixFilters: + async def test_filter_by_relation_type(self, client, db, matrix_env): + e = matrix_env + await create_relation( + db, type_key="app_to_do", source_id=e["app_a"].id, target_id=e["do_x"].id + ) + await create_relation( + db, type_key="do_to_app", source_id=e["do_y"].id, target_id=e["app_b"].id + ) + + data = await _matrix(client, e["admin"], relation_types="app_to_do") + + # `relation_types` is the index space the edges refer into, not the + # filtered set — it always lists the types declared for the pair. + assert data["relation_types"] == ["app_to_do", "do_to_app"] + edges = _cell(data, e["app_a"].id, e["do_x"].id)["e"] + assert len(edges) == 1 + assert data["relation_types"][edges[0][0]] == "app_to_do" + assert all( + not ix["e"] for ix in data["intersections"] if ix["row_id"] == str(e["app_b"].id) + ) + + async def test_filter_by_select_value(self, client, db, matrix_env): + e = matrix_env + await create_relation( + db, + type_key="app_to_do", + source_id=e["app_a"].id, + target_id=e["do_x"].id, + attributes={"usage": "owner"}, + ) + await create_relation( + db, + type_key="app_to_do", + source_id=e["app_b"].id, + target_id=e["do_y"].id, + attributes={"usage": "user"}, + ) + + data = await _matrix(client, e["admin"], attr="app_to_do.usage:owner") + + rows_with_edges = {ix["row_id"] for ix in data["intersections"] if ix["e"]} + assert rows_with_edges == {str(e["app_a"].id)} + + async def test_filter_by_several_values_of_one_field_ors_them(self, client, db, matrix_env): + e = matrix_env + await create_relation( + db, + type_key="app_to_do", + source_id=e["app_a"].id, + target_id=e["do_x"].id, + attributes={"usage": "owner"}, + ) + await create_relation( + db, + type_key="app_to_do", + source_id=e["app_b"].id, + target_id=e["do_y"].id, + attributes={"usage": "user"}, + ) + + resp = await client.get( + "/api/v1/reports/matrix", + params=[ + ("row_type", "Application"), + ("col_type", "DataObject"), + ("attr", "app_to_do.usage:owner"), + ("attr", "app_to_do.usage:user"), + ], + headers=auth_headers(e["admin"]), + ) + data = resp.json() + + rows_with_edges = {ix["row_id"] for ix in data["intersections"] if ix["e"]} + assert rows_with_edges == {str(e["app_a"].id), str(e["app_b"].id)} + + async def test_filter_by_boolean_value(self, client, db, matrix_env): + e = matrix_env + await create_relation( + db, + type_key="app_to_do", + source_id=e["app_a"].id, + target_id=e["do_x"].id, + attributes={"flagA": True}, + ) + await create_relation( + db, + type_key="app_to_do", + source_id=e["app_b"].id, + target_id=e["do_x"].id, + attributes={"flagA": False}, + ) + + data = await _matrix(client, e["admin"], attr="app_to_do.flagA:true") + + rows_with_edges = {ix["row_id"] for ix in data["intersections"] if ix["e"]} + assert rows_with_edges == {str(e["app_a"].id)} + + async def test_filter_on_unset_value(self, client, db, matrix_env): + e = matrix_env + await create_relation( + db, + type_key="app_to_do", + source_id=e["app_a"].id, + target_id=e["do_x"].id, + attributes={"usage": "owner"}, + ) + await create_relation( + db, type_key="app_to_do", source_id=e["app_b"].id, target_id=e["do_x"].id + ) + + data = await _matrix(client, e["admin"], attr="app_to_do.usage:__empty__") + + rows_with_edges = {ix["row_id"] for ix in data["intersections"] if ix["e"]} + assert rows_with_edges == {str(e["app_b"].id)} + + async def test_filter_is_scoped_to_its_own_relation_type(self, client, db, matrix_env): + """A filter on one relation type must not discard the other type's edges.""" + e = matrix_env + await create_relation( + db, + type_key="app_to_do", + source_id=e["app_a"].id, + target_id=e["do_x"].id, + attributes={"usage": "user"}, + ) + await create_relation( + db, type_key="do_to_app", source_id=e["do_y"].id, target_id=e["app_b"].id + ) + + data = await _matrix(client, e["admin"], attr="app_to_do.usage:owner") + + rows_with_edges = {ix["row_id"] for ix in data["intersections"] if ix["e"]} + assert rows_with_edges == {str(e["app_b"].id)} + + async def test_filter_by_direction(self, client, db, matrix_env): + e = matrix_env + await create_relation( + db, type_key="app_to_do", source_id=e["app_a"].id, target_id=e["do_x"].id + ) + await create_relation( + db, type_key="do_to_app", source_id=e["do_y"].id, target_id=e["app_b"].id + ) + + forward = await _matrix(client, e["admin"], direction="forward") + reverse = await _matrix(client, e["admin"], direction="reverse") + + assert {ix["row_id"] for ix in forward["intersections"] if ix["e"]} == {str(e["app_a"].id)} + assert {ix["row_id"] for ix in reverse["intersections"] if ix["e"]} == {str(e["app_b"].id)} + + async def test_filters_never_prune_the_card_lists(self, client, db, matrix_env): + """Cards always ship in full — pruning them would break the parent chain. + + The client hides cards with no surviving edge; it cannot rebuild a + hierarchy whose intermediate nodes the server dropped. + """ + e = matrix_env + await create_relation( + db, + type_key="app_to_do", + source_id=e["app_a"].id, + target_id=e["do_x"].id, + attributes={"usage": "owner"}, + ) + + data = await _matrix(client, e["admin"], attr="app_to_do.usage:user") + + assert len(data["rows"]) == 2 + assert len(data["columns"]) == 2 + assert all(not ix["e"] for ix in data["intersections"]) + + @pytest.mark.parametrize( + "bad", + [ + "no-separators", + "app_to_do.usage", # missing :value + "usage:owner", # missing relation-type prefix + "not_a_type.usage:owner", # relation type not on these axes + "app_to_do.notAField:owner", # field not declared on that type + ], + ) + async def test_malformed_attribute_filter_is_rejected(self, client, db, matrix_env, bad): + """A typo must fail loudly — silently widening the result set is worse.""" + resp = await client.get( + "/api/v1/reports/matrix", + params={"row_type": "Application", "col_type": "DataObject", "attr": bad}, + headers=auth_headers(matrix_env["admin"]), + ) + assert resp.status_code == 400 + + async def test_relation_type_filter_naming_no_real_type_is_rejected( + self, client, db, matrix_env + ): + """A key that exists nowhere is a typo; one declared elsewhere is not.""" + resp = await client.get( + "/api/v1/reports/matrix", + params={ + "row_type": "Application", + "col_type": "DataObject", + "relation_types": "not_a_real_relation_type", + }, + headers=auth_headers(matrix_env["admin"]), + ) + assert resp.status_code == 400 + + async def test_relation_type_filter_accepts_a_type_declared_for_another_pair( + self, client, db, matrix_env + ): + # `app_to_itc` is declared Application -> ITComponent, but a relation of + # that type between an Application and a Data Object still connects the + # two axes, so filtering on it is a legitimate question, not an error. + e = matrix_env + await create_relation( + db, type_key="app_to_itc", source_id=e["app_a"].id, target_id=e["do_x"].id + ) + + data = await _matrix(client, e["admin"], relation_types="app_to_itc") + + assert sum(len(ix["e"]) for ix in data["intersections"]) == 1 + + +class TestMatrixRelationTypeCoverage: + """Which relations belong in the grid is decided by the cards they connect. + + Restricting to the relation types the metamodel declares for the axis pair + silently drops relations whose type was renamed, imported, or declared for + another pair — they connect the two cards all the same. + """ + + async def test_includes_a_relation_whose_type_is_not_declared_for_the_pair( + self, client, db, matrix_env + ): + e = matrix_env + await create_relation( + db, type_key="app_to_itc", source_id=e["app_a"].id, target_id=e["do_x"].id + ) + + data = await _matrix(client, e["admin"]) + + edges = _cell(data, e["app_a"].id, e["do_x"].id)["e"] + assert len(edges) == 1 + assert data["relation_types"][edges[0][0]] == "app_to_itc" + + async def test_lists_declared_types_first_then_the_ones_found_in_the_data( + self, client, db, matrix_env + ): + e = matrix_env + await create_relation( + db, type_key="app_to_itc", source_id=e["app_a"].id, target_id=e["do_x"].id + ) + + data = await _matrix(client, e["admin"]) + + assert data["relation_types"][:2] == ["app_to_do", "do_to_app"] + assert "app_to_itc" in data["relation_types"] + + async def test_includes_relations_when_the_metamodel_declares_none_for_the_pair( + self, client, db, env + ): + """A pair the metamodel says nothing about still shows what is there.""" + admin = env["admin"] + app = await create_card(db, card_type="Application", name="App", user_id=admin.id) + itc = await create_card(db, card_type="ITComponent", name="Component", user_id=admin.id) + # ITComponent has no declared relation type back to Application. + await create_relation(db, type_key="app_to_itc", source_id=app.id, target_id=itc.id) + + resp = await client.get( + "/api/v1/reports/matrix", + params={"row_type": "ITComponent", "col_type": "Application"}, + headers=auth_headers(admin), + ) + assert resp.status_code == 200 + data = resp.json() + + edges = _cell(data, itc.id, app.id)["e"] + assert len(edges) == 1 + assert data["relation_types"][edges[0][0]] == "app_to_itc" + # The row card is the relation's target, so the row reads as reverse. + assert edges[0][1] == "r" + + async def test_attributes_are_read_through_the_relations_own_schema( + self, client, db, matrix_env + ): + """Even for a type not declared for this pair.""" + e = matrix_env + await create_relation_type( + db, + key="app_to_do_legacy", + label="legacy", + source_type_key="Application", + target_type_key="ITComponent", + attributes_schema=[{"key": "legacyFlag", "label": "Legacy", "type": "boolean"}], + ) + await create_relation( + db, + type_key="app_to_do_legacy", + source_id=e["app_a"].id, + target_id=e["do_x"].id, + attributes={"legacyFlag": True, "unknownKey": "x"}, + ) + + data = await _matrix(client, e["admin"]) + + edges = _cell(data, e["app_a"].id, e["do_x"].id)["e"] + assert data["attr_sets"][edges[0][2]] == {"legacyFlag": True} + # --------------------------------------------------------------------------- # Roadmap diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 4a461a68f..d0989685a 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -29307,7 +29307,7 @@ }, "/api/v1/reports/matrix": { "get": { - "description": "Matrix report: cross-reference grid.", + "description": "Matrix report: cross-reference grid between two card types.\n\nEach intersection carries the relations behind it \u2014 their relation type,\ntheir orientation relative to the row axis, and their attribute values \u2014 so\nthe grid can show what the link *means*, not merely that one exists.\n\n``direction`` is structural: ``forward`` keeps relations whose source is the\nrow card, ``reverse`` those whose target is. Semantic notions of direction\nthat a metamodel may express as a relation attribute are just attribute\nvalues, filtered through ``attr`` like any other.\n\nOnly the *edges* are filtered server-side; the row and column card lists are\nalways complete. Pruning cards here would break their ``parent_id`` chains\nand silently flatten the hierarchy the client rebuilds \u2014 the client already\nhides cards with no surviving edge, and can do so without a refetch.", "operationId": "matrix_api_v1_reports_matrix_get", "parameters": [ { @@ -29329,6 +29329,49 @@ "title": "Col Type", "type": "string" } + }, + { + "description": "Comma-separated relation-type keys to restrict the grid to", + "in": "query", + "name": "relation_types", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Comma-separated relation-type keys to restrict the grid to", + "title": "Relation Types" + } + }, + { + "description": "Attribute filter, `relationTypeKey.fieldKey:value`. Repeatable.", + "in": "query", + "name": "attr", + "required": false, + "schema": { + "description": "Attribute filter, `relationTypeKey.fieldKey:value`. Repeatable.", + "items": { + "type": "string" + }, + "title": "Attr", + "type": "array" + } + }, + { + "in": "query", + "name": "direction", + "required": false, + "schema": { + "default": "any", + "pattern": "^(any|forward|reverse)$", + "title": "Direction", + "type": "string" + } } ], "responses": { diff --git a/docs/guide/reports.ar.md b/docs/guide/reports.ar.md index d04c6c283..f31df6932 100644 --- a/docs/guide/reports.ar.md +++ b/docs/guide/reports.ar.md @@ -159,6 +159,45 @@ استخدم مفتاح **إخفاء البطاقات غير المرتبطة** لإخفاء الصفوف والأعمدة الخاصة بالبطاقات التي ليس لها أي علاقات، مع الاحتفاظ فقط بالبطاقات التي تشارك في علاقة واحدة على الأقل. يظل العرض الكامل الذي يعرض جميع البطاقات هو الإعداد الافتراضي. +### ما تعرضه كل خلية + +يوفّر عنصر التحكم **عرض الخلية** أربعة خيارات: + +- **موجودة (نقطة)** — نقطة أينما وُجدت علاقة. +- **العدد (خريطة حرارية)** — عدد العلاقات، مظلّلًا حسب الكثافة. +- **القيم (رموز)** — حرف ملوّن لكل قيمة علاقة، مع مفتاح فوق الشبكة. الأنسب للمصفوفات الكبيرة. +- **القيم (تسميات)** — أسماء القيم كاملة. تتّسع الأعمدة، لذا يناسب هذا الخيار المصفوفات الأصغر. + +تأتي الحروف والأسماء من السمات التي تعلنها أنواع العلاقات لديك، وبلغتك أنت. تُقرأ علاقة CRUD على هيئة `C R U D`؛ وتعرض علاقة الملكية قيمها الخاصة. أضِف قيمة إلى نوع علاقة في [النموذج الوصفي](../admin/metamodel.md) فتظهر هنا دون أي إعداد إضافي. تعرض خلية المجموعة المطوية عددًا دائمًا، لأنها قد تشمل قيمًا مختلفة كثيرة — وسِّع مستوى لرؤيتها. + +قد تحمل البطاقة التي لها بطاقات فرعية في التسلسل الهرمي علاقات خاصة بها أيضًا. وعندها تحصل على صف (أو عمود) خاص بها يحمل التسمية **(نفسه)** أسفل عنوان مجموعتها مباشرة، فتجد تلك العلاقات مكانًا تظهر فيه بدل أن تضيع بين الأصل وفروعه. وعند طي المستوى تُحتسب ضمن خلية المجموعة مع علاقات الفروع. + +### التصفية حسب العلاقة + +يضيّق شريط المرشّحات فوق الشبكة المصفوفة إلى العلاقات التي تهمّك: + +- **نوع العلاقة** — عندما يكون نوعا البطاقة مرتبطين في كلا الاتجاهين. +- **الاتجاه** — هل بطاقة الصف هي مصدر العلاقة أم هدفها. +- **القيم** — مرشّح لكل سمة تعلنها أنواع العلاقات، بما في ذلك «(فارغ)» للعلاقات التي لم تُضبط قيمتها قط. + +تُفرِغ التصفية خلايا البطاقات التي لم تعد مطابقة، لذا فإن تفعيل **إخفاء البطاقات غير المطابقة** يُبقي المطابقة وحدها. بعض الأمثلة: + +- تطبيق × كائن بيانات، مع التصفية على *إنشاء* — أي التطبيقات هي النظام المرجعي لكل كائن بيانات. +- تطبيق × واجهة، مع التصفية حسب الاتجاه — من ينشر الواجهة ومن يستهلكها. +- مؤسسة × تطبيق، مع التصفية على *المالك* — خريطة الملكية دون أن يزدحمها المستخدمون. + +### العثور على فجوات التغطية + +تحسب بطاقتان عدد البطاقات على كل محور التي لا تملك أي علاقة على الإطلاق. يقلّص خيار **إظهار الفجوات فقط** الشبكة إلى هذه البطاقات بالضبط — القدرات التي لا يدعمها أحد، وكائنات البيانات التي لا يصونها أحد. + +### التنقّل داخل مصفوفة كبيرة + +يعمل **البحث عن صف** و**البحث عن عمود** على تصفية المحاور بالاسم؛ ويظل العنصر الأب ظاهرًا عندما يطابق أحد أبنائه. ويبدّل زر التبديل في شريط العنوان المحورين. + +### التصدير + +يُنتج التصدير إلى Excel ورقتين: الشبكة كما تظهر على الشاشة، وسطرًا لكل علاقة مع قيمها موزّعة على أعمدة — وهي الورقة التي تبني عليها جدولًا محوريًا. أما التصدير إلى PowerPoint فيلتقط الصورة. + ## تقرير جودة البيانات ![تقرير جودة البيانات](../assets/img/en/33_report_data_quality.png) diff --git a/docs/guide/reports.da.md b/docs/guide/reports.da.md index 37b69aad0..6cddb5577 100644 --- a/docs/guide/reports.da.md +++ b/docs/guide/reports.da.md @@ -159,6 +159,45 @@ Dette er nyttigt til at identificere dækningshuller (kompetencer uden understø Brug kontakten **Skjul ikke-relaterede kort** for at skjule rækker og kolonner for kort uden relationer, så kun kort, der indgår i mindst én relation, vises. Den fulde visning med alle kort forbliver standardindstillingen. +### Hvad hver celle viser + +Kontrollen **Cellevisning** tilbyder fire muligheder: + +- **Findes (prik)** — en prik alle steder, hvor der findes en relation. +- **Antal (heatmap)** — hvor mange relationer der er, tonet efter tæthed. +- **Værdier (koder)** — ét farvekodet bogstav pr. relationsværdi med en signaturforklaring over gitteret. Bedst til en stor matrix. +- **Værdier (etiketter)** — værdinavnene i fuld længde. Kolonnerne bliver bredere, så det passer til en mindre matrix. + +Bogstaverne og navnene kommer fra de attributter, dine relationstyper erklærer, og vises på dit eget sprog. En CRUD-relation læses `C R U D`; en ejerskabsrelation viser sine egne værdier. Tilføj en værdi til en relationstype i [metamodellen](../admin/metamodel.md), så dukker den op her uden yderligere opsætning. En sammenklappet gruppecelle viser altid et antal, fordi den kan spænde over mange forskellige værdier — udvid et niveau for at se dem. + +Et kort med underliggende kort i hierarkiet kan også bære sine egne relationer. Når det gør, får det sin egen række (eller kolonne) med etiketten **(selv)** lige under sin gruppeoverskrift, så de relationer har et sted at vise sig i stedet for at gå tabt mellem forælderen og dens børn. Klap niveauet sammen, og de tælles med i gruppens celle sammen med børnenes. + +### Filtrering på relation + +Filterlinjen over gitteret indsnævrer matricen til de relationer, du er interesseret i: + +- **Relationstype** — når de to korttyper er forbundet i begge retninger. +- **Retning** — om rækkens kort er relationens kilde eller mål. +- **Værdier** — ét filter pr. attribut, som relationstyperne erklærer, inklusive «(tom)» for relationer, hvor værdien aldrig blev sat. + +Filtrering tømmer cellerne for de kort, der ikke længere matcher, så **Skjul kort uden match** efterlader kun dem, der gør. Nogle eksempler: + +- Applikation × Dataobjekt filtreret på *Opret* — hvilke applikationer der er kildesystem for hvert dataobjekt. +- Applikation × Grænseflade filtreret på retning — hvem der udstiller en grænseflade, og hvem der forbruger den. +- Organisation × Applikation filtreret på *Ejer* — ejerskabskortet, uden at brugerne fylder det op. + +### At finde huller i dækningen + +To felter tæller de kort på hver akse, der slet ingen relation har. **Vis kun huller** reducerer gitteret til netop dem — de kapabiliteter, ingen understøtter, og de dataobjekter, ingen vedligeholder. + +### At finde rundt i en stor matrix + +**Find række** og **Find kolonne** filtrerer akserne på navn; et overordnet element forbliver synligt, når et af dets underelementer matcher. Byt-knappen i titellinjen bytter om på de to akser. + +### Eksport + +Excel-eksport giver to ark: gitteret, som det ser ud på skærmen, og én række pr. relation med værdierne fordelt på kolonner — arket, du laver pivot på. PowerPoint-eksport fanger billedet. + ## Datakvalitetsrapport ![Datakvalitetsrapport](../assets/img/en/33_report_data_quality.png) diff --git a/docs/guide/reports.de.md b/docs/guide/reports.de.md index 77baff9ac..87bdcde10 100644 --- a/docs/guide/reports.de.md +++ b/docs/guide/reports.de.md @@ -159,6 +159,45 @@ Dies ist nützlich zur Identifizierung von Abdeckungslücken (Fähigkeiten ohne Verwenden Sie den Schalter **Nicht verknüpfte Karten ausblenden**, um Zeilen und Spalten für Karten ohne Beziehungen auszublenden, sodass nur Karten angezeigt werden, die an mindestens einer Beziehung beteiligt sind. Die vollständige Ansicht mit allen Karten bleibt die Standardeinstellung. +### Was eine Zelle zeigt + +Die Steuerung **Zellenanzeige** bietet vier Optionen: + +- **Vorhanden (Punkt)** — ein Punkt überall dort, wo eine Beziehung besteht. +- **Anzahl (Heatmap)** — wie viele Beziehungen es gibt, nach Dichte eingefärbt. +- **Werte (Kürzel)** — ein farbcodierter Buchstabe je Beziehungswert, mit einer Legende über dem Raster. Am besten für große Matrizen. +- **Werte (Beschriftungen)** — die Wertenamen vollständig. Die Spalten werden breiter, daher eignet sich das für kleinere Matrizen. + +Buchstaben und Namen stammen aus den Attributen, die Ihre Beziehungstypen deklarieren, in Ihrer eigenen Sprache. Eine CRUD-Beziehung liest sich als `C R U D`; eine Eigentumsbeziehung zeigt ihre eigenen Werte. Fügen Sie im [Metamodell](../admin/metamodel.md) einem Beziehungstyp einen Wert hinzu, erscheint er hier ohne weitere Einrichtung. Eine eingeklappte Gruppenzelle zeigt immer eine Anzahl, da sie viele verschiedene Werte umfassen kann — klappen Sie eine Ebene aus, um sie zu sehen. + +Eine Karte mit untergeordneten Elementen kann auch eigene Beziehungen haben. In diesem Fall erhält sie direkt unter ihrer Gruppenüberschrift eine eigene Zeile (bzw. Spalte) mit der Beschriftung **(selbst)**, damit diese Beziehungen einen Platz haben und nicht zwischen der übergeordneten Karte und ihren untergeordneten Elementen verloren gehen. Klappen Sie die Ebene ein, werden sie zusammen mit denen der untergeordneten Elemente in der Gruppenzelle gezählt. + +### Nach Beziehung filtern + +Die Filterleiste über dem Raster grenzt die Matrix auf die Beziehungen ein, die Sie interessieren: + +- **Beziehungstyp** — wenn die beiden Kartentypen in beide Richtungen verbunden sind. +- **Richtung** — ob die Zeilenkarte Quelle oder Ziel der Beziehung ist. +- **Werte** — ein Filter je Attribut, das die Beziehungstypen deklarieren, einschließlich «(leer)» für Beziehungen, bei denen der Wert nie gesetzt wurde. + +Beim Filtern leeren sich die Zellen der Karten, die nicht mehr passen; der Schalter **Nicht passende Karten ausblenden** lässt dann nur die passenden übrig. Einige Beispiele: + +- Anwendung × Datenobjekt, gefiltert auf *Anlegen* — welche Anwendungen das führende System für ein Datenobjekt sind. +- Anwendung × Schnittstelle, nach Richtung gefiltert — wer eine Schnittstelle bereitstellt und wer sie konsumiert. +- Organisation × Anwendung, gefiltert auf *Eigentümer* — die Eigentümerlandkarte, ohne dass die Nutzer sie überladen. + +### Abdeckungslücken finden + +Zwei Kacheln zählen die Karten je Achse, die überhaupt keine Beziehung haben. **Nur Lücken anzeigen** reduziert das Raster auf genau diese — die Fähigkeiten, die niemand unterstützt, die Datenobjekte, die niemand pflegt. + +### Sich in einer großen Matrix zurechtfinden + +**Zeile suchen** und **Spalte suchen** filtern die Achsen nach Namen; ein übergeordnetes Element bleibt sichtbar, wenn eines seiner untergeordneten Elemente passt. Die Tauschschaltfläche in der Titelleiste vertauscht die beiden Achsen. + +### Exportieren + +Der Excel-Export erzeugt zwei Blätter: das Raster wie auf dem Bildschirm und eine Zeile je Beziehung mit ihren Werten in Spalten — das Blatt zum Pivotieren. Der PowerPoint-Export erfasst das Bild. + ## Datenqualitätsbericht ![Datenqualitätsbericht](../assets/img/de/33_bericht_datenqualitaet.png) diff --git a/docs/guide/reports.es.md b/docs/guide/reports.es.md index ce648b4fa..f4be0c635 100644 --- a/docs/guide/reports.es.md +++ b/docs/guide/reports.es.md @@ -159,6 +159,45 @@ Esto es útil para identificar brechas de cobertura (capacidades sin aplicacione Utilice el interruptor **Ocultar tarjetas sin relación** para ocultar las filas y columnas de las fichas que no tienen relaciones, conservando solo las fichas que participan en al menos una relación. La vista completa que muestra todas las fichas sigue siendo el comportamiento predeterminado. +### Qué muestra cada celda + +El control **Visualización de celda** ofrece cuatro opciones: + +- **Existe (punto)** — un punto allí donde exista una relación. +- **Recuento (mapa de calor)** — cuántas relaciones hay, sombreado según la densidad. +- **Valores (códigos)** — una letra con color por cada valor de relación, con una leyenda sobre la cuadrícula. Ideal para una matriz grande. +- **Valores (etiquetas)** — los nombres de los valores completos. Las columnas se ensanchan, así que conviene a una matriz más pequeña. + +Las letras y los nombres provienen de los atributos que declaran sus tipos de relación, en su propio idioma. Una relación CRUD se lee `C R U D`; una relación de propiedad muestra sus propios valores. Añada un valor a un tipo de relación en el [metamodelo](../admin/metamodel.md) y aparecerá aquí sin más configuración. Una celda de grupo contraído siempre muestra un recuento, porque puede abarcar muchos valores distintos: expanda un nivel para verlos. + +Una ficha con hijos en la jerarquía también puede tener relaciones propias. Cuando así ocurre, recibe una fila (o columna) propia etiquetada **(él mismo)** justo debajo de su encabezado de grupo, de modo que esas relaciones tengan dónde mostrarse en lugar de perderse entre el padre y sus hijos. Al contraer el nivel se cuentan en la celda del grupo junto con las de sus hijos. + +### Filtrar por relación + +La barra de filtros sobre la cuadrícula limita la matriz a las relaciones que le interesan: + +- **Tipo de relación** — cuando los dos tipos de ficha están conectados en ambos sentidos. +- **Dirección** — si la ficha de la fila es el origen o el destino de la relación. +- **Valores** — un filtro por cada atributo que declaren los tipos de relación, incluido «(vacío)» para las relaciones cuyo valor nunca se estableció. + +Al filtrar se vacían las celdas de las fichas que ya no coinciden, de modo que activar **Ocultar tarjetas que no coinciden** deja solo las que sí. Algunos ejemplos: + +- Aplicación × Objeto de datos, filtrado por *Crear*: qué aplicaciones son el sistema de referencia de cada objeto de datos. +- Aplicación × Interfaz, filtrado por dirección: quién publica una interfaz y quién la consume. +- Organización × Aplicación, filtrado por *Propietario*: el mapa de propiedad, sin que los usuarios lo saturen. + +### Encontrar brechas de cobertura + +Dos tarjetas cuentan las fichas de cada eje que no tienen ninguna relación. **Mostrar solo brechas** reduce la cuadrícula exactamente a esas: las capacidades que nadie soporta, los objetos de datos que nadie mantiene. + +### Moverse por una matriz grande + +**Buscar fila** y **Buscar columna** filtran los ejes por nombre; un elemento padre permanece visible cuando alguno de sus hijos coincide. El botón de intercambio de la barra de título permuta los dos ejes. + +### Exportar + +La exportación a Excel genera dos hojas: la cuadrícula tal como aparece en pantalla y una fila por relación con sus valores repartidos en columnas, la hoja sobre la que construir una tabla dinámica. La exportación a PowerPoint captura la imagen. + ## Informe de Calidad de Datos ![Informe de Calidad de Datos](../assets/img/es/33_informe_calidad_datos.png) diff --git a/docs/guide/reports.fr.md b/docs/guide/reports.fr.md index 638e6487f..1093761d8 100644 --- a/docs/guide/reports.fr.md +++ b/docs/guide/reports.fr.md @@ -159,6 +159,45 @@ Ceci est utile pour identifier les lacunes de couverture (capacités sans applic Utilisez l'option **Masquer les cartes sans relation** pour masquer les lignes et colonnes des fiches qui n'ont aucune relation, en ne conservant que les fiches participant à au moins une relation. La vue complète affichant toutes les fiches reste le comportement par défaut. +### Ce qu'affiche chaque cellule + +Le contrôle **Affichage des cellules** propose quatre options : + +- **Existe (point)** — un point partout où une relation existe. +- **Nombre (carte de chaleur)** — combien de relations, nuancé selon la densité. +- **Valeurs (codes)** — une lettre colorée par valeur de relation, avec une légende au-dessus de la grille. Idéal pour une grande matrice. +- **Valeurs (libellés)** — les noms des valeurs en entier. Les colonnes s'élargissent, ce qui convient à une matrice plus petite. + +Les lettres et les noms proviennent des attributs déclarés par vos types de relation, dans votre langue. Une relation CRUD se lit `C R U D` ; une relation de propriété affiche ses propres valeurs. Ajoutez une valeur à un type de relation dans le [métamodèle](../admin/metamodel.md) et elle apparaît ici sans autre configuration. Une cellule de groupe replié affiche toujours un nombre, car elle peut couvrir de nombreuses valeurs différentes — développez d'un niveau pour les voir. + +Une fiche ayant des enfants dans la hiérarchie peut également porter ses propres relations. Le cas échéant, elle reçoit une ligne (ou une colonne) qui lui est propre, intitulée **(lui-même)**, directement sous son en-tête de groupe : ces relations ont ainsi un endroit où s'afficher au lieu de se perdre entre le parent et ses enfants. Repliez le niveau et elles sont comptées dans la cellule du groupe avec celles de ses enfants. + +### Filtrer par relation + +La barre de filtres au-dessus de la grille restreint la matrice aux relations qui vous intéressent : + +- **Type de relation** — lorsque les deux types de fiches sont reliés dans les deux sens. +- **Sens** — si la fiche de la ligne est la source ou la cible de la relation. +- **Valeurs** — un filtre par attribut déclaré par les types de relation, y compris «(vide)» pour les relations dont la valeur n'a jamais été renseignée. + +Le filtrage vide les cellules des fiches qui ne correspondent plus ; l'option **Masquer les cartes non correspondantes** ne laisse alors que celles qui correspondent. Quelques exemples : + +- Application × Objet de données, filtré sur *Créer* — quelles applications font référence pour chaque objet de données. +- Application × Interface, filtré par sens — qui publie une interface et qui la consomme. +- Organisation × Application, filtré sur *Propriétaire* — la carte des propriétaires, sans que les utilisateurs l'encombrent. + +### Repérer les lacunes de couverture + +Deux tuiles comptent les fiches de chaque axe qui n'ont aucune relation. **Afficher seulement les manques** réduit la grille à celles-ci — les capacités que personne ne soutient, les objets de données que personne n'entretient. + +### S'orienter dans une grande matrice + +**Trouver une ligne** et **Trouver une colonne** filtrent les axes par nom ; un parent reste visible si l'un de ses enfants correspond. Le bouton de permutation dans la barre de titre échange les deux axes. + +### Exporter + +L'export Excel produit deux feuilles : la grille telle qu'elle apparaît à l'écran, et une ligne par relation avec ses valeurs réparties en colonnes — la feuille sur laquelle construire un tableau croisé. L'export PowerPoint capture l'image. + ## Rapport Qualité des données ![Rapport Qualité des données](../assets/img/fr/33_rapport_qualite_donnees.png) diff --git a/docs/guide/reports.it.md b/docs/guide/reports.it.md index 9b86647d7..81cf40aab 100644 --- a/docs/guide/reports.it.md +++ b/docs/guide/reports.it.md @@ -159,6 +159,45 @@ Questo è utile per identificare lacune di copertura (capability senza applicazi Utilizza l'interruttore **Nascondi le schede senza relazioni** per nascondere righe e colonne delle card prive di relazioni, mantenendo solo le card che partecipano ad almeno una relazione. La vista completa che mostra tutte le card rimane l'impostazione predefinita. +### Che cosa mostra ogni cella + +Il controllo **Visualizzazione cella** offre quattro opzioni: + +- **Esiste (punto)** — un punto ovunque esista una relazione. +- **Conteggio (heatmap)** — quante relazioni ci sono, sfumate in base alla densità. +- **Valori (codici)** — una lettera colorata per ogni valore della relazione, con una legenda sopra la griglia. Ideale per una matrice grande. +- **Valori (etichette)** — i nomi dei valori per esteso. Le colonne si allargano, quindi si presta a una matrice più piccola. + +Lettere e nomi provengono dagli attributi dichiarati dai tuoi tipi di relazione, nella tua lingua. Una relazione CRUD si legge `C R U D`; una relazione di proprietà mostra i propri valori. Aggiungi un valore a un tipo di relazione nel [metamodello](../admin/metamodel.md) e comparirà qui senza altra configurazione. Una cella di gruppo compresso mostra sempre un conteggio, perché può abbracciare molti valori diversi: espandi un livello per vederli. + +Una card con figli nella gerarchia può avere anche relazioni proprie. In tal caso riceve una riga (o colonna) tutta sua, etichettata **(esso stesso)**, subito sotto la sua intestazione di gruppo, così quelle relazioni hanno dove comparire invece di perdersi tra il padre e i figli. Comprimendo il livello vengono conteggiate nella cella del gruppo insieme a quelle dei figli. + +### Filtrare per relazione + +La barra dei filtri sopra la griglia restringe la matrice alle relazioni che ti interessano: + +- **Tipo di relazione** — quando i due tipi di card sono collegati in entrambe le direzioni. +- **Direzione** — se la card della riga è l'origine o la destinazione della relazione. +- **Valori** — un filtro per ogni attributo dichiarato dai tipi di relazione, incluso «(vuoto)» per le relazioni in cui il valore non è mai stato impostato. + +Il filtro svuota le celle delle card che non corrispondono più, così attivando **Nascondi le schede non corrispondenti** restano solo quelle che corrispondono. Alcuni esempi: + +- Application × Data Object, filtrato su *Crea*: quali applicazioni sono il sistema di riferimento per ciascun oggetto dati. +- Application × Interface, filtrato per direzione: chi pubblica un'interfaccia e chi la consuma. +- Organization × Application, filtrato su *Proprietario*: la mappa della proprietà, senza che gli utenti la affollino. + +### Individuare le lacune di copertura + +Due riquadri contano le card di ciascun asse che non hanno alcuna relazione. **Mostra solo le lacune** riduce la griglia esattamente a quelle: le capability che nessuno supporta, gli oggetti dati che nessuno mantiene. + +### Orientarsi in una matrice grande + +**Trova riga** e **Trova colonna** filtrano gli assi per nome; un elemento padre resta visibile quando uno dei figli corrisponde. Il pulsante di scambio nella barra del titolo inverte i due assi. + +### Esportare + +L'esportazione in Excel produce due fogli: la griglia così come appare a schermo e una riga per relazione con i suoi valori distribuiti in colonne, il foglio su cui costruire una tabella pivot. L'esportazione in PowerPoint cattura l'immagine. + ## Report Qualità dei Dati ![Report Qualità dei Dati](../assets/img/it/33_report_qualita_dati.png) diff --git a/docs/guide/reports.md b/docs/guide/reports.md index b79514f7e..55f374644 100644 --- a/docs/guide/reports.md +++ b/docs/guide/reports.md @@ -159,6 +159,45 @@ This is useful for identifying coverage gaps (capabilities with no supporting ap Use the **Hide unrelated cards** toggle to hide rows and columns for cards that have no relationships, keeping only the cards that participate in at least one relationship. The full view showing every card remains the default. +### What each cell shows + +The **Cell Display** control offers four options: + +- **Exists (dot)** — a dot wherever a relation exists. +- **Count (heatmap)** — how many relations there are, shaded by density. +- **Values (codes)** — one colour-coded letter per relation value, with a legend above the grid. Best for a large matrix. +- **Values (labels)** — the value names in full. The columns widen, so this suits a smaller matrix. + +The letters and names come from the attributes your relation types declare, in your own language. A CRUD relation reads `C R U D`; an ownership relation reads its own values. Add a value to a relation type in the [metamodel](../admin/metamodel.md) and it shows up here with no further setup. A collapsed group cell always shows a count, because it can span many different values — expand a level to see them. + +A card that has children below it in the hierarchy can also carry relations of its own. When it does, it gets a row (or column) of its own labelled **(itself)** directly under its group heading, so those relations have somewhere to appear rather than being lost between the parent and its children. Collapse the level and they are counted in the group's cell along with its children's. + +### Filtering by relation + +The filter bar above the grid narrows the matrix to the relations you care about: + +- **Relation type** — when the two card types are connected in both directions. +- **Direction** — whether the row card is the source or the target of the relation. +- **Values** — one filter per attribute the relation types declare, including «(empty)» for relations where the value was never set. + +Filtering empties the cells of the cards that no longer match, so switching on **Hide non-matching cards** leaves only the cards that do. Some examples: + +- Application × Data Object, filtered to *Create* — which applications are the system of record for each data object. +- Application × Interface, filtered by direction — who publishes an interface and who consumes it. +- Organization × Application, filtered to *Owner* — the ownership map, without the users cluttering it. + +### Finding coverage gaps + +Two tiles count the cards on each axis that have no relation at all. **Show only gaps** reduces the grid to exactly those — the capabilities nobody supports, the data objects nobody maintains. + +### Finding your way around a large matrix + +**Find row** and **Find column** filter the axes by name; a parent stays visible when one of its children matches. The swap button in the title bar exchanges the two axes. + +### Exporting + +Excel export produces two sheets: the grid as it appears on screen, and one row per relation with its values spread across columns — the sheet to pivot on. PowerPoint export captures the picture. + ## Data Quality Report ![Data Quality Report](../assets/img/en/33_report_data_quality.png) diff --git a/docs/guide/reports.pt.md b/docs/guide/reports.pt.md index 17124fb4a..4c89fcc15 100644 --- a/docs/guide/reports.pt.md +++ b/docs/guide/reports.pt.md @@ -159,6 +159,45 @@ Isso é útil para identificar lacunas de cobertura (capacidades sem aplicaçõe Utilize o botão **Ocultar cartões sem relações** para ocultar linhas e colunas de cards que não têm relações, mantendo apenas os cards que participam em pelo menos uma relação. A visualização completa que mostra todos os cards continua a ser o comportamento predefinido. +### O que cada célula mostra + +O controlo **Exibição da célula** oferece quatro opções: + +- **Existe (ponto)** — um ponto onde exista uma relação. +- **Contagem (mapa de calor)** — quantas relações existem, sombreadas conforme a densidade. +- **Valores (códigos)** — uma letra colorida por cada valor da relação, com uma legenda acima da grelha. Ideal para uma matriz grande. +- **Valores (rótulos)** — os nomes dos valores por extenso. As colunas alargam-se, pelo que se adequa a uma matriz mais pequena. + +As letras e os nomes vêm dos atributos que os seus tipos de relação declaram, no seu próprio idioma. Uma relação CRUD lê-se `C R U D`; uma relação de propriedade mostra os seus próprios valores. Adicione um valor a um tipo de relação no [metamodelo](../admin/metamodel.md) e ele aparece aqui sem mais configuração. Uma célula de grupo recolhido mostra sempre uma contagem, porque pode abranger muitos valores diferentes — expanda um nível para os ver. + +Um card com filhos na hierarquia pode também ter relações próprias. Quando tem, recebe uma linha (ou coluna) só sua, com o rótulo **(ele próprio)**, logo abaixo do seu cabeçalho de grupo, para que essas relações tenham onde aparecer em vez de se perderem entre o pai e os filhos. Ao recolher o nível, passam a ser contadas na célula do grupo juntamente com as dos filhos. + +### Filtrar por relação + +A barra de filtros acima da grelha restringe a matriz às relações que lhe interessam: + +- **Tipo de relação** — quando os dois tipos de card estão ligados em ambos os sentidos. +- **Direção** — se o card da linha é a origem ou o destino da relação. +- **Valores** — um filtro por cada atributo declarado pelos tipos de relação, incluindo «(vazio)» para relações cujo valor nunca foi definido. + +Ao filtrar, as células dos cards que já não correspondem ficam vazias, pelo que ativar **Ocultar cartões não correspondentes** deixa apenas os que correspondem. Alguns exemplos: + +- Aplicação × Objeto de dados, filtrado por *Criar* — que aplicações são o sistema de referência de cada objeto de dados. +- Aplicação × Interface, filtrado por direção — quem publica uma interface e quem a consome. +- Organização × Aplicação, filtrado por *Proprietário* — o mapa de propriedade, sem os utilizadores a sobrecarregá-lo. + +### Encontrar lacunas de cobertura + +Dois mosaicos contam os cards de cada eixo que não têm qualquer relação. **Mostrar apenas lacunas** reduz a grelha exatamente a esses — as capacidades que ninguém suporta, os objetos de dados que ninguém mantém. + +### Orientar-se numa matriz grande + +**Procurar linha** e **Procurar coluna** filtram os eixos por nome; um elemento pai permanece visível quando um dos seus filhos corresponde. O botão de troca na barra de título inverte os dois eixos. + +### Exportar + +A exportação para Excel produz duas folhas: a grelha tal como aparece no ecrã e uma linha por relação com os seus valores distribuídos por colunas — a folha sobre a qual construir uma tabela dinâmica. A exportação para PowerPoint capta a imagem. + ## Relatório de Qualidade dos Dados ![Relatório de Qualidade dos Dados](../assets/img/pt/33_relatorio_qualidade_dados.png) diff --git a/docs/guide/reports.ru.md b/docs/guide/reports.ru.md index b91df7132..e5de4e75f 100644 --- a/docs/guide/reports.ru.md +++ b/docs/guide/reports.ru.md @@ -159,6 +159,45 @@ Turbo EA включает мощный модуль **визуальной от Используйте переключатель **Скрыть несвязанные карточки**, чтобы скрыть строки и столбцы карточек, не имеющих связей, оставив только карточки, участвующие хотя бы в одной связи. Полное представление со всеми карточками остаётся вариантом по умолчанию. +### Что показывает ячейка + +Элемент управления **Отображение ячеек** предлагает четыре варианта: + +- **Существует (точка)** — точка везде, где есть связь. +- **Количество (тепловая карта)** — сколько связей, с заливкой по плотности. +- **Значения (коды)** — одна цветная буква на каждое значение связи, с легендой над сеткой. Лучше всего для большой матрицы. +- **Значения (подписи)** — названия значений полностью. Столбцы становятся шире, поэтому этот режим подходит для матрицы поменьше. + +Буквы и названия берутся из атрибутов, объявленных вашими типами связей, и отображаются на вашем языке. Связь CRUD читается как `C R U D`; связь владения показывает собственные значения. Добавьте значение типу связи в [метамодели](../admin/metamodel.md) — и оно появится здесь без дополнительной настройки. Ячейка свёрнутой группы всегда показывает количество, поскольку может охватывать много разных значений: разверните уровень, чтобы их увидеть. + +Карточка, у которой есть потомки в иерархии, может нести и собственные связи. В этом случае она получает собственную строку (или столбец) с подписью **(сам)** прямо под своим групповым заголовком, чтобы этим связям было где отобразиться, а не теряться между родителем и потомками. Свернёте уровень — и они будут учтены в ячейке группы вместе со связями потомков. + +### Фильтрация по связи + +Панель фильтров над сеткой сужает матрицу до нужных вам связей: + +- **Тип связи** — когда два типа карточек соединены в обоих направлениях. +- **Направление** — является ли карточка строки источником или целью связи. +- **Значения** — по фильтру на каждый атрибут, объявленный типами связей, включая «(пусто)» для связей, где значение так и не задали. + +Фильтрация опустошает ячейки карточек, которые больше не подходят, поэтому переключатель **Скрыть несовпадающие карточки** оставит только подходящие. Несколько примеров: + +- Приложение × Объект данных с фильтром *Создание* — какие приложения являются системой-источником для каждого объекта данных. +- Приложение × Интерфейс с фильтром по направлению — кто публикует интерфейс, а кто его потребляет. +- Организация × Приложение с фильтром *Владелец* — карта владения, не перегруженная пользователями. + +### Поиск пробелов в покрытии + +Две плитки считают карточки на каждой оси, у которых нет ни одной связи. **Только пробелы** сводит сетку именно к ним — способности, которые никто не поддерживает, объекты данных, которые никто не ведёт. + +### Ориентация в большой матрице + +**Найти строку** и **Найти столбец** фильтруют оси по имени; родительский элемент остаётся видимым, если совпадает хотя бы один из его потомков. Кнопка обмена в строке заголовка меняет оси местами. + +### Экспорт + +Экспорт в Excel создаёт два листа: сетку в том виде, в каком она на экране, и по строке на каждую связь со значениями, разнесёнными по столбцам, — лист для сводной таблицы. Экспорт в PowerPoint сохраняет изображение. + ## Отчёт по качеству данных ![Отчёт по качеству данных](../assets/img/ru/33_otchet_kachestvo_dannykh.png) diff --git a/docs/guide/reports.zh.md b/docs/guide/reports.zh.md index 906318ad3..9b45d1320 100644 --- a/docs/guide/reports.zh.md +++ b/docs/guide/reports.zh.md @@ -159,6 +159,45 @@ Turbo EA 包含强大的**可视化报告**模块,允许从不同角度分析 使用**隐藏无关联的卡片**开关可隐藏没有任何关系的卡片所对应的行和列,仅保留至少参与一个关系的卡片。显示所有卡片的完整视图仍为默认设置。 +### 单元格显示的内容 + +**单元格显示**控件提供四个选项: + +- **存在(圆点)** — 存在关系之处显示一个圆点。 +- **数量(热力图)** — 关系的数量,按密度着色。 +- **取值(代码)** — 每个关系取值一个彩色字母,网格上方带有图例。适合大型矩阵。 +- **取值(标签)** — 完整的取值名称。列会加宽,因此适合较小的矩阵。 + +字母与名称来自您的关系类型所声明的属性,并使用您自己的语言显示。CRUD 关系读作 `C R U D`;所有权关系显示其自身的取值。在[元模型](../admin/metamodel.md)中为关系类型添加一个取值,它无需其他设置即会出现在这里。折叠分组的单元格始终显示数量,因为它可能跨越许多不同取值 — 展开一层即可查看。 + +在层级中拥有子项的卡片,本身也可能带有自己的关系。此时它会在其分组标题正下方获得一行(或一列),标记为**(自身)**,使这些关系有处可显示,而不会消失在父项与子项之间。折叠该层级后,它们会与子项的关系一起计入分组单元格。 + +### 按关系筛选 + +网格上方的筛选栏可将矩阵收窄到您关心的关系: + +- **关系类型** — 当两种卡片类型在两个方向上都有连接时。 +- **方向** — 行卡片是关系的来源方还是目标方。 +- **取值** — 关系类型所声明的每个属性各有一个筛选器,其中包括「(空)」,用于从未设置过取值的关系。 + +筛选会清空不再匹配的卡片所在的单元格,因此打开**隐藏不匹配的卡片**后只会留下匹配的卡片。几个示例: + +- 应用 × 数据对象,筛选为*创建* — 哪些应用是各数据对象的权威系统。 +- 应用 × 接口,按方向筛选 — 谁发布接口,谁消费接口。 +- 组织 × 应用,筛选为*所有者* — 所有权地图,不会被使用方淹没。 + +### 发现覆盖缺口 + +两个指标块统计每个轴上完全没有关系的卡片。**仅显示缺口**会将网格收窄到正好这些卡片 — 无人支撑的能力,无人维护的数据对象。 + +### 在大型矩阵中导航 + +**查找行**和**查找列**按名称筛选坐标轴;当某个子项匹配时,其父项仍保持可见。标题栏中的互换按钮可交换两个坐标轴。 + +### 导出 + +Excel 导出会生成两个工作表:屏幕上所见的网格,以及每个关系一行、取值分列展开的明细表 — 适合用于数据透视。PowerPoint 导出捕获图像。 + ## 数据质量报告 ![数据质量报告](../assets/img/zh/33_report_data_quality.png) diff --git a/frontend/src/features/cards/sections/RelationAttributesEditor.test.tsx b/frontend/src/features/cards/sections/RelationAttributesEditor.test.tsx index 1d932a8d0..48549581c 100644 --- a/frontend/src/features/cards/sections/RelationAttributesEditor.test.tsx +++ b/frontend/src/features/cards/sections/RelationAttributesEditor.test.tsx @@ -1,5 +1,10 @@ -import { describe, it, expect } from "vitest"; -import { hasRelationSubtypes, relationAttributeBadges } from "./RelationAttributesEditor"; +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import RelationAttributesEditor, { + hasEditableRelationAttributes, + relationAttributeBadges, +} from "./RelationAttributesEditor"; import type { RelationType } from "@/types"; const usageType: RelationType = { @@ -84,6 +89,48 @@ const noOptions: RelationType = { ], } as unknown as RelationType; +/** + * A relation type whose only dimensions are flags. CRUD is the seeded example, + * but nothing in the code is keyed to these names — any boolean behaves the + * same, which is what these cases pin down. + */ +const flagsOnly: RelationType = { + key: "relAppToDataObj", + label: "CRUD", + source_type_key: "Application", + target_type_key: "DataObject", + cardinality: "n:m", + built_in: true, + is_hidden: false, + attributes_schema: [ + { key: "crudCreate", label: "Create", type: "boolean" }, + { key: "crudRead", label: "Read", type: "boolean" }, + { key: "crudUpdate", label: "Update", type: "boolean" }, + { key: "crudDelete", label: "Delete", type: "boolean" }, + ], +} as unknown as RelationType; + +/** Flags and a select side by side, in a deliberately interleaved order. */ +const mixedDimensions: RelationType = { + key: "relCustomMixed", + label: "custom", + source_type_key: "Application", + target_type_key: "DataObject", + cardinality: "n:m", + built_in: false, + is_hidden: false, + attributes_schema: [ + { key: "archived", label: "Archived", type: "boolean" }, + { + key: "usageType", + label: "Usage", + type: "single_select", + options: [{ key: "owner", label: "Owner", color: "#1976d2" }], + }, + { key: "encrypted", label: "Encrypted", type: "boolean" }, + ], +} as unknown as RelationType; + const noSchema: RelationType = { key: "relBare", label: "relates to", @@ -95,23 +142,32 @@ const noSchema: RelationType = { attributes_schema: [], } as unknown as RelationType; -describe("hasRelationSubtypes", () => { +describe("hasEditableRelationAttributes", () => { it("returns true when a single_select field has options", () => { - expect(hasRelationSubtypes(usageType)).toBe(true); - expect(hasRelationSubtypes(flowOnly)).toBe(true); - expect(hasRelationSubtypes(multiDimension)).toBe(true); + expect(hasEditableRelationAttributes(usageType)).toBe(true); + expect(hasEditableRelationAttributes(flowOnly)).toBe(true); + expect(hasEditableRelationAttributes(multiDimension)).toBe(true); + }); + + it("returns true for a schema of nothing but boolean flags", () => { + // Gating on single_select alone is what left these values unsettable. + expect(hasEditableRelationAttributes(flagsOnly)).toBe(true); + }); + + it("returns true when flags and selects are mixed", () => { + expect(hasEditableRelationAttributes(mixedDimensions)).toBe(true); }); - it("returns false when a single_select field has no options (no subtypes)", () => { - expect(hasRelationSubtypes(noOptions)).toBe(false); + it("returns false when a single_select field has no options (nothing to pick)", () => { + expect(hasEditableRelationAttributes(noOptions)).toBe(false); }); it("returns false for an empty attributes_schema", () => { - expect(hasRelationSubtypes(noSchema)).toBe(false); + expect(hasEditableRelationAttributes(noSchema)).toBe(false); }); it("returns false when relationType is undefined", () => { - expect(hasRelationSubtypes(undefined)).toBe(false); + expect(hasEditableRelationAttributes(undefined)).toBe(false); }); }); @@ -156,4 +212,102 @@ describe("relationAttributeBadges", () => { it("returns an empty array when relationType is undefined", () => { expect(relationAttributeBadges(undefined, { usageType: "owner" })).toEqual([]); }); + + it("returns a flag badge for each boolean that is switched on", () => { + const badges = relationAttributeBadges(flagsOnly, { + crudCreate: true, + crudRead: true, + crudDelete: false, + }); + expect(badges.map((b) => b.fieldKey)).toEqual(["crudCreate", "crudRead"]); + expect(badges.every((b) => b.isFlag)).toBe(true); + // A flag has no option entity, so its label doubles as its value. + expect(badges.map((b) => b.optionLabel)).toEqual(["Create", "Read"]); + expect(badges.map((b) => b.optionKey)).toEqual(["true", "true"]); + }); + + it("ignores booleans that are false or absent", () => { + expect(relationAttributeBadges(flagsOnly, { crudCreate: false })).toEqual([]); + expect(relationAttributeBadges(flagsOnly, {})).toEqual([]); + }); + + it("returns flags and select values together, in schema order", () => { + const badges = relationAttributeBadges(mixedDimensions, { + archived: true, + usageType: "owner", + encrypted: true, + }); + expect(badges.map((b) => b.fieldKey)).toEqual(["archived", "usageType", "encrypted"]); + expect(badges.map((b) => !!b.isFlag)).toEqual([true, false, true]); + expect(badges[1].color).toBe("#1976d2"); + }); +}); + +describe("RelationAttributesEditor — boolean flags", () => { + it("renders one checkbox per boolean field, labelled from the schema", () => { + render( + , + ); + + expect(screen.getAllByRole("checkbox")).toHaveLength(4); + for (const label of ["Create", "Read", "Update", "Delete"]) { + expect(screen.getByRole("checkbox", { name: label })).toBeInTheDocument(); + } + }); + + it("reflects the stored value", () => { + render( + , + ); + + expect(screen.getByRole("checkbox", { name: "Create" })).toBeChecked(); + expect(screen.getByRole("checkbox", { name: "Read" })).not.toBeChecked(); + }); + + it("ticking a flag merges it into the attributes bag", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + , + ); + + await user.click(screen.getByRole("checkbox", { name: "Create" })); + + expect(onChange).toHaveBeenCalledWith({ crudRead: true, crudCreate: true }); + }); + + it("unticking a flag removes the key rather than storing false", async () => { + // "Never ticked" and "ticked then unticked" must look identical to every + // reader — badges, filters and the matrix all treat absent as not-set. + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + , + ); + + await user.click(screen.getByRole("checkbox", { name: "Create" })); + + expect(onChange).toHaveBeenCalledWith({ crudRead: true }); + }); + + it("renders flags and selects together", () => { + render( + , + ); + + expect(screen.getAllByRole("checkbox")).toHaveLength(2); + expect(screen.getByRole("combobox")).toBeInTheDocument(); + }); }); diff --git a/frontend/src/features/cards/sections/RelationAttributesEditor.tsx b/frontend/src/features/cards/sections/RelationAttributesEditor.tsx index b074a462e..5a3129536 100644 --- a/frontend/src/features/cards/sections/RelationAttributesEditor.tsx +++ b/frontend/src/features/cards/sections/RelationAttributesEditor.tsx @@ -1,5 +1,7 @@ import Box from "@mui/material/Box"; +import Checkbox from "@mui/material/Checkbox"; import FormControl from "@mui/material/FormControl"; +import FormControlLabel from "@mui/material/FormControlLabel"; import InputLabel from "@mui/material/InputLabel"; import Select from "@mui/material/Select"; import MenuItem from "@mui/material/MenuItem"; @@ -21,10 +23,11 @@ interface Props { /** * Renders the editable inputs declared by a relation type's - * `attributes_schema`. Only the field types actually used by built-in - * relation types are wired here (single_select today). The flow-direction - * field renders option labels using the relation type's own forward / - * reverse labels so the user reads concrete wording, not generic + * `attributes_schema` — `single_select` pickers and `boolean` flags. Nothing + * here is keyed to a particular attribute: the inputs are derived from the + * schema, so an admin-defined dimension renders exactly like a built-in one. + * The flow-direction field renders option labels using the relation type's own + * forward / reverse labels so the user reads concrete wording, not generic * "forward / reverse" keys. */ export default function RelationAttributesEditor({ @@ -42,34 +45,69 @@ export default function RelationAttributesEditor({ const schema = relationType.attributes_schema ?? []; if (schema.length === 0) return null; + const setField = (field: FieldDef) => (next: unknown) => { + const merged = { ...value }; + if (next === undefined || next === "" || next === null) { + delete merged[field.key]; + } else { + merged[field.key] = next; + } + onChange(merged); + }; + return ( - {schema.map((field) => ( - { - const merged = { ...value }; - if (next === undefined || next === "" || next === null) { - delete merged[field.key]; - } else { - merged[field.key] = next; - } - onChange(merged); - }} - fieldLabel={fieldLabel} - optLabel={optLabel} - relLabel={relLabel} - t={t} - disabled={disabled} - /> - ))} + {groupFields(schema).map((group, gi) => + group.flags ? ( + // Consecutive flags render as one wrapping row of checkboxes so a set + // of them reads as a single control group rather than a tall stack. + + {group.fields.map((field) => ( + + ))} + + ) : ( + + ), + )} ); } +/** Collapse runs of consecutive `boolean` fields into one group; everything else stays alone. */ +function groupFields(schema: FieldDef[]): { flags: boolean; fields: FieldDef[] }[] { + const groups: { flags: boolean; fields: FieldDef[] }[] = []; + for (const field of schema) { + const flags = field.type === "boolean"; + const last = groups[groups.length - 1]; + if (flags && last?.flags) last.fields.push(field); + else groups.push({ flags, fields: [field] }); + } + return groups; +} + interface FieldInputProps { field: FieldDef; relationType: RelationType; @@ -113,7 +151,28 @@ function FieldInput({ field, relationType, value, onChange, fieldLabel, optLabel ); } - // Other field types (text, boolean, etc.) can be added here as relation + if (field.type === "boolean") { + return ( + onChange(e.target.checked ? true : undefined)} + /> + } + label={{label}} + sx={{ mr: 0 }} + /> + ); + } + + // Other field types (text, number, date, …) can be added here as relation // attribute schemas grow. We deliberately keep this thin until needed. return null; } @@ -197,17 +256,22 @@ export function hasRelationAttributes(relationType: RelationType | undefined): b } /** - * Returns true only when a relation type declares at least one editable - * "subtype" — a single_select field with options. Stricter than - * {@link hasRelationAttributes} (which is true for any schema entry, even an - * options-less field), so UI gates don't surface an empty attribute editor / - * "label" icon for relations that have no actual subtypes to pick. + * Returns true only when a relation type declares at least one attribute the + * user can actually edit — a `single_select` with options, or a `boolean` flag. + * Stricter than {@link hasRelationAttributes} (which is true for any schema + * entry, even an options-less or unrenderable field), so UI gates don't surface + * an empty attribute editor for relations with nothing to set. + * + * Booleans count: a relation type whose only dimensions are flags (the seeded + * CRUD pairs, say) still needs the editor, and gating on `single_select` alone + * is what made those values unsettable. */ -export function hasRelationSubtypes(relationType: RelationType | undefined): boolean { +export function hasEditableRelationAttributes(relationType: RelationType | undefined): boolean { return ( !!relationType && (relationType.attributes_schema ?? []).some( - (f) => f.type === "single_select" && (f.options ?? []).length > 0, + (f) => + f.type === "boolean" || (f.type === "single_select" && (f.options ?? []).length > 0), ) ); } @@ -220,16 +284,23 @@ export interface RelationAttributeBadge { optionLabel: string; optionTranslations?: { [k: string]: string }; color?: string; + /** + * True for a `boolean` dimension that is switched on. A flag has no option + * entity, so `optionLabel` mirrors the field label — callers that print + * "field: value" should print just the label for these. + */ + isFlag?: boolean; } /** - * Generic counterpart to {@link flowDirectionBadge}: returns EVERY - * `single_select` attribute (other than `flowDirection`, which renders as a - * directional icon) that has a value set, so the caller can render one labelled - * chip per value (e.g. both `usageType` and `criticality` on a - * BusinessProcess→Application relation). Labels are returned raw (with their - * `translations`) so the caller resolves them with the locale-aware label - * resolver. Returns an empty array when nothing is set. + * Generic counterpart to {@link flowDirectionBadge}: returns EVERY attribute + * that carries a value — `single_select` values (other than `flowDirection`, + * which renders as a directional icon) and `boolean` flags that are on — so the + * caller can render one labelled chip each (e.g. both `usageType` and + * `criticality` on a BusinessProcess→Application relation, or the flags on a + * CRUD-style relation). Labels are returned raw (with their `translations`) so + * the caller resolves them with the locale-aware label resolver. Returns an + * empty array when nothing is set. */ export function relationAttributeBadges( relationType: RelationType | undefined, @@ -238,8 +309,24 @@ export function relationAttributeBadges( if (!relationType) return []; const badges: RelationAttributeBadge[] = []; for (const field of relationType.attributes_schema ?? []) { - if (field.type !== "single_select" || field.key === "flowDirection") continue; + if (field.key === "flowDirection") continue; const v = attributes?.[field.key]; + + if (field.type === "boolean") { + if (v !== true) continue; + badges.push({ + fieldKey: field.key, + fieldLabel: field.label, + fieldTranslations: field.translations, + optionKey: "true", + optionLabel: field.label, + optionTranslations: field.translations, + isFlag: true, + }); + continue; + } + + if (field.type !== "single_select") continue; if (typeof v !== "string" || !v) continue; const opt = (field.options ?? []).find((o) => o.key === v); if (!opt) continue; diff --git a/frontend/src/features/cards/sections/RelationsSection.tsx b/frontend/src/features/cards/sections/RelationsSection.tsx index 817ed5a5b..163a22fcc 100644 --- a/frontend/src/features/cards/sections/RelationsSection.tsx +++ b/frontend/src/features/cards/sections/RelationsSection.tsx @@ -46,7 +46,7 @@ import DescendantRelationsDrawer from "./DescendantRelationsDrawer"; import RelationAttributesEditor, { flowDirectionBadge, relationAttributeBadges, - hasRelationSubtypes, + hasEditableRelationAttributes, type RelationAttributes, } from "./RelationAttributesEditor"; import { readableTextColor } from "@/lib/color"; @@ -311,7 +311,7 @@ function RelationGroup({ const [attrsRelation, setAttrsRelation] = useState(null); const [rollupOpen, setRollupOpen] = useState(false); - const rtHasSubtypes = hasRelationSubtypes(rt); + const rtHasAttributes = hasEditableRelationAttributes(rt); const otherTypeKey = isSource ? rt.target_type_key : rt.source_type_key; const otherType = getType(otherTypeKey); @@ -410,9 +410,12 @@ function RelationGroup({ ? t(`relations.flowDirection.${flowBadge.value}`) : attrBadges.length > 0 ? attrBadges - .map( - (b) => - `${rl(b.fieldLabel, b.fieldTranslations)}: ${rl(b.optionLabel, b.optionTranslations)}`, + .map((b) => + // A flag has no option entity — its label IS the value, so + // printing "field: value" would read "Create: Create". + b.isFlag + ? rl(b.fieldLabel, b.fieldTranslations) + : `${rl(b.fieldLabel, b.fieldTranslations)}: ${rl(b.optionLabel, b.optionTranslations)}`, ) .join(", ") : t("relations.editAttributes"); @@ -433,7 +436,7 @@ function RelationGroup({ }} /> ))} - {rtHasSubtypes && canManageRelations && ( + {rtHasAttributes && canManageRelations && ( )} - {rtHasSubtypes && attrsRelation && ( + {rtHasAttributes && attrsRelation && ( - {selectedRT && hasRelationSubtypes(selectedRT) && ( + {selectedRT && hasEditableRelationAttributes(selectedRT) && ( {t("relations.optionalDetails")} diff --git a/frontend/src/features/diagrams/RelationPickerDialog.tsx b/frontend/src/features/diagrams/RelationPickerDialog.tsx index 701c27222..b73d27652 100644 --- a/frontend/src/features/diagrams/RelationPickerDialog.tsx +++ b/frontend/src/features/diagrams/RelationPickerDialog.tsx @@ -14,7 +14,7 @@ import Chip from "@mui/material/Chip"; import MaterialSymbol from "@/components/MaterialSymbol"; import { useRelationLabel } from "@/hooks/useResolveLabel"; import RelationAttributesEditor, { - hasRelationSubtypes, + hasEditableRelationAttributes, type RelationAttributes, } from "@/features/cards/sections/RelationAttributesEditor"; import type { RelationType } from "@/types"; @@ -88,7 +88,7 @@ export default function RelationPickerDialog({ } const handlePickType = (rt: RelationType, direction: "as-is" | "reversed") => { - if (hasRelationSubtypes(rt)) { + if (hasEditableRelationAttributes(rt)) { setPicked({ rt, direction }); setAttrs({}); } else { diff --git a/frontend/src/features/reports/MatrixFilterBar.tsx b/frontend/src/features/reports/MatrixFilterBar.tsx new file mode 100644 index 000000000..963d0eef7 --- /dev/null +++ b/frontend/src/features/reports/MatrixFilterBar.tsx @@ -0,0 +1,201 @@ +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import Box from "@mui/material/Box"; +import Chip from "@mui/material/Chip"; +import Tooltip from "@mui/material/Tooltip"; +import Typography from "@mui/material/Typography"; +import ToggleButton from "@mui/material/ToggleButton"; +import ToggleButtonGroup from "@mui/material/ToggleButtonGroup"; +import MaterialSymbol from "@/components/MaterialSymbol"; +import FilterSelect from "@/components/FilterSelect"; +import { useFieldLabel, useRelationLabel } from "@/hooks/useResolveLabel"; +import type { RelationType } from "@/types"; +import type { MatrixDimension, MatrixValue } from "./matrixDimensions"; + +export interface MatrixFilterState { + /** Relation-type keys to keep. Empty means all. */ + relationTypes: string[]; + /** `${relationTypeKey}.${fieldKey}` → the values to keep. */ + attrValues: Record; + direction: "any" | "forward" | "reverse"; +} + +interface Props { + /** Relation types connecting the two axes, in either orientation. */ + relationTypes: RelationType[]; + dimensions: MatrixDimension[]; + values: MatrixValue[]; + state: MatrixFilterState; + onChange: (next: MatrixFilterState) => void; + activeCount: number; +} + +/** Filters shown before the "+N more" chip has been expanded. */ +const COLLAPSED_FILTERS = 3; + +/** + * Relation filter for the Matrix report. + * + * Which controls appear is decided entirely by the metamodel: the relation + * types that connect the chosen axes, and the `boolean` / `single_select` + * dimensions those types declare. An admin who adds a dimension gets a filter + * for it with no code change, and a pair whose relations carry no values shows + * only the controls that still mean something. + */ +export default function MatrixFilterBar({ + relationTypes, + dimensions, + values, + state, + onChange, + activeCount, +}: Props) { + const { t } = useTranslation(["reports", "common"]); + const fieldLabel = useFieldLabel(); + const relationLabel = useRelationLabel(); + const [showAll, setShowAll] = useState(false); + + // Only the relation types actually in scope contribute filters, so narrowing + // by relation type also narrows the attribute filters on offer. + const scopedTypeKeys = state.relationTypes.length > 0 + ? new Set(state.relationTypes) + : new Set(relationTypes.map((rt) => rt.key)); + + const filterableDimensions = useMemo( + () => dimensions.filter( + (d) => d.kind !== "scalar" && scopedTypeKeys.has(d.relationTypeKey), + ), + [dimensions, scopedTypeKeys], + ); + + if (relationTypes.length === 0) return null; + + const multipleTypes = relationTypes.length > 1; + const visibleDimensions = showAll + ? filterableDimensions + : filterableDimensions.slice(0, COLLAPSED_FILTERS); + const hiddenCount = filterableDimensions.length - visibleDimensions.length; + + const optionsFor = (dimension: MatrixDimension) => { + if (dimension.kind === "flag") { + // A flag is tri-state in storage — on, off, or never set. "Off" and + // "unset" are different questions, so both are offered. + return [ + { key: "true", label: t("common:labels.yes") }, + { key: "false", label: t("common:labels.no") }, + ]; + } + return values + .filter((v) => v.dimensionId === dimension.id) + .map((v) => ({ key: v.optionKey as string, label: v.label, color: v.color })); + }; + + const labelFor = (dimension: MatrixDimension) => { + const base = fieldLabel(dimension.field); + if (!multipleTypes) return base; + const rt = relationTypes.find((r) => r.key === dimension.relationTypeKey); + return rt ? `${relationLabel(rt)} · ${base}` : base; + }; + + const setAttr = (dimensionId: string, next: string[]) => { + const attrValues = { ...state.attrValues }; + if (next.length === 0) delete attrValues[dimensionId]; + else attrValues[dimensionId] = next; + onChange({ ...state, attrValues }); + }; + + return ( + + + {t("matrix.filters")} + + + {multipleTypes && ( + ({ key: rt.key, label: relationLabel(rt) }))} + value={state.relationTypes} + onChange={(next) => onChange({ ...state, relationTypes: next })} + /> + )} + + next && onChange({ ...state, direction: next })} + > + + + + + + + + + + + + + + + + + + + + + + + + {visibleDimensions.map((dimension) => ( + setAttr(dimension.id, next)} + /> + ))} + + {hiddenCount > 0 && ( + } + label={t("matrix.moreFilters", { count: hiddenCount })} + onClick={() => setShowAll(true)} + /> + )} + + {activeCount > 0 && ( + <> + + {t("matrix.activeFilters", { count: activeCount })} + + } + label={t("matrix.clearFilters")} + onClick={() => onChange({ relationTypes: [], attrValues: {}, direction: "any" })} + /> + + )} + + ); +} diff --git a/frontend/src/features/reports/MatrixReport.test.tsx b/frontend/src/features/reports/MatrixReport.test.tsx new file mode 100644 index 000000000..ba8588e2c --- /dev/null +++ b/frontend/src/features/reports/MatrixReport.test.tsx @@ -0,0 +1,414 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router"; +import MatrixReport from "./MatrixReport"; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +const mockNavigate = vi.fn(); +vi.mock("react-router", async () => { + const actual = await vi.importActual("react-router"); + return { ...actual, useNavigate: () => mockNavigate }; +}); + +vi.mock("@/api/client", async () => { + const actual = await vi.importActual("@/api/client"); + return { ...actual, api: { get: vi.fn(), post: vi.fn() } }; +}); + +vi.mock("@/hooks/useMetamodel", () => ({ useMetamodel: vi.fn() })); +vi.mock("@/hooks/useSavedReport", () => ({ useSavedReport: vi.fn() })); +vi.mock("@/hooks/useThumbnailCapture", () => ({ useThumbnailCapture: vi.fn() })); +vi.mock("./SaveReportDialog", () => ({ default: () => null })); +vi.mock("@/components/CardDetailSidePanel", () => ({ default: () => null })); + +import { api } from "@/api/client"; +import { useMetamodel } from "@/hooks/useMetamodel"; +import { useSavedReport } from "@/hooks/useSavedReport"; +import { useThumbnailCapture } from "@/hooks/useThumbnailCapture"; +import { createRef } from "react"; + +// --------------------------------------------------------------------------- +// Test data +// +// The attribute keys are arbitrary on purpose: the report reads them out of the +// relation type's `attributes_schema`, so these fixtures exercise the same code +// path an admin-defined dimension would. +// --------------------------------------------------------------------------- + +const cardTypes = (hierarchical = false) => [ + { key: "Application", label: "Application", icon: "apps", color: "#0f7eb5", has_hierarchy: hierarchical }, + { key: "BusinessCapability", label: "Business Capability", icon: "account_tree", color: "#003399", has_hierarchy: false }, +]; + +const ATTRIBUTED_RELATION = { + key: "relAppToBc", + label: "uses", + reverse_label: "is used by", + source_type_key: "Application", + target_type_key: "BusinessCapability", + cardinality: "n:m", + built_in: true, + is_hidden: false, + attributes_schema: [ + { key: "alpha", label: "Create", type: "boolean" }, + { key: "beta", label: "Read", type: "boolean" }, + ], +}; + +const BARE_RELATION = { + ...ATTRIBUTED_RELATION, + key: "relAppToBcBare", + attributes_schema: [], +}; + +const PAYLOAD = { + rows: [ + { id: "app-1", name: "App One", parent_id: null }, + { id: "app-2", name: "App Two", parent_id: null }, + ], + columns: [ + { id: "bc-1", name: "Cap One", parent_id: null }, + { id: "bc-2", name: "Cap Two", parent_id: null }, + ], + relation_types: ["relAppToBc"], + attr_sets: [{}, { alpha: true, beta: true }], + intersections: [{ row_id: "app-1", col_id: "bc-1", e: [[0, "f", 1]] }], + truncated: false, +}; + +let metamodelRelationTypes: unknown[] = [ATTRIBUTED_RELATION]; +let metamodelCardTypes: unknown[] = cardTypes(); + +beforeEach(() => { + vi.clearAllMocks(); + metamodelRelationTypes = [ATTRIBUTED_RELATION]; + metamodelCardTypes = cardTypes(); + + vi.mocked(useMetamodel).mockImplementation( + () => + ({ + types: metamodelCardTypes, + relationTypes: metamodelRelationTypes, + loading: false, + getType: () => undefined, + getRelationsForType: () => [], + invalidateCache: vi.fn(), + }) as unknown as ReturnType, + ); + + vi.mocked(useSavedReport).mockReturnValue({ + savedReport: null, + savedReportName: null, + saveDialogOpen: false, + setSaveDialogOpen: vi.fn(), + loadedConfig: null, + consumeConfig: vi.fn().mockReturnValue(null), + resetSavedReport: vi.fn(), + persistConfig: vi.fn(), + resetAll: vi.fn(), + reportType: "matrix", + } as unknown as ReturnType); + + vi.mocked(useThumbnailCapture).mockReturnValue({ + chartRef: createRef(), + thumbnail: undefined, + captureAndSave: vi.fn(), + } as unknown as ReturnType); + + vi.mocked(api.get).mockResolvedValue(PAYLOAD); + + // The sticky multi-row header measures itself on resize; jsdom has no + // ResizeObserver, and the measurement is irrelevant to what these assert. + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); +}); + +function renderMatrix() { + return render( + + + , + ); +} + +/** The MetricCard tile carrying a given label. */ +function metricCard(label: string): HTMLElement { + return screen.getByText(label).closest(".MuiPaper-root") as HTMLElement; +} + +/** Widths declared on the , in document order. */ +function colWidths(): string[] { + return Array.from(document.querySelectorAll("colgroup col")).map( + (c) => (c as HTMLElement).style.width, + ); +} + +async function selectCellMode(user: ReturnType, label: string) { + await user.click(screen.getByLabelText("Cell Display")); + await user.click(await screen.findByRole("option", { name: label })); +} + +describe("MatrixReport", () => { + it("renders the axes and their cards", async () => { + renderMatrix(); + + expect(await screen.findByText("App One")).toBeInTheDocument(); + expect(screen.getByText("Cap One")).toBeInTheDocument(); + expect(screen.getByText("App Two")).toBeInTheDocument(); + }); + + it("requests the default axes", async () => { + renderMatrix(); + + await waitFor(() => { + expect(api.get).toHaveBeenCalledWith( + expect.stringContaining("row_type=Application"), + expect.anything(), + ); + }); + }); + + it("counts every relation, not every pair", async () => { + vi.mocked(api.get).mockResolvedValue({ + ...PAYLOAD, + intersections: [{ row_id: "app-1", col_id: "bc-1", e: [[0, "f", 0], [0, "r", 0]] }], + }); + renderMatrix(); + + // Two relations between one pair: the Relations metric says 2. + await waitFor(() => { + expect(metricCard("Relations")).toHaveTextContent("2"); + }); + }); + + it("reports cards that take part in no relation as gaps", async () => { + renderMatrix(); + + // App Two and Object Two are linked to nothing. + const labels = await screen.findAllByText(/with no relation/); + expect(labels).toHaveLength(2); + for (const label of labels) { + expect(label.closest(".MuiPaper-root")).toHaveTextContent("1"); + } + }); + + describe("value cell modes", () => { + it("shows the codes derived from the relation's own field labels", async () => { + const user = userEvent.setup(); + renderMatrix(); + await screen.findByText("App One"); + + await selectCellMode(user, "Values (codes)"); + + // "Create" and "Read" — first letters, taken from the schema labels. + const table = document.querySelector("table") as HTMLElement; + await waitFor(() => { + expect(within(table).getByText("C")).toBeInTheDocument(); + expect(within(table).getByText("R")).toBeInTheDocument(); + }); + }); + + it("shows full labels and a legend in the labels mode", async () => { + const user = userEvent.setup(); + renderMatrix(); + await screen.findByText("App One"); + + await selectCellMode(user, "Values (labels)"); + + await waitFor(() => { + // Once in the cell, once in the legend. + expect(screen.getAllByText("Create").length).toBeGreaterThanOrEqual(1); + }); + }); + + it("widens only the data columns in the labels mode", async () => { + // The sticky row headers are positioned at `colIdx * ROW_HEADER_COL_WIDTH`, + // so the header columns must keep that width whatever the cells do — + // mixing the two is what knocked them out of alignment in #846. + const user = userEvent.setup(); + renderMatrix(); + await screen.findByText("App One"); + + expect(colWidths()).toEqual(["140px", "32px", "32px", "44px"]); + + await selectCellMode(user, "Values (labels)"); + + await waitFor(() => { + expect(colWidths()).toEqual(["140px", "120px", "120px", "44px"]); + }); + }); + + it("offers no value modes when the relations carry no values", async () => { + metamodelRelationTypes = [BARE_RELATION]; + const user = userEvent.setup(); + renderMatrix(); + await screen.findByText("App One"); + + await user.click(screen.getByLabelText("Cell Display")); + + const option = (await screen.findByText("Values (codes)")).closest("li"); + expect(option).toHaveAttribute("aria-disabled", "true"); + }); + }); + + describe("filter bar", () => { + it("offers a filter for each dimension the relation type declares", async () => { + renderMatrix(); + await screen.findByText("App One"); + + expect(screen.getByText("Relation filter")).toBeInTheDocument(); + expect(screen.getByLabelText("Create")).toBeInTheDocument(); + expect(screen.getByLabelText("Read")).toBeInTheDocument(); + }); + + it("stays out of the way when no relation type connects the axes", async () => { + metamodelRelationTypes = []; + renderMatrix(); + await screen.findByText("App One"); + + expect(screen.queryByText("Relation filter")).not.toBeInTheDocument(); + }); + + it("says so plainly when nothing connects the axes and there is nothing to draw", async () => { + metamodelRelationTypes = []; + vi.mocked(api.get).mockResolvedValue({ ...PAYLOAD, rows: [], columns: [], intersections: [] }); + renderMatrix(); + + expect(await screen.findByText(/No relation type connects/)).toBeInTheDocument(); + }); + + it("sends the chosen value to the server", async () => { + const user = userEvent.setup(); + renderMatrix(); + await screen.findByText("App One"); + + await user.click(screen.getByLabelText("Create")); + await user.click(await screen.findByRole("option", { name: "Yes" })); + + await waitFor(() => { + expect(api.get).toHaveBeenCalledWith( + expect.stringContaining("attr=relAppToBc.alpha%3Atrue"), + expect.anything(), + ); + }); + }); + + it("sends the chosen direction to the server", async () => { + const user = userEvent.setup(); + renderMatrix(); + await screen.findByText("App One"); + + await user.click(screen.getByRole("button", { name: "Row is the target" })); + + await waitFor(() => { + expect(api.get).toHaveBeenCalledWith( + expect.stringContaining("direction=reverse"), + expect.anything(), + ); + }); + }); + }); + + it("swaps the axes when transposed", async () => { + const user = userEvent.setup(); + renderMatrix(); + await screen.findByText("App One"); + + await user.click(screen.getByRole("button", { name: "Swap rows and columns" })); + + await waitFor(() => { + expect(api.get).toHaveBeenCalledWith( + expect.stringContaining("row_type=BusinessCapability&col_type=Application"), + expect.anything(), + ); + }); + }); + + it("shows a relation attached to a parent card, on a row of its own", async () => { + // The reported bug: the app column was there, the intersection was blank + // and the total read 0, because the parent org was only a group header + // spanning its children and had no row to put the dot in. + metamodelCardTypes = cardTypes(true); + vi.mocked(api.get).mockResolvedValue({ + ...PAYLOAD, + rows: [ + { id: "app-parent", name: "Parent App", parent_id: null }, + { id: "app-child", name: "Child App", parent_id: "app-parent" }, + ], + intersections: [{ row_id: "app-parent", col_id: "bc-1", e: [[0, "f", 0]] }], + }); + renderMatrix(); + + await screen.findByText("Parent App"); + // The parent keeps its group header and gains its own line beneath it. + expect(screen.getByText("(itself)")).toBeInTheDocument(); + // One relation in the payload, one in the grid. + await waitFor(() => { + expect(metricCard("Relations")).toHaveTextContent("1"); + }); + const table = document.querySelector("table") as HTMLElement; + expect(within(table).getAllByText("1").length).toBeGreaterThan(0); + }); + + it("keeps a related parent whose children are unrelated when hiding unrelated cards", async () => { + const user = userEvent.setup(); + metamodelCardTypes = cardTypes(true); + vi.mocked(api.get).mockResolvedValue({ + ...PAYLOAD, + rows: [ + { id: "app-parent", name: "Parent App", parent_id: null }, + { id: "app-child", name: "Child App", parent_id: "app-parent" }, + ], + intersections: [{ row_id: "app-parent", col_id: "bc-1", e: [[0, "f", 0]] }], + }); + renderMatrix(); + await screen.findByText("Parent App"); + + await user.click(screen.getByRole("checkbox", { name: /Hide unrelated cards/ })); + + // The toggle keeps related cards — the parent is related, so it stays. + await waitFor(() => { + expect(screen.getByText("Parent App")).toBeInTheDocument(); + }); + expect(screen.queryByText("Child App")).not.toBeInTheDocument(); + }); + + it("loads a config saved before the filters existed", async () => { + // Older saved reports carry none of the new keys; they must land on the + // defaults rather than throwing on the way to building the request path. + vi.mocked(useSavedReport).mockReturnValue({ + savedReport: null, + savedReportName: "Old report", + saveDialogOpen: false, + setSaveDialogOpen: vi.fn(), + loadedConfig: { rowType: "Application", colType: "BusinessCapability", cellMode: "count" }, + consumeConfig: vi + .fn() + .mockReturnValue({ rowType: "Application", colType: "BusinessCapability", cellMode: "count" }), + resetSavedReport: vi.fn(), + persistConfig: vi.fn(), + resetAll: vi.fn(), + reportType: "matrix", + } as unknown as ReturnType); + + renderMatrix(); + + expect(await screen.findByText("App One")).toBeInTheDocument(); + await waitFor(() => { + expect(api.get).toHaveBeenCalledWith( + expect.not.stringContaining("attr="), + expect.anything(), + ); + }); + }); +}); diff --git a/frontend/src/features/reports/MatrixReport.tsx b/frontend/src/features/reports/MatrixReport.tsx index b7c8c8d4e..da7f43a39 100644 --- a/frontend/src/features/reports/MatrixReport.tsx +++ b/frontend/src/features/reports/MatrixReport.tsx @@ -15,39 +15,63 @@ import ListItemText from "@mui/material/ListItemText"; import Tooltip from "@mui/material/Tooltip"; import FormControlLabel from "@mui/material/FormControlLabel"; import Switch from "@mui/material/Switch"; +import IconButton from "@mui/material/IconButton"; +import InputAdornment from "@mui/material/InputAdornment"; +import Alert from "@mui/material/Alert"; import MaterialSymbol from "@/components/MaterialSymbol"; import ReportShell from "./ReportShell"; import SaveReportDialog from "./SaveReportDialog"; import MetricCard from "./MetricCard"; +import ReportLegend from "./ReportLegend"; +import MatrixFilterBar, { type MatrixFilterState } from "./MatrixFilterBar"; import { useMetamodel } from "@/hooks/useMetamodel"; import { useSavedReport } from "@/hooks/useSavedReport"; import { useThumbnailCapture } from "@/hooks/useThumbnailCapture"; -import { useTypeLabel } from "@/hooks/useResolveLabel"; +import { useDebouncedValue } from "@/hooks/useDebouncedValue"; +import { + useFieldLabel, + useOptionLabel, + useRelationLabel, + useTypeLabel, +} from "@/hooks/useResolveLabel"; import { useApiQuery } from "@/hooks/useApiQuery"; import CardDetailSidePanel from "@/components/CardDetailSidePanel"; +import { readableTextColor } from "@/lib/color"; +import type { ReportExportData } from "./reportExport"; import { - type MatrixItem, type TreeNode, + addSelfNodes, + cardIdOf, buildTree, pruneTreeToDepth, getLeafNodes, buildColumnHeaderRows, buildRowHeaderLayout, - aggregateCount, - getEffectiveLeafIds, buildAllNodesMap, filterRelatedSubtrees, } from "./matrixHierarchy"; +import { + type CellDatum, + type MatrixPayload, + DIR_FORWARD, + DIR_REVERSE, + buildCellMatrix, + getCell, + relatedCardIds, +} from "./matrixCells"; +import { + type MatrixValue, + buildValueIndex, +} from "./matrixDimensions"; -interface MatrixData { - rows: MatrixItem[]; - columns: MatrixItem[]; - intersections: { row_id: string; col_id: string }[]; -} +type MatrixData = MatrixPayload; -type CellMode = "exists" | "count"; +type CellMode = "exists" | "count" | "codes" | "labels"; type SortMode = "alpha" | "count" | "hierarchy"; +const CELL_MODES: CellMode[] = ["exists", "count", "codes", "labels"]; +const DIRECTIONS = ["any", "forward", "reverse"] as const; + const HEAT_COLORS_LIGHT = [ "#e3f2fd", "#bbdefb", "#90caf9", "#64b5f6", "#42a5f5", "#2196f3", "#1e88e5", "#1976d2", "#1565c0", "#0d47a1", @@ -73,6 +97,53 @@ function heatColor( const ROW_HEADER_COL_WIDTH = 140; // LEVEL_COLORS and CELL_BORDER moved inside component for theme access +/** + * Intersection column width. The dense width is what makes a large landscape + * scannable; the wide one is opted into by the Labels mode, where cells carry + * readable text instead of a glyph. + * + * This only ever applies to *data* columns. Row-header columns stay at + * ROW_HEADER_COL_WIDTH because their sticky offsets are computed from it + * (`left: colIdx * ROW_HEADER_COL_WIDTH`) — mixing the two is what knocked the + * hierarchical row headers out of alignment in #846. + */ +const DATA_COL_WIDTH_DENSE = 32; +const DATA_COL_WIDTH_WIDE = 120; + +/** Beyond this many cells the browser, not the data, becomes the bottleneck. */ +const LARGE_GRID_CELLS = 30_000; + +/** Glyphs that fit a dense cell before it has to fall back to "+n". */ +const MAX_DENSE_GLYPHS = 4; + +const EMPTY_FILTERS: MatrixFilterState = { relationTypes: [], attrValues: {}, direction: "any" }; + +/** Coerce a persisted filter blob back into a usable state, dropping anything malformed. */ +function sanitiseFilters(raw: unknown): MatrixFilterState { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return EMPTY_FILTERS; + const cfg = raw as Record; + const relationTypes = Array.isArray(cfg.relationTypes) + ? cfg.relationTypes.filter((v): v is string => typeof v === "string") + : []; + const attrValues: Record = {}; + if (cfg.attrValues && typeof cfg.attrValues === "object" && !Array.isArray(cfg.attrValues)) { + for (const [key, value] of Object.entries(cfg.attrValues as Record)) { + if (!Array.isArray(value)) continue; + const values = value.filter((v): v is string => typeof v === "string"); + if (values.length > 0) attrValues[key] = values; + } + } + const direction = DIRECTIONS.includes(cfg.direction as (typeof DIRECTIONS)[number]) + ? (cfg.direction as MatrixFilterState["direction"]) + : "any"; + return { relationTypes, attrValues, direction }; +} + +/** Case-insensitive name match, used by the row/column quick-search. */ +function matchesSearch(name: string, query: string): boolean { + return name.toLowerCase().includes(query.toLowerCase()); +} + // Depth control icon button styles const DEPTH_ICON_SIZE = 22; const depthBtnStyle = (disabled: boolean): React.CSSProperties => ({ @@ -122,8 +193,11 @@ export default function MatrixReport() { const countTextLow = isDark ? "#ccc" : "#333"; const countTextHigh = "#fff"; const countTextDiag = isDark ? "#aaa" : "#666"; - const { types, loading: ml } = useMetamodel(); + const { types, relationTypes, loading: ml } = useMetamodel(); const typeLabel = useTypeLabel(); + const fieldLabel = useFieldLabel(); + const optionLabel = useOptionLabel(); + const relationLabel = useRelationLabel(); const saved = useSavedReport("matrix"); const { chartRef, thumbnail, captureAndSave } = useThumbnailCapture(() => saved.setSaveDialogOpen(true)); const [rowType, setRowType] = useState("Application"); @@ -131,13 +205,30 @@ export default function MatrixReport() { const [sidePanelCardId, setSidePanelCardId] = useState(null); const [cellMode, setCellMode] = useState("exists"); const [hideEmpty, setHideEmpty] = useState(false); + const [showOnlyGaps, setShowOnlyGaps] = useState(false); const [sortRows, setSortRows] = useState("hierarchy"); const [sortCols, setSortCols] = useState("hierarchy"); const [hoveredRow, setHoveredRow] = useState(null); const [hoveredCol, setHoveredCol] = useState(null); const [popover, setPopover] = useState<{ el: HTMLElement; rowId: string; colId: string } | null>(null); + const [rowSearch, setRowSearch] = useState(""); + const [colSearch, setColSearch] = useState(""); + // Search filters the already-fetched cards, so no request is in flight — the + // debounce only spares the (expensive) re-derivation of the visible leaves. + const [debouncedRowSearch] = useDebouncedValue(rowSearch, 200); + const [debouncedColSearch] = useDebouncedValue(colSearch, 200); const tableRef = useRef(null); + // Relation filter. Keys in `attrValues` are `${relationTypeKey}.${fieldKey}`, + // matching the wire format the endpoint parses — the relation-type prefix is + // required because the same field key legitimately appears on both relation + // types when the two axes are connected in either direction. + const [filters, setFilters] = useState({ + relationTypes: [], + attrValues: {}, + direction: "any", + }); + // Depth control state (Infinity = fully expanded) const [rowExpandedDepth, setRowExpandedDepth] = useState(Infinity); const [colExpandedDepth, setColExpandedDepth] = useState(Infinity); @@ -174,31 +265,40 @@ export default function MatrixReport() { return () => observer.disconnect(); }, [measureHeaderOffsets]); - // Load saved report config + // Load saved report config. Every key is guarded, and the filter keys are + // shape-checked as well as presence-checked, so a report saved before they + // existed — or one hand-edited — loads onto the defaults instead of throwing. useEffect(() => { const cfg = saved.consumeConfig(); if (cfg) { if (cfg.rowType) setRowType(cfg.rowType as string); if (cfg.colType) setColType(cfg.colType as string); - if (cfg.cellMode) setCellMode(cfg.cellMode as CellMode); + if (typeof cfg.cellMode === "string" && CELL_MODES.includes(cfg.cellMode as CellMode)) { + setCellMode(cfg.cellMode as CellMode); + } if (cfg.hideEmpty !== undefined) setHideEmpty(cfg.hideEmpty as boolean); + if (typeof cfg.showOnlyGaps === "boolean") setShowOnlyGaps(cfg.showOnlyGaps); if (cfg.sortRows) setSortRows(cfg.sortRows as SortMode); if (cfg.sortCols) setSortCols(cfg.sortCols as SortMode); if (cfg.rowExpandedDepth !== undefined) setRowExpandedDepth(cfg.rowExpandedDepth as number); if (cfg.colExpandedDepth !== undefined) setColExpandedDepth(cfg.colExpandedDepth as number); + setFilters(sanitiseFilters(cfg.filters)); } }, [saved.loadedConfig]); // eslint-disable-line react-hooks/exhaustive-deps const getConfig = () => ({ - rowType, colType, cellMode, hideEmpty, sortRows, sortCols, + rowType, colType, cellMode, hideEmpty, showOnlyGaps, sortRows, sortCols, rowExpandedDepth: effectiveRowDepth, colExpandedDepth: effectiveColDepth, + filters, + // Row/column search is deliberately not persisted: reopening a saved report + // onto a near-empty grid with no visible cause is a support ticket. }); // Auto-persist config to localStorage useEffect(() => { saved.persistConfig(getConfig()); - }, [rowType, colType, cellMode, hideEmpty, sortRows, sortCols, rowExpandedDepth, colExpandedDepth]); // eslint-disable-line react-hooks/exhaustive-deps + }, [rowType, colType, cellMode, hideEmpty, showOnlyGaps, sortRows, sortCols, rowExpandedDepth, colExpandedDepth, filters]); // eslint-disable-line react-hooks/exhaustive-deps // Reset all parameters to defaults const handleReset = useCallback(() => { @@ -207,19 +307,42 @@ export default function MatrixReport() { setColType("BusinessCapability"); setCellMode("exists"); setHideEmpty(false); + setShowOnlyGaps(false); setSortRows("hierarchy"); setSortCols("hierarchy"); setRowExpandedDepth(Infinity); setColExpandedDepth(Infinity); + setFilters(EMPTY_FILTERS); + setRowSearch(""); + setColSearch(""); }, [saved]); // eslint-disable-line react-hooks/exhaustive-deps - // The axis labels below render from `rowType`/`colType`, so data for the - // previous axes must never survive a switch — it would draw a complete, - // convincing matrix under the wrong headers with nothing to signal it. - // `keepPreviousData: false` falls back to the existing spinner instead, and - // the hook discards a superseded response outright (#882). + // Keys and values are sorted so two equivalent filter sets always produce the + // same path — otherwise a re-render that rebuilt the object in a different + // key order would look like a new query and refetch. + const matrixPath = useMemo(() => { + const params = new URLSearchParams(); + params.set("row_type", rowType); + params.set("col_type", colType); + if (filters.relationTypes.length > 0) { + params.set("relation_types", [...filters.relationTypes].sort().join(",")); + } + for (const key of Object.keys(filters.attrValues).sort()) { + for (const value of [...(filters.attrValues[key] ?? [])].sort()) { + params.append("attr", `${key}:${value}`); + } + } + if (filters.direction !== "any") params.set("direction", filters.direction); + return `/reports/matrix?${params.toString()}`; + }, [rowType, colType, filters]); + + // The axis labels and filter chips below render from the current state, so + // data for a previous query must never survive: it would draw a complete, + // convincing matrix under headings and filters it does not match, with + // nothing to signal the disagreement. `keepPreviousData: false` falls back to + // the spinner, and the hook discards a superseded response outright (#882). const { data: matrixData, loading: matrixLoading } = useApiQuery( - `/reports/matrix?row_type=${rowType}&col_type=${colType}`, + matrixPath, { keepPreviousData: false }, ); const data = matrixData ?? null; @@ -239,38 +362,72 @@ export default function MatrixReport() { setColExpandedDepth(Infinity); }, [colType, types]); // eslint-disable-line react-hooks/exhaustive-deps - // Build lookup structures - const intersectionMap = useMemo(() => { - if (!data) return new Map(); - const m = new Map(); - for (const i of data.intersections) { - const k = `${i.row_id}:${i.col_id}`; - m.set(k, [...(m.get(k) || []), "1"]); - } - return m; - }, [data]); + // Filter keys are namespaced by relation type, so they are meaningless for a + // different axis pair — carrying them over would silently empty the new grid. + // Skipped on the first run so a saved report's filters survive being loaded. + const loadedAxes = useRef(null); + useEffect(() => { + const axes = `${rowType}|${colType}`; + if (loadedAxes.current !== null && loadedAxes.current !== axes) setFilters(EMPTY_FILTERS); + loadedAxes.current = axes; + }, [rowType, colType]); - // Ids of cards that participate in at least one real relationship (self-diagonal - // excluded), used by the "hide unrelated cards" toggle. Global to the report, so - // hidden rows and hidden columns never depend on each other. - const { relatedRowIds, relatedColIds } = useMemo(() => { - const rows = new Set(); - const cols = new Set(); - for (const i of data?.intersections || []) { - if (i.row_id === i.col_id) continue; // self-diagonal is not a relationship - rows.add(i.row_id); - cols.add(i.col_id); - } - return { relatedRowIds: rows, relatedColIds: cols }; - }, [data]); + // Relation types able to connect the two axes — at most one per ordered pair + // by the metamodel rule, so at most two here (one per orientation). Everything + // the filter bar, the legend, the cells and the export can show is derived + // from these types' own `attributes_schema`; no attribute is named in code. + const pairRelationTypes = useMemo( + () => relationTypes.filter( + (rt) => (rt.source_type_key === rowType && rt.target_type_key === colType) + || (rt.source_type_key === colType && rt.target_type_key === rowType), + ), + [relationTypes, rowType, colType], + ); + + const valueIndex = useMemo( + () => buildValueIndex(pairRelationTypes, { fieldLabel, optionLabel }), + [pairRelationTypes, fieldLabel, optionLabel], + ); + + // A pair whose relations carry no bounded values has nothing to code or + // label, so those cell modes stay unavailable rather than rendering blanks. + const hasCodeableValues = valueIndex.values.length > 0; + + // Ids of cards that participate in at least one relation *surviving the + // filter*, used by the hide-unrelated and show-only-gaps toggles. Global to + // the report, so hidden rows and hidden columns never depend on each other. + const { rows: relatedRowIds, columns: relatedColIds } = useMemo( + () => relatedCardIds(data), + [data], + ); + + // Row/column quick-search. An ancestor whose descendant matches survives, so + // searching never orphans a branch of the hierarchy. + const searchedRowIds = useMemo(() => { + if (!debouncedRowSearch.trim() || !data) return null; + return new Set( + data.rows.filter((r) => matchesSearch(r.name, debouncedRowSearch)).map((r) => r.id), + ); + }, [data, debouncedRowSearch]); + + const searchedColIds = useMemo(() => { + if (!debouncedColSearch.trim() || !data) return null; + return new Set( + data.columns.filter((c) => matchesSearch(c.name, debouncedColSearch)).map((c) => c.id), + ); + }, [data, debouncedColSearch]); // Build trees from raw data const rowTreeFull = useMemo(() => data ? buildTree(data.rows) : null, [data]); const colTreeFull = useMemo(() => data ? buildTree(data.columns) : null, [data]); - // Detect hierarchy from actual data - const rowHasHierarchy = data ? data.rows.some((r) => r.parent_id !== null) : false; - const colHasHierarchy = data ? data.columns.some((c) => c.parent_id !== null) : false; + // Prefer the metamodel over the data: a hierarchical type whose cards have no + // parent yet still deserves the option, and the answer must not flip while a + // fetch is in flight (which would leave the Select on a value it no longer offers). + const rowHasHierarchy = types.find((t) => t.key === rowType)?.has_hierarchy + ?? (data ? data.rows.some((r) => r.parent_id !== null) : false); + const colHasHierarchy = types.find((t) => t.key === colType)?.has_hierarchy + ?? (data ? data.columns.some((c) => c.parent_id !== null) : false); // Effective depth (clamped to actual max) const effectiveRowDepth = rowTreeFull ? Math.min( @@ -282,34 +439,96 @@ export default function MatrixReport() { colTreeFull.maxDepth, ) : 0; - // Pruned trees based on visible depth (only in hierarchy mode) + // Relations per card, straight off the payload's edges — one pass over the + // data rather than a full grid walk per axis. + const { cardRowCounts, cardColCounts } = useMemo(() => { + const rows = new Map(); + const cols = new Map(); + for (const i of data?.intersections ?? []) { + const n = (i.e ?? []).length; + if (n === 0) continue; + rows.set(i.row_id, (rows.get(i.row_id) ?? 0) + n); + cols.set(i.col_id, (cols.get(i.col_id) ?? 0) + n); + } + return { cardRowCounts: rows, cardColCounts: cols }; + }, [data]); + + // Which cards may appear at all. Coverage (hide-unrelated / only-gaps) and + // search intersect: searching inside a filtered view narrows it further + // rather than reopening what the filter closed. + const buildVisibleIds = ( + all: { id: string }[] | undefined, + related: Set, + searched: Set | null, + ): Set | null => { + let ids: Set | null = null; + if (hideEmpty) ids = related; + else if (showOnlyGaps) ids = new Set((all ?? []).filter((c) => !related.has(c.id)).map((c) => c.id)); + if (searched) ids = ids ? new Set([...searched].filter((id) => ids!.has(id))) : searched; + return ids; + }; + + const visibleRowIds = useMemo( + () => buildVisibleIds(data?.rows, relatedRowIds, searchedRowIds), + [data, relatedRowIds, searchedRowIds, hideEmpty, showOnlyGaps], // eslint-disable-line react-hooks/exhaustive-deps + ); + const visibleColIds = useMemo( + () => buildVisibleIds(data?.columns, relatedColIds, searchedColIds), + [data, relatedColIds, searchedColIds, hideEmpty, showOnlyGaps], // eslint-disable-line react-hooks/exhaustive-deps + ); + + // Cards that carry relations of their own AND have children. A card like that + // is otherwise only a group header spanning its children, with no cell row of + // its own, so its relations would have nowhere to land. + const parentsWithOwnRelations = ( + tree: ReturnType | null, + related: Set, + ): Set => { + const ids = new Set(); + if (!tree) return ids; + for (const id of related) { + if ((tree.allNodes.get(id)?.children.length ?? 0) > 0) ids.add(id); + } + return ids; + }; + + const rowSelfIds = useMemo( + () => parentsWithOwnRelations(rowTreeFull, relatedRowIds), + [rowTreeFull, relatedRowIds], // eslint-disable-line react-hooks/exhaustive-deps + ); + const colSelfIds = useMemo( + () => parentsWithOwnRelations(colTreeFull, relatedColIds), + [colTreeFull, relatedColIds], // eslint-disable-line react-hooks/exhaustive-deps + ); + + // Pruned trees based on visible depth (only in hierarchy mode). Self rows are + // added after pruning: a collapsed parent is already a leaf that stands for + // itself, so only a parent still showing its children needs one. const prunedRowRoots = useMemo(() => { if (!rowTreeFull || sortRows !== "hierarchy") return null; - const pruned = pruneTreeToDepth(rowTreeFull.roots, effectiveRowDepth); - return hideEmpty ? filterRelatedSubtrees(pruned, relatedRowIds) : pruned; - }, [rowTreeFull, effectiveRowDepth, sortRows, hideEmpty, relatedRowIds]); + const withSelf = addSelfNodes( + pruneTreeToDepth(rowTreeFull.roots, effectiveRowDepth), + rowSelfIds, + ); + return visibleRowIds ? filterRelatedSubtrees(withSelf, visibleRowIds) : withSelf; + }, [rowTreeFull, effectiveRowDepth, sortRows, visibleRowIds, rowSelfIds]); const prunedColRoots = useMemo(() => { if (!colTreeFull || sortCols !== "hierarchy") return null; - const pruned = pruneTreeToDepth(colTreeFull.roots, effectiveColDepth); - return hideEmpty ? filterRelatedSubtrees(pruned, relatedColIds) : pruned; - }, [colTreeFull, effectiveColDepth, sortCols, hideEmpty, relatedColIds]); + const withSelf = addSelfNodes( + pruneTreeToDepth(colTreeFull.roots, effectiveColDepth), + colSelfIds, + ); + return visibleColIds ? filterRelatedSubtrees(withSelf, visibleColIds) : withSelf; + }, [colTreeFull, effectiveColDepth, sortCols, visibleColIds, colSelfIds]); // Get pruned leaf nodes const leafRowNodes = useMemo(() => { if (prunedRowRoots) return getLeafNodes(prunedRowRoots); if (!data) return []; - const items = hideEmpty ? data.rows.filter((r) => relatedRowIds.has(r.id)) : [...data.rows]; + const items = visibleRowIds ? data.rows.filter((r) => visibleRowIds.has(r.id)) : [...data.rows]; if (sortRows === "count") { - const rc = new Map(); - for (const r of data.rows) { - let count = 0; - for (const c of data.columns) { - count += intersectionMap.get(`${r.id}:${c.id}`)?.length || 0; - } - rc.set(r.id, count); - } - items.sort((a, b) => (rc.get(b.id) || 0) - (rc.get(a.id) || 0)); + items.sort((a, b) => (cardRowCounts.get(b.id) ?? 0) - (cardRowCounts.get(a.id) ?? 0)); } else { items.sort((a, b) => a.name.localeCompare(b.name)); } @@ -317,22 +536,14 @@ export default function MatrixReport() { item, children: [], depth: 0, leafCount: 1, leafDescendants: [item.id], isPrunedGroup: false, originalLeafCount: 1, })); - }, [prunedRowRoots, data, sortRows, intersectionMap, hideEmpty, relatedRowIds]); + }, [prunedRowRoots, data, sortRows, cardRowCounts, visibleRowIds]); const leafColNodes = useMemo(() => { if (prunedColRoots) return getLeafNodes(prunedColRoots); if (!data) return []; - const items = hideEmpty ? data.columns.filter((c) => relatedColIds.has(c.id)) : [...data.columns]; + const items = visibleColIds ? data.columns.filter((c) => visibleColIds.has(c.id)) : [...data.columns]; if (sortCols === "count") { - const cc = new Map(); - for (const c of data.columns) { - let count = 0; - for (const r of data.rows) { - count += intersectionMap.get(`${r.id}:${c.id}`)?.length || 0; - } - cc.set(c.id, count); - } - items.sort((a, b) => (cc.get(b.id) || 0) - (cc.get(a.id) || 0)); + items.sort((a, b) => (cardColCounts.get(b.id) ?? 0) - (cardColCounts.get(a.id) ?? 0)); } else { items.sort((a, b) => a.name.localeCompare(b.name)); } @@ -340,7 +551,7 @@ export default function MatrixReport() { item, children: [], depth: 0, leafCount: 1, leafDescendants: [item.id], isPrunedGroup: false, originalLeafCount: 1, })); - }, [prunedColRoots, data, sortCols, intersectionMap, hideEmpty, relatedColIds]); + }, [prunedColRoots, data, sortCols, cardColCounts, visibleColIds]); // Node maps for aggregation lookups const allRowNodesMap = useMemo( @@ -376,64 +587,35 @@ export default function MatrixReport() { const numRowHeaderCols = rowHeaderLayout.length > 0 ? rowHeaderLayout[0].length : 1; const numColHeaderRows = columnHeaderRows.length; - // Cell value getter with aggregation support - const getCellValue = (rowNode: TreeNode, colNode: TreeNode): number => { - const rowLeaves = getEffectiveLeafIds(rowNode); - const colLeaves = getEffectiveLeafIds(colNode); - return aggregateCount(rowLeaves, colLeaves, intersectionMap); - }; + // One pass over the payload's edges yields the cell contents, both sets of + // totals and the heat maximum. This used to be four separate walks of the + // whole grid, each calling an aggregation that was itself quadratic in the + // leaf counts — cost grew with the *area* of the grid rather than with the + // (sparse) data, which the value glyphs would have multiplied further. + const cellMatrix = useMemo( + () => buildCellMatrix(leafRowNodes, leafColNodes, data, valueIndex), + [leafRowNodes, leafColNodes, data, valueIndex], + ); + const maxCellCount = cellMatrix.max; + const grandTotal = cellMatrix.grandTotal; - // Max cell count for heatmap scaling - const maxCellCount = useMemo(() => { - let max = 0; - for (const rNode of leafRowNodes) { - for (const cNode of leafColNodes) { - const val = getCellValue(rNode, cNode); - if (val > max) max = val; - } - } - return max; - }, [leafRowNodes, leafColNodes, intersectionMap]); // eslint-disable-line react-hooks/exhaustive-deps - - // Row totals - const rowTotals = useMemo(() => { - const m = new Map(); - for (const rNode of leafRowNodes) { - let total = 0; - for (const cNode of leafColNodes) { - total += getCellValue(rNode, cNode); - } - m.set(rNode.item.id, total); - } - return m; - }, [leafRowNodes, leafColNodes, intersectionMap]); // eslint-disable-line react-hooks/exhaustive-deps - - // Column totals - const colTotals = useMemo(() => { - const m = new Map(); - for (const cNode of leafColNodes) { - let total = 0; - for (const rNode of leafRowNodes) { - total += getCellValue(rNode, cNode); - } - m.set(cNode.item.id, total); - } - return m; - }, [leafRowNodes, leafColNodes, intersectionMap]); // eslint-disable-line react-hooks/exhaustive-deps - - // Grand total - const grandTotal = useMemo(() => { - let total = 0; - for (const [, v] of rowTotals) total += v; - return total; - }, [rowTotals]); - - // Stats — counts reflect the visible (filtered) set when hiding unrelated cards - const totalIntersections = data?.intersections.length || 0; - const visibleRowCount = hideEmpty ? relatedRowIds.size : (data?.rows.length || 0); - const visibleColCount = hideEmpty ? relatedColIds.size : (data?.columns.length || 0); + // Stats — counts reflect the visible (filtered) set + const totalRelations = useMemo( + () => (data?.intersections ?? []).reduce((sum, i) => sum + (i.e ?? []).length, 0), + [data], + ); + const visibleRowCount = visibleRowIds ? visibleRowIds.size : (data?.rows.length || 0); + const visibleColCount = visibleColIds ? visibleColIds.size : (data?.columns.length || 0); const maxPossible = visibleRowCount * visibleColCount; - const coverage = maxPossible > 0 ? ((totalIntersections / maxPossible) * 100).toFixed(1) : "0"; + const populatedCells = cellMatrix.cells.size; + const coverage = maxPossible > 0 ? ((populatedCells / maxPossible) * 100).toFixed(1) : "0"; + + // Coverage gaps, over the whole axis rather than the visible slice: a card is + // uncovered because nothing links to it, not because it scrolled off. + const uncoveredRowCount = (data?.rows.length ?? 0) - relatedRowIds.size; + const uncoveredColCount = (data?.columns.length ?? 0) - relatedColIds.size; + + const gridCellCount = leafRowNodes.length * leafColNodes.length; // Hover helpers const getHoveredRowIds = (id: string | null): Set => { @@ -451,9 +633,15 @@ export default function MatrixReport() { const hoveredRowIds = useMemo(() => getHoveredRowIds(hoveredRow), [hoveredRow, allRowNodesMap]); // eslint-disable-line react-hooks/exhaustive-deps const hoveredColIds = useMemo(() => getHoveredColIds(hoveredCol), [hoveredCol, allColNodesMap]); // eslint-disable-line react-hooks/exhaustive-deps - const handleCellClick = (e: React.MouseEvent, rowNode: TreeNode, colNode: TreeNode) => { - const val = getCellValue(rowNode, colNode); - if (val > 0) setPopover({ el: e.currentTarget, rowId: rowNode.item.id, colId: colNode.item.id }); + const handleCellClick = ( + e: React.MouseEvent, + rowNode: TreeNode, + colNode: TreeNode, + cell: CellDatum, + ) => { + if (cell.count > 0) { + setPopover({ el: e.currentTarget, rowId: cardIdOf(rowNode), colId: cardIdOf(colNode) }); + } }; const rowMeta = types.find((t) => t.key === rowType); @@ -461,23 +649,276 @@ export default function MatrixReport() { const rowLabel = typeLabel(rowMeta) || rowType; const colLabel = typeLabel(colMeta) || colType; + const valuesOf = (cell: CellDatum): MatrixValue[] => + cell.valueIds.map((id) => valueIndex.byId.get(id)).filter((v): v is MatrixValue => !!v); + + /** + * A cell's whole story as plain text. Built per cell in the render pass and + * handed to the native `title` attribute rather than a MUI `` — a + * wide matrix has tens of thousands of cells, and a component each would cost + * far more than the hover is worth. + */ + const cellTitle = ( + rowNode: TreeNode, + colNode: TreeNode, + cell: CellDatum, + isAggregated: boolean, + ): string => { + const lines = [`${rowNode.item.name} × ${colNode.item.name}`]; + lines.push(t("matrix.relations", { count: cell.count })); + if (isAggregated) lines.push(t("matrix.aggregatedHint")); + const values = valuesOf(cell); + if (values.length > 0) lines.push(values.map((v) => v.label).join(", ")); + if (cell.dirMask === (DIR_FORWARD | DIR_REVERSE)) lines.push(t("matrix.directionBoth")); + else if (cell.dirMask === DIR_REVERSE) lines.push(t("matrix.directionReverse")); + else if (cell.dirMask === DIR_FORWARD) lines.push(t("matrix.directionForward")); + return lines.join("\n"); + }; + + const directionBorder = (dirMask: number, side: "left" | "right"): string | undefined => { + if (dirMask === 0) return undefined; + const wants = side === "left" ? DIR_FORWARD : DIR_REVERSE; + return dirMask & wants ? `2px solid ${theme.palette.primary.main}` : undefined; + }; + + /** Glyphs (dense) or chips (wide) for the values behind a cell. */ + const renderCellValues = (cell: CellDatum, mode: CellMode) => { + const values = valuesOf(cell); + if (values.length === 0) { + // Relations exist but carry no values — still worth a mark. + return ; + } + if (mode === "labels") { + return ( + + {values.map((v) => ( + + {v.label} + + ))} + + ); + } + // Dense: a fixed number of glyphs fits, the rest collapse into "+n". + const shown = values.slice(0, MAX_DENSE_GLYPHS); + const overflow = values.length - shown.length; + return ( + + {shown.map((v) => ( + + {v.code} + + ))} + {overflow > 0 && ( + + +{overflow} + + )} + + ); + }; + + /** Swap the axes, carrying each one's sort and depth across with it. */ + const handleTranspose = () => { + setRowType(colType); + setColType(rowType); + setSortRows(sortCols); + setSortCols(sortRows); + setRowExpandedDepth(colExpandedDepth); + setColExpandedDepth(rowExpandedDepth); + setRowSearch(colSearch); + setColSearch(rowSearch); + }; + const sortModeLabel = (m: SortMode) => m === "alpha" ? t("matrix.alphaSort") : m === "count" ? t("matrix.byCount") : t("matrix.hierarchy"); + const cellModeLabel = (m: CellMode) => ({ + exists: t("matrix.existsDot"), + count: t("matrix.countHeatmap"), + codes: t("matrix.codes"), + labels: t("matrix.labels"), + })[m]; + const directionLabel = (d: MatrixFilterState["direction"]) => ({ + any: t("matrix.directionAny"), + forward: t("matrix.directionForward"), + reverse: t("matrix.directionReverse"), + })[d]; + + const activeFilterCount = (filters.relationTypes.length > 0 ? 1 : 0) + + Object.values(filters.attrValues).filter((v) => v.length > 0).length + + (filters.direction !== "any" ? 1 : 0); + const printParams = useMemo(() => { const params: { label: string; value: string }[] = []; params.push({ label: t("matrix.rows"), value: rowLabel }); params.push({ label: t("matrix.columns"), value: colLabel }); - params.push({ label: t("matrix.cell"), value: cellMode === "exists" ? t("matrix.existsDot") : t("matrix.countHeatmap") }); + params.push({ label: t("matrix.cell"), value: cellModeLabel(cellMode) }); params.push({ label: t("matrix.sortRows"), value: sortModeLabel(sortRows) }); params.push({ label: t("matrix.sortColumns"), value: sortModeLabel(sortCols) }); if (hideEmpty) params.push({ label: t("matrix.hideUnrelated"), value: t("matrix.on") }); + if (showOnlyGaps) params.push({ label: t("matrix.showOnlyGaps"), value: t("matrix.on") }); + if (filters.relationTypes.length > 0) { + params.push({ + label: t("matrix.relationType"), + value: filters.relationTypes + .map((key) => { + const rt = pairRelationTypes.find((r) => r.key === key); + return rt ? relationLabel(rt) : key; + }) + .join(", "), + }); + } + for (const [dimensionId, values] of Object.entries(filters.attrValues)) { + if (values.length === 0) continue; + const dim = valueIndex.dimensions.find((d) => d.id === dimensionId); + params.push({ + label: dim ? fieldLabel(dim.field) : dimensionId, + value: values + .map((v) => valueIndex.byId.get(`${dimensionId}:${v}`)?.label ?? v) + .join(", "), + }); + } + if (filters.direction !== "any") { + params.push({ label: t("matrix.direction"), value: directionLabel(filters.direction) }); + } return params; - }, [rowLabel, colLabel, cellMode, sortRows, sortCols, hideEmpty, t]); + }, [rowLabel, colLabel, cellMode, sortRows, sortCols, hideEmpty, showOnlyGaps, filters, pairRelationTypes, valueIndex, t]); // eslint-disable-line react-hooks/exhaustive-deps - if (ml || matrixLoading || data === null) - return ; + /** + * Legend entries, grouped per relation type. Only rendered by the value-bearing + * cell modes — a dot or a count explains itself. + */ + const legendGroups = useMemo(() => { + if (cellMode !== "codes" && cellMode !== "labels") return []; + return pairRelationTypes + .map((rt) => ({ rt, values: valueIndex.byRelationType.get(rt.key) ?? [] })) + .filter((g) => g.values.length > 0); + }, [cellMode, pairRelationTypes, valueIndex]); + + /** + * Two sheets, both built from data structures rather than scraped from the + * DOM: the grid as it looks on screen, and — the one an architect actually + * pivots on — a row per relation with its attribute values spread into + * columns. Which columns exist is decided by the relation types in play, so a + * new admin-defined dimension appears in the export with no code change. + */ + const buildMatrixExportData = useCallback((): ReportExportData => { + const gridColumns = [ + { key: "row", label: rowLabel }, + ...leafColNodes.map((node, i) => ({ key: `c${i}`, label: node.item.name })), + { key: "total", label: t("matrix.total"), type: "number" as const }, + ]; + const gridRows = leafRowNodes.map((rowNode, rowIdx) => { + const record: Record = { row: rowNode.item.name }; + leafColNodes.forEach((_, colIdx) => { + const cell = getCell(cellMatrix, rowIdx, colIdx); + if (cell.count === 0) { + record[`c${colIdx}`] = ""; + } else if (cellMode === "codes") { + record[`c${colIdx}`] = valuesOf(cell).map((v) => v.code).join(" ") || String(cell.count); + } else if (cellMode === "labels") { + record[`c${colIdx}`] = valuesOf(cell).map((v) => v.label).join(", ") || String(cell.count); + } else if (cellMode === "count") { + record[`c${colIdx}`] = cell.count; + } else { + record[`c${colIdx}`] = "●"; + } + }); + record.total = cellMatrix.rowTotals[rowIdx] ?? 0; + return record; + }); + + // One column per dimension actually declared on the relation types in play. + const edgeDimensions = valueIndex.dimensions; + const edgeColumns = [ + { key: "rowCard", label: rowLabel }, + { key: "colCard", label: colLabel }, + { key: "relationType", label: t("matrix.relationType") }, + { key: "direction", label: t("matrix.direction") }, + ...edgeDimensions.map((d) => ({ + key: d.id, + label: pairRelationTypes.length > 1 + ? `${relationLabel(relationTypes.find((r) => r.key === d.relationTypeKey)!)} · ${fieldLabel(d.field)}` + : fieldLabel(d.field), + })), + ]; + + const rowNames = new Map((data?.rows ?? []).map((r) => [r.id, r.name])); + const colNames = new Map((data?.columns ?? []).map((c) => [c.id, c.name])); + const visibleRows = new Set(leafRowNodes.flatMap((n) => n.leafDescendants)); + const visibleCols = new Set(leafColNodes.flatMap((n) => n.leafDescendants)); + const relationTypeKeys = data?.relation_types ?? []; + const attrSets = data?.attr_sets ?? []; + + const edgeRows: Record[] = []; + for (const intersection of data?.intersections ?? []) { + if (!visibleRows.has(intersection.row_id) || !visibleCols.has(intersection.col_id)) continue; + for (const [rtIdx, orientation, attrIdx] of intersection.e ?? []) { + const rtKey = relationTypeKeys[rtIdx]; + const rt = pairRelationTypes.find((r) => r.key === rtKey); + const attributes = attrSets[attrIdx] ?? {}; + const record: Record = { + rowCard: rowNames.get(intersection.row_id) ?? "", + colCard: colNames.get(intersection.col_id) ?? "", + // Read the verb from the row's point of view, so the sheet says what + // the row card does rather than what some other card does to it. + relationType: rt ? relationLabel(rt, orientation === "r") : (rtKey ?? ""), + direction: orientation === "r" + ? t("matrix.directionReverse") + : t("matrix.directionForward"), + }; + for (const dim of edgeDimensions) { + if (dim.relationTypeKey !== rtKey) continue; + const raw = attributes[dim.field.key]; + if (raw === undefined || raw === null || raw === "") continue; + if (dim.kind === "flag") { + record[dim.id] = raw === true ? t("common:labels.yes") : t("common:labels.no"); + } else if (dim.kind === "enum") { + record[dim.id] = valueIndex.byId.get(`${dim.id}:${raw}`)?.label ?? String(raw); + } else { + record[dim.id] = String(raw); + } + } + edgeRows.push(record); + } + } + + return { + title: t("matrix.title"), + subtitle: `${rowLabel} × ${colLabel}`, + filterSummary: printParams, + chartNode: chartRef.current, + sheets: [ + { name: t("matrix.exportGridSheet"), columns: gridColumns, rows: gridRows }, + { name: t("matrix.exportRelationsSheet"), columns: edgeColumns, rows: edgeRows }, + ], + }; + }, [data, leafRowNodes, leafColNodes, cellMatrix, cellMode, valueIndex, pairRelationTypes, relationTypes, rowLabel, colLabel, printParams, chartRef, relationLabel, fieldLabel, t]); // eslint-disable-line react-hooks/exhaustive-deps + + const loading = ml || matrixLoading || data === null; const isHierarchyRowMode = sortRows === "hierarchy" && rowHasHierarchy && rowTreeFull !== null && rowTreeFull.maxDepth > 0; const isHierarchyColMode = sortCols === "hierarchy" && colHasHierarchy && colTreeFull !== null && colTreeFull.maxDepth > 0; + const dataColWidth = cellMode === "labels" ? DATA_COL_WIDTH_WIDE : DATA_COL_WIDTH_DENSE; return ( setColType(e.target.value)} sx={{ minWidth: 150 }}> {types.filter((tp) => !tp.is_hidden).map((tp) => {typeLabel(tp)})} - setCellMode(e.target.value as CellMode)} sx={{ minWidth: 140 }}> + setCellMode(e.target.value as CellMode)} sx={{ minWidth: 150 }}> {t("matrix.existsDot")} {t("matrix.countHeatmap")} + {/* Disabled rather than hidden: the modes exist, this axis pair just + has no relation attributes for them to show. */} + + + {t("matrix.codes")} + + + + + {t("matrix.labels")} + + setSortRows(e.target.value as SortMode)} sx={{ minWidth: 130 }}> {t("matrix.alphaSort")} @@ -514,24 +968,148 @@ export default function MatrixReport() { {t("matrix.byCount")} {colHasHierarchy && {t("matrix.hierarchy")}} + setRowSearch(e.target.value)} + sx={{ minWidth: 150 }} + slotProps={{ + input: { + endAdornment: rowSearch ? ( + + setRowSearch("")} edge="end"> + + + + ) : undefined, + }, + }} + /> + setColSearch(e.target.value)} + sx={{ minWidth: 150 }} + slotProps={{ + input: { + endAdornment: colSearch ? ( + + setColSearch("")} edge="end"> + + + + ) : undefined, + }, + }} + /> setHideEmpty(e.target.checked)} />} - label={t("matrix.hideUnrelated")} + control={ + { + setHideEmpty(e.target.checked); + if (e.target.checked) setShowOnlyGaps(false); + }} + /> + } + label={activeFilterCount > 0 ? t("matrix.hideNonMatching") : t("matrix.hideUnrelated")} + /> + { + setShowOnlyGaps(e.target.checked); + if (e.target.checked) setHideEmpty(false); + }} + /> + } + label={t("matrix.showOnlyGaps")} /> } + actions={ + + + + + + } > + {loading ? ( + + ) : ( + <> + + {/* Summary strip */} - + + + + {/* Legend sits inside the chart area so it is captured by the image + export alongside the grid it explains. */} + {legendGroups.length > 0 && ( + + {legendGroups.map(({ rt, values }) => ( + ({ + label: cellMode === "codes" ? `${v.code} — ${v.label}` : v.label, + color: v.color, + }))} + /> + ))} + + )} + + {data?.truncated && ( + + {t("matrix.truncated")} + + )} + {gridCellCount > LARGE_GRID_CELLS && ( + + {t("matrix.tooLarge", { count: gridCellCount })} + + )} + {leafRowNodes.length === 0 || leafColNodes.length === 0 ? ( - {t("matrix.noData")} + + {pairRelationTypes.length === 0 + ? t("matrix.noRelationTypes", { row: rowLabel, col: colLabel }) + : t("matrix.noData")} + ) : ( and the can never disagree. */} {Array.from({ length: numRowHeaderCols }).map((_, i) => ( ))} {leafColNodes.map((colNode) => ( - + ))} @@ -660,11 +1244,20 @@ export default function MatrixReport() { const isLeafCell = cell.isLeaf; const isHighlighted = hoveredColIds.has(cell.node.item.id) || cell.node.leafDescendants.some((id) => hoveredColIds.has(id)); + // A wide column fits more text lying flat than standing on + // end, so the vertical ribbon is only worth it when dense. + const isVertical = isLeafCell && cellMode !== "labels"; + const maxChars = cellMode === "labels" ? 40 : 24; return ( setHoveredCol(cell.node.item.id)} onMouseLeave={() => setHoveredCol(null)} - onClick={() => setSidePanelCardId(cell.node.item.id)} + onClick={() => setSidePanelCardId(cardIdOf(cell.node))} > - {isLeafCell - ? (cell.node.item.name.length > 24 - ? cell.node.item.name.slice(0, 23) + "\u2026" - : cell.node.item.name) - : cell.node.item.name} + {/* A self column repeats its parent's name directly + under that parent's header, so the marker alone + reads clearly and saves the space. */} + {cell.node.isSelfNode + ? t("matrix.selfRow") + : isLeafCell + ? (cell.node.item.name.length > maxChars + ? cell.node.item.name.slice(0, maxChars - 1) + "\u2026" + : cell.node.item.name) + : cell.node.item.name} {cell.isPrunedGroup && ( ({cell.node.originalLeafCount}) @@ -726,7 +1324,7 @@ export default function MatrixReport() { {leafRowNodes.map((leafRow, rowIdx) => { - const rTotal = rowTotals.get(leafRow.item.id) || 0; + const rTotal = cellMatrix.rowTotals[rowIdx] ?? 0; const headerCells = rowHeaderLayout[rowIdx]; return ( @@ -766,7 +1364,7 @@ export default function MatrixReport() { }} onMouseEnter={() => setHoveredRow(cell.node.item.id)} onMouseLeave={() => setHoveredRow(null)} - onClick={() => setSidePanelCardId(cell.node.item.id)} + onClick={() => setSidePanelCardId(cardIdOf(cell.node))} > - {cell.node.item.name} + {cell.node.isSelfNode ? t("matrix.selfRow") : cell.node.item.name} {cell.isPrunedGroup && ( ({cell.node.originalLeafCount}) @@ -788,12 +1391,18 @@ export default function MatrixReport() { })} {/* Intersection cells */} - {leafColNodes.map((colNode) => { - const val = getCellValue(leafRow, colNode); + {leafColNodes.map((colNode, colIdx) => { + const cell = getCell(cellMatrix, rowIdx, colIdx); + const val = cell.count; const isDiagonal = rowType === colType && leafRow.item.id === colNode.item.id; const isHighlighted = hoveredRowIds.has(leafRow.item.id) || hoveredColIds.has(colNode.item.id); const isAggregated = leafRow.isPrunedGroup || colNode.isPrunedGroup; + // A collapsed group cell can span dozens of different + // values; showing one of them would be a lie, so it always + // falls back to the count. Expand a level to see values. const displayAsCount = cellMode === "count" || isAggregated; + const showValues = !displayAsCount + && (cellMode === "codes" || cellMode === "labels"); let bg = theme.palette.background.paper; if (isDiagonal) { @@ -809,22 +1418,27 @@ export default function MatrixReport() { return ( 0 ? cellTitle(leafRow, colNode, cell, isAggregated) : undefined} style={{ - padding: 0, + padding: showValues ? "2px 1px" : 0, borderRight: cellBorder, borderBottom: cellBorder, + // Direction reads as a coloured edge on the side the + // relation points from — free at 32px, where an arrow + // glyph would not fit. + borderLeft: directionBorder(cell.dirMask, "left"), textAlign: "center", verticalAlign: "middle", backgroundColor: bg, - width: 32, - minWidth: 32, + width: dataColWidth, + minWidth: dataColWidth, height: 26, cursor: val > 0 ? "pointer" : "default", transition: "background-color 0.15s", }} onMouseEnter={() => { setHoveredRow(leafRow.item.id); setHoveredCol(colNode.item.id); }} onMouseLeave={() => { setHoveredRow(null); setHoveredCol(null); }} - onClick={(e) => handleCellClick(e, leafRow, colNode)} + onClick={(e) => handleCellClick(e, leafRow, colNode, cell)} > {displayAsCount ? ( val > 0 ? ( @@ -839,6 +1453,8 @@ export default function MatrixReport() { {val} ) : null + ) : showValues && val > 0 ? ( + renderCellValues(cell, cellMode) ) : ( val > 0 ? ( - Σ {t("matrix.total")} + {t("matrix.sigmaTotal")} - {leafColNodes.map((cNode) => ( + {leafColNodes.map((cNode, colIdx) => ( - {colTotals.get(cNode.item.id) || 0} + {cellMatrix.colTotals[colIdx] ?? 0} ))} {popover && (() => { - const row = data.rows.find((r) => r.id === popover.rowId); - const col = data.columns.find((c) => c.id === popover.colId); - const rowNode = leafRowNodes.find((n) => n.item.id === popover.rowId); - const colNode = leafColNodes.find((n) => n.item.id === popover.colId); - const val = rowNode && colNode ? getCellValue(rowNode, colNode) : 0; + const row = data?.rows.find((r) => r.id === popover.rowId); + const col = data?.columns.find((c) => c.id === popover.colId); + const rowIdx = leafRowNodes.findIndex((n) => cardIdOf(n) === popover.rowId); + const colIdx = leafColNodes.findIndex((n) => cardIdOf(n) === popover.colId); + const cell = rowIdx >= 0 && colIdx >= 0 + ? getCell(cellMatrix, rowIdx, colIdx) + : { count: 0, dirMask: 0, valueIds: [], relationTypeKeys: [] }; + const values = valuesOf(cell); return ( - + {row?.name} × {col?.name} - + + + {cell.relationTypeKeys.map((key) => { + const rt = pairRelationTypes.find((r) => r.key === key); + return ( + + ); + })} + {cell.dirMask > 0 && ( + + } + label={ + cell.dirMask === (DIR_FORWARD | DIR_REVERSE) + ? t("matrix.directionBoth") + : cell.dirMask === DIR_REVERSE + ? t("matrix.directionReverse") + : t("matrix.directionForward") + } + /> + )} + + {values.length > 0 && ( + + {values.map((v) => ( + + ))} + + )} { setPopover(null); setSidePanelCardId(popover.rowId); }}> @@ -957,6 +1621,8 @@ export default function MatrixReport() { ); })()} + + )} { expect(mockNavigate).toHaveBeenCalledWith("/reports/saved"); }); + it("offers Excel export for a chart-only report that supplies buildExportData", async () => { + // A report that hands over real tabular data isn't relying on the DOM + // scrape, so the chart/table toggle is irrelevant to whether XLSX applies. + // Without this the Matrix report — which has no table view — could never + // export its data. + const user = userEvent.setup(); + renderShell({ + chartRef: { current: document.createElement("div") }, + hasTableToggle: false, + buildExportData: () => ({ title: "Test Report", sheets: [] }), + }); + + await user.click(screen.getByRole("button", { name: /more actions/i })); + + await waitFor(() => { + expect(screen.getByText("Export to Excel (.xlsx)")).toBeInTheDocument(); + }); + }); + + it("hides Excel export for a chart-only report with no buildExportData", async () => { + const user = userEvent.setup(); + renderShell({ + chartRef: { current: document.createElement("div") }, + hasTableToggle: false, + }); + + await user.click(screen.getByRole("button", { name: /more actions/i })); + + await waitFor(() => { + expect(screen.getByText("Export to PowerPoint (.pptx)")).toBeInTheDocument(); + }); + expect(screen.queryByText("Export to Excel (.xlsx)")).not.toBeInTheDocument(); + }); + it("renders print params when provided", () => { renderShell({ printParams: [ diff --git a/frontend/src/features/reports/ReportShell.tsx b/frontend/src/features/reports/ReportShell.tsx index 761b3c276..9bfd89406 100644 --- a/frontend/src/features/reports/ReportShell.tsx +++ b/frontend/src/features/reports/ReportShell.tsx @@ -139,8 +139,10 @@ export default function ReportShell({ const exportEnabled = !disableExport && !!chartRef; // Match the export format to what the user is currently looking at: // PPTX captures the chart, XLSX harvests the rendered tables. Reports - // without a table toggle are inherently chart-only. - const showXlsxItem = exportEnabled && hasTableToggle && view === "table"; + // without a table toggle are inherently chart-only — unless they supply + // their own `buildExportData`, which means they hand over real tabular + // data instead of relying on the DOM scrape, so XLSX always applies. + const showXlsxItem = exportEnabled && (!!buildExportData || (hasTableToggle && view === "table")); const showPptxItem = exportEnabled && (!hasTableToggle || view === "chart"); return ( diff --git a/frontend/src/features/reports/matrixCells.test.ts b/frontend/src/features/reports/matrixCells.test.ts new file mode 100644 index 000000000..82086bb14 --- /dev/null +++ b/frontend/src/features/reports/matrixCells.test.ts @@ -0,0 +1,285 @@ +import { describe, it, expect } from "vitest"; +import { + DIR_FORWARD, + DIR_REVERSE, + buildCellMatrix, + getCell, + relatedCardIds, + type MatrixPayload, +} from "./matrixCells"; +import { buildValueIndex } from "./matrixDimensions"; +import { addSelfNodes, buildTree, pruneTreeToDepth, getLeafNodes } from "./matrixHierarchy"; +import type { FieldDef, FieldOption, RelationType } from "@/types"; + +const rt: RelationType = { + key: "relA", + label: "uses", + source_type_key: "Application", + target_type_key: "DataObject", + cardinality: "n:m", + built_in: false, + is_hidden: false, + attributes_schema: [ + { key: "alpha", label: "Create", type: "boolean" }, + { key: "beta", label: "Read", type: "boolean" }, + ], +} as unknown as RelationType; + +const rtB: RelationType = { ...rt, key: "relB", label: "feeds" } as RelationType; + +const valueIndex = buildValueIndex([rt, rtB], { + fieldLabel: (f: FieldDef) => f.label, + optionLabel: (o: FieldOption) => o.label, +}); + +/** Flat (non-hierarchical) leaf nodes, the shape the report uses without hierarchy. */ +function leaves(ids: string[]) { + return getLeafNodes( + buildTree(ids.map((id) => ({ id, name: id, parent_id: null }))).roots, + ); +} + +function payload( + intersections: MatrixPayload["intersections"], + attrSets: Record[] = [{}], +): MatrixPayload { + return { + rows: [], + columns: [], + intersections, + relation_types: ["relA", "relB"], + attr_sets: attrSets, + }; +} + +describe("buildCellMatrix", () => { + it("returns an empty matrix for no data", () => { + const m = buildCellMatrix([], [], null, valueIndex); + expect(m.cells.size).toBe(0); + expect(m.grandTotal).toBe(0); + expect(m.max).toBe(0); + }); + + it("counts every edge in a cell, not just the pair", () => { + // Two relations between the same two cards is two, not one — the old + // set-based payload could never express this. + const m = buildCellMatrix( + leaves(["r1"]), + leaves(["c1"]), + payload([{ row_id: "r1", col_id: "c1", e: [[0, "f", 0], [1, "r", 0]] }]), + valueIndex, + ); + expect(getCell(m, 0, 0).count).toBe(2); + expect(m.grandTotal).toBe(2); + expect(m.max).toBe(2); + }); + + it("records the orientation of each edge as a bitmask", () => { + const m = buildCellMatrix( + leaves(["r1", "r2", "r3"]), + leaves(["c1"]), + payload([ + { row_id: "r1", col_id: "c1", e: [[0, "f", 0]] }, + { row_id: "r2", col_id: "c1", e: [[0, "r", 0]] }, + { row_id: "r3", col_id: "c1", e: [[0, "f", 0], [1, "r", 0]] }, + ]), + valueIndex, + ); + expect(getCell(m, 0, 0).dirMask).toBe(DIR_FORWARD); + expect(getCell(m, 1, 0).dirMask).toBe(DIR_REVERSE); + expect(getCell(m, 2, 0).dirMask).toBe(DIR_FORWARD | DIR_REVERSE); + }); + + it("resolves attribute sets into value ids", () => { + const m = buildCellMatrix( + leaves(["r1"]), + leaves(["c1"]), + payload([{ row_id: "r1", col_id: "c1", e: [[0, "f", 1]] }], [{}, { alpha: true }]), + valueIndex, + ); + expect(getCell(m, 0, 0).valueIds).toEqual(["relA.alpha"]); + }); + + it("unions the values of several edges, in legend order", () => { + const m = buildCellMatrix( + leaves(["r1"]), + leaves(["c1"]), + payload( + [{ row_id: "r1", col_id: "c1", e: [[0, "f", 1], [0, "f", 2]] }], + [{}, { beta: true }, { alpha: true }], + ), + valueIndex, + ); + // Declared alpha-then-beta in the schema, so that is how they read. + expect(getCell(m, 0, 0).valueIds).toEqual(["relA.alpha", "relA.beta"]); + }); + + it("records which relation types are behind a cell", () => { + const m = buildCellMatrix( + leaves(["r1"]), + leaves(["c1"]), + payload([{ row_id: "r1", col_id: "c1", e: [[1, "f", 0], [0, "f", 0], [1, "r", 0]] }]), + valueIndex, + ); + expect(getCell(m, 0, 0).relationTypeKeys).toEqual(["relB", "relA"]); + }); + + it("ignores the structural diagonal of a self-matrix", () => { + const m = buildCellMatrix( + leaves(["r1", "r2"]), + leaves(["r1", "r2"]), + payload([ + { row_id: "r1", col_id: "r1", e: [] }, + { row_id: "r2", col_id: "r2", e: [] }, + { row_id: "r1", col_id: "r2", e: [[0, "f", 0]] }, + ]), + valueIndex, + ); + expect(getCell(m, 0, 0).count).toBe(0); + expect(m.grandTotal).toBe(1); + // A card related to nothing but itself is still a gap. + expect(m.emptyRowIndices.has(1)).toBe(true); + }); + + it("computes row, column and grand totals in the same pass", () => { + const m = buildCellMatrix( + leaves(["r1", "r2"]), + leaves(["c1", "c2"]), + payload([ + { row_id: "r1", col_id: "c1", e: [[0, "f", 0]] }, + { row_id: "r1", col_id: "c2", e: [[0, "f", 0], [1, "f", 0]] }, + { row_id: "r2", col_id: "c1", e: [[0, "f", 0]] }, + ]), + valueIndex, + ); + expect(Array.from(m.rowTotals)).toEqual([3, 1]); + expect(Array.from(m.colTotals)).toEqual([2, 2]); + expect(m.grandTotal).toBe(4); + expect(m.max).toBe(2); + }); + + it("only stores populated cells", () => { + const m = buildCellMatrix( + leaves(["r1", "r2", "r3"]), + leaves(["c1", "c2", "c3"]), + payload([{ row_id: "r2", col_id: "c3", e: [[0, "f", 0]] }]), + valueIndex, + ); + expect(m.cells.size).toBe(1); + expect(getCell(m, 0, 0)).toEqual({ + count: 0, + dirMask: 0, + valueIds: [], + relationTypeKeys: [], + }); + }); + + it("flags rows and columns with no relation at all", () => { + const m = buildCellMatrix( + leaves(["r1", "r2"]), + leaves(["c1", "c2"]), + payload([{ row_id: "r1", col_id: "c1", e: [[0, "f", 0]] }]), + valueIndex, + ); + expect(Array.from(m.emptyRowIndices)).toEqual([1]); + expect(Array.from(m.emptyColIndices)).toEqual([1]); + }); + + it("ignores edges whose cards are not on screen", () => { + const m = buildCellMatrix( + leaves(["r1"]), + leaves(["c1"]), + payload([{ row_id: "elsewhere", col_id: "c1", e: [[0, "f", 0]] }]), + valueIndex, + ); + expect(m.grandTotal).toBe(0); + }); + + it("rolls a collapsed group's descendants up into the group cell", () => { + // Same aggregation the old per-cell walk recomputed for every cell drawn. + const tree = buildTree([ + { id: "parent", name: "Parent", parent_id: null }, + { id: "childA", name: "Child A", parent_id: "parent" }, + { id: "childB", name: "Child B", parent_id: "parent" }, + ]); + const collapsed = getLeafNodes(pruneTreeToDepth(tree.roots, 0)); + + const m = buildCellMatrix( + collapsed, + leaves(["c1"]), + payload([ + { row_id: "childA", col_id: "c1", e: [[0, "f", 0]] }, + { row_id: "childB", col_id: "c1", e: [[0, "f", 0]] }, + ]), + valueIndex, + ); + + expect(collapsed).toHaveLength(1); + expect(getCell(m, 0, 0).count).toBe(2); + }); +}); + +describe("every relation lands in a cell", () => { + // The invariant that was missing. The totals and the hide-unrelated toggle + // are computed from raw card ids, so any edge the grid cannot place shows up + // as a visible card with empty cells and a count that does not add up. + const total = (p: MatrixPayload) => + p.intersections.reduce((sum, i) => sum + (i.e ?? []).length, 0); + + it("places a relation attached to a parent card, once it has a row of its own", () => { + const items = [ + { id: "p", name: "Parent", parent_id: null }, + { id: "c1", name: "Child", parent_id: "p" }, + ]; + const rowNodes = getLeafNodes(addSelfNodes(buildTree(items).roots, new Set(["p"]))); + const data = payload([{ row_id: "p", col_id: "c1", e: [[0, "f", 0]] }]); + + const m = buildCellMatrix(rowNodes, leaves(["c1"]), data, valueIndex); + + expect(m.droppedEdges).toBe(0); + expect(m.grandTotal).toBe(total(data)); + // The parent's own row, not one of its children. + const selfIdx = rowNodes.findIndex((n) => n.isSelfNode); + expect(getCell(m, selfIdx, 0).count).toBe(1); + }); + + it("places a relation attached to a collapsed parent", () => { + const items = [ + { id: "p", name: "Parent", parent_id: null }, + { id: "c1", name: "Child", parent_id: "p" }, + ]; + const collapsed = getLeafNodes(pruneTreeToDepth(buildTree(items).roots, 0)); + const data = payload([{ row_id: "p", col_id: "c1", e: [[0, "f", 0]] }]); + + const m = buildCellMatrix(collapsed, leaves(["c1"]), data, valueIndex); + + expect(m.droppedEdges).toBe(0); + expect(getCell(m, 0, 0).count).toBe(1); + }); + + it("counts an edge it cannot place instead of losing it silently", () => { + const data = payload([{ row_id: "nowhere", col_id: "c1", e: [[0, "f", 0]] }]); + const m = buildCellMatrix(leaves(["r1"]), leaves(["c1"]), data, valueIndex); + expect(m.droppedEdges).toBe(1); + expect(m.grandTotal).toBe(0); + }); +}); + +describe("relatedCardIds", () => { + it("collects the cards on either end of a real relation", () => { + const { rows, columns } = relatedCardIds( + payload([ + { row_id: "r1", col_id: "c1", e: [[0, "f", 0]] }, + { row_id: "r2", col_id: "c2", e: [] }, + ]), + ); + expect(Array.from(rows)).toEqual(["r1"]); + expect(Array.from(columns)).toEqual(["c1"]); + }); + + it("is empty for no payload", () => { + const { rows, columns } = relatedCardIds(null); + expect(rows.size).toBe(0); + expect(columns.size).toBe(0); + }); +}); diff --git a/frontend/src/features/reports/matrixCells.ts b/frontend/src/features/reports/matrixCells.ts new file mode 100644 index 000000000..aac911f16 --- /dev/null +++ b/frontend/src/features/reports/matrixCells.ts @@ -0,0 +1,200 @@ +/** + * Builds the Matrix report's grid contents in a single pass over the payload's + * edges. + * + * The report used to walk the whole grid four times — once each for the heat + * scale, the row totals, the column totals, and the render — and every cell of + * every walk called an aggregation that was itself O(rowLeaves x colLeaves). + * Cost grew with the *area* of the grid even though the data is sparse. + * + * Here the leaf ids are mapped to their visible column/row index once, and the + * edges are visited once. Totals, the heat maximum and the per-cell contents + * all fall out of that one pass, and memory is proportional to the number of + * populated cells rather than to rows x columns. + */ + +import { getEffectiveLeafIds, type TreeNode } from "./matrixHierarchy"; +import { valueIdsForAttributes, type MatrixValueIndex } from "./matrixDimensions"; + +/** Which end of the relation the row card sits on. */ +export const DIR_FORWARD = 1; +export const DIR_REVERSE = 2; + +export interface MatrixPayloadIntersection { + row_id: string; + col_id: string; + /** `[relationTypeIndex, "f" | "r", attributeSetIndex]` per underlying relation. */ + e?: [number, string, number][]; +} + +export interface MatrixPayload { + rows: { id: string; name: string; parent_id: string | null }[]; + columns: { id: string; name: string; parent_id: string | null }[]; + intersections: MatrixPayloadIntersection[]; + relation_types?: string[]; + attr_sets?: Record[]; + truncated?: boolean; +} + +export interface CellDatum { + /** Relations behind this cell. The self-diagonal is structural and counts 0. */ + count: number; + /** Bitmask of {@link DIR_FORWARD} / {@link DIR_REVERSE}. */ + dirMask: number; + /** Union of the value ids across this cell's edges, in legend order. */ + valueIds: string[]; + /** Relation types behind this cell, in payload order. */ + relationTypeKeys: string[]; +} + +export interface CellMatrix { + /** Populated cells only, keyed `rowIndex * numCols + colIndex`. */ + cells: Map; + numCols: number; + rowTotals: Int32Array; + colTotals: Int32Array; + /** Highest single-cell count, for the heat scale. */ + max: number; + grandTotal: number; + /** Row / column indices with no relation at all — the coverage gaps. */ + emptyRowIndices: Set; + emptyColIndices: Set; + /** + * Edges whose cards could not be placed in the grid. Should always be zero: + * every card in the payload has a row or column, so anything counted here is + * a relation the user is being shown a total for but no cell to explain it. + * Asserted in the tests rather than surfaced in the UI. + */ + droppedEdges: number; +} + +const EMPTY_CELL: CellDatum = { count: 0, dirMask: 0, valueIds: [], relationTypeKeys: [] }; + +/** + * Map every card id reachable from a visible leaf node to that node's index. + * + * A collapsed group stands in for all of its descendants, so each descendant id + * points at the group's index — which is exactly the aggregation the old + * per-cell walk recomputed for every cell it drew. + */ +function buildIndexMap(nodes: TreeNode[]): Map { + const map = new Map(); + nodes.forEach((node, index) => { + for (const id of getEffectiveLeafIds(node)) map.set(id, index); + }); + return map; +} + +export function buildCellMatrix( + rowNodes: TreeNode[], + colNodes: TreeNode[], + payload: MatrixPayload | null, + valueIndex: MatrixValueIndex, +): CellMatrix { + const numRows = rowNodes.length; + const numCols = colNodes.length; + const matrix: CellMatrix = { + cells: new Map(), + numCols, + rowTotals: new Int32Array(numRows), + colTotals: new Int32Array(numCols), + max: 0, + grandTotal: 0, + emptyRowIndices: new Set(), + emptyColIndices: new Set(), + droppedEdges: 0, + }; + if (!payload || numRows === 0 || numCols === 0) return matrix; + + const rowIndex = buildIndexMap(rowNodes); + const colIndex = buildIndexMap(colNodes); + const relationTypes = payload.relation_types ?? []; + const attrSets = payload.attr_sets ?? []; + + // Value ids per (relation type, attribute set) pair, resolved once instead of + // once per edge — the payload interns the bags precisely so this is cheap. + const valueIdCache = new Map(); + const valueIdsFor = (rtIdx: number, attrIdx: number): string[] => { + const key = `${rtIdx}:${attrIdx}`; + const hit = valueIdCache.get(key); + if (hit) return hit; + const rtKey = relationTypes[rtIdx]; + const ids = rtKey ? valueIdsForAttributes(rtKey, attrSets[attrIdx], valueIndex) : []; + valueIdCache.set(key, ids); + return ids; + }; + + for (const intersection of payload.intersections) { + const edges = intersection.e ?? []; + if (edges.length === 0) continue; // structural diagonal — not a relationship + + const r = rowIndex.get(intersection.row_id); + const c = colIndex.get(intersection.col_id); + if (r === undefined || c === undefined) { + // A card the grid has no row or column for. The totals and the toggles + // read raw card ids, so silently skipping here is what produced a visible + // card with empty cells and a count that did not add up. + matrix.droppedEdges += edges.length; + continue; + } + + const key = r * numCols + c; + let cell = matrix.cells.get(key); + if (!cell) { + cell = { count: 0, dirMask: 0, valueIds: [], relationTypeKeys: [] }; + matrix.cells.set(key, cell); + } + + for (const [rtIdx, orientation, attrIdx] of edges) { + cell.count += 1; + cell.dirMask |= orientation === "r" ? DIR_REVERSE : DIR_FORWARD; + const rtKey = relationTypes[rtIdx]; + if (rtKey && !cell.relationTypeKeys.includes(rtKey)) cell.relationTypeKeys.push(rtKey); + for (const id of valueIdsFor(rtIdx, attrIdx)) { + if (!cell.valueIds.includes(id)) cell.valueIds.push(id); + } + } + + matrix.rowTotals[r] += edges.length; + matrix.colTotals[c] += edges.length; + matrix.grandTotal += edges.length; + if (cell.count > matrix.max) matrix.max = cell.count; + } + + // Keep the union in legend order so a cell's glyphs read the same way + // everywhere, regardless of which edge happened to arrive first. + const order = new Map(valueIndex.values.map((v, i) => [v.id, i])); + for (const cell of matrix.cells.values()) { + if (cell.valueIds.length > 1) { + cell.valueIds.sort((a, b) => (order.get(a) ?? 0) - (order.get(b) ?? 0)); + } + } + + for (let r = 0; r < numRows; r++) if (matrix.rowTotals[r] === 0) matrix.emptyRowIndices.add(r); + for (let c = 0; c < numCols; c++) if (matrix.colTotals[c] === 0) matrix.emptyColIndices.add(c); + + return matrix; +} + +export function getCell(matrix: CellMatrix, rowIndex: number, colIndex: number): CellDatum { + return matrix.cells.get(rowIndex * matrix.numCols + colIndex) ?? EMPTY_CELL; +} + +/** + * Card ids that take part in at least one relation, for the hide-unrelated and + * show-only-gaps toggles. Derived from the payload rather than the visible + * grid, so collapsing a level never changes which cards count as covered. + */ +export function relatedCardIds(payload: MatrixPayload | null): { + rows: Set; + columns: Set; +} { + const rows = new Set(); + const columns = new Set(); + for (const intersection of payload?.intersections ?? []) { + if ((intersection.e ?? []).length === 0) continue; + rows.add(intersection.row_id); + columns.add(intersection.col_id); + } + return { rows, columns }; +} diff --git a/frontend/src/features/reports/matrixDimensions.test.ts b/frontend/src/features/reports/matrixDimensions.test.ts new file mode 100644 index 000000000..8f1ec1382 --- /dev/null +++ b/frontend/src/features/reports/matrixDimensions.test.ts @@ -0,0 +1,201 @@ +import { describe, it, expect } from "vitest"; +import { + buildDimensions, + buildValueIndex, + fallbackColor, + shortCode, + valueIdsForAttributes, +} from "./matrixDimensions"; +import type { FieldDef, FieldOption, RelationType } from "@/types"; + +// The attribute keys below are deliberately arbitrary. Nothing in the module +// knows any of them — they are read out of `attributes_schema` — so an +// admin-defined dimension behaves exactly like a seeded one. +const relationType = (key: string, schema: Partial[]): RelationType => + ({ + key, + label: key, + source_type_key: "Application", + target_type_key: "DataObject", + cardinality: "n:m", + built_in: false, + is_hidden: false, + attributes_schema: schema, + }) as unknown as RelationType; + +const resolvers = { + fieldLabel: (f: FieldDef) => f.label, + optionLabel: (o: FieldOption) => o.label, +}; + +const flags = relationType("relFlags", [ + { key: "alpha", label: "Create", type: "boolean" }, + { key: "beta", label: "Read", type: "boolean" }, + { key: "gamma", label: "Update", type: "boolean" }, + { key: "delta", label: "Delete", type: "boolean" }, +]); + +const selects = relationType("relSelects", [ + { + key: "usage", + label: "Usage", + type: "single_select", + options: [ + { key: "owner", label: "Owner", color: "#1976d2" }, + { key: "user", label: "User" }, + { key: "legacy", label: "Legacy", hidden: true }, + ], + }, +]); + +describe("buildDimensions", () => { + it("classifies each field from its declared type", () => { + const rt = relationType("relMixed", [ + { key: "flag", label: "Flag", type: "boolean" }, + { key: "pick", label: "Pick", type: "single_select", options: [{ key: "a", label: "A" }] }, + { key: "note", label: "Note", type: "text" }, + { key: "amount", label: "Amount", type: "number" }, + ]); + + expect(buildDimensions([rt]).map((d) => [d.field.key, d.kind])).toEqual([ + ["flag", "flag"], + ["pick", "enum"], + ["note", "scalar"], + ["amount", "scalar"], + ]); + }); + + it("treats an options-less single_select as a scalar (nothing to enumerate)", () => { + const rt = relationType("relBare", [ + { key: "pick", label: "Pick", type: "single_select", options: [] }, + ]); + expect(buildDimensions([rt])[0].kind).toBe("scalar"); + }); + + it("gives flowDirection no special treatment — it is a select like any other", () => { + const rt = relationType("relFlow", [ + { + key: "flowDirection", + label: "Flow direction", + type: "single_select", + options: [ + { key: "forward", label: "Forward" }, + { key: "reverse", label: "Reverse" }, + ], + }, + ]); + expect(buildDimensions([rt])[0].kind).toBe("enum"); + }); +}); + +describe("shortCode", () => { + it("uses the uppercased first character when it is free", () => { + expect(shortCode("Create", new Set())).toBe("C"); + }); + + it("extends to the next character on a collision", () => { + expect(shortCode("Clone", new Set(["C"]))).toBe("Cl"); + expect(shortCode("Close", new Set(["C", "Cl"]))).toBe("Clo"); + }); + + it("falls back to a numbered code when even three characters collide", () => { + expect(shortCode("Close", new Set(["C", "Cl", "Clo"]))).toBe("C2"); + }); + + it("keeps multi-byte characters whole", () => { + // Slicing by code unit would cut a surrogate pair in half and render as a + // replacement character. + expect(shortCode("创建", new Set())).toBe("创"); + expect(shortCode("إنشاء", new Set())).toBe("إ"); + expect(shortCode("😀 Emoji", new Set())).toBe("😀"); + }); + + it("never returns an empty code", () => { + expect(shortCode(" ", new Set())).toBe("?"); + }); +}); + +describe("fallbackColor", () => { + it("gives consecutive positions distinct colours", () => { + // Values of one relation type sit side by side in a cell and in the + // legend, so neighbours must not look alike. + const colors = [0, 1, 2, 3].map(fallbackColor); + expect(new Set(colors).size).toBe(4); + }); + + it("wraps around once the palette runs out", () => { + expect(fallbackColor(0)).toBe(fallbackColor(10)); + }); +}); + +describe("buildValueIndex", () => { + it("emits one value per flag, coded from the localized field label", () => { + const index = buildValueIndex([flags], resolvers); + expect(index.values.map((v) => v.code)).toEqual(["C", "R", "U", "D"]); + expect(new Set(index.values.map((v) => v.color)).size).toBe(4); + expect(index.values.map((v) => v.label)).toEqual(["Create", "Read", "Update", "Delete"]); + expect(index.values.every((v) => v.kind === "flag")).toBe(true); + }); + + it("emits one value per visible option and keeps the metamodel colour", () => { + const index = buildValueIndex([selects], resolvers); + expect(index.values.map((v) => v.optionKey)).toEqual(["owner", "user"]); + expect(index.values[0].color).toBe("#1976d2"); + // No colour in the metamodel — assigned one by position rather than left blank. + expect(index.values[1].color).toBe(fallbackColor(1)); + }); + + it("drops hidden options", () => { + const index = buildValueIndex([selects], resolvers); + expect(index.values.map((v) => v.optionKey)).not.toContain("legacy"); + }); + + it("scopes code uniqueness to each relation type", () => { + // Both types start with the same letter; each gets a plain "C" because a + // cell and a legend block only ever mix values of one relation type's data. + const other = relationType("relOther", [{ key: "x", label: "Create", type: "boolean" }]); + const index = buildValueIndex([flags, other], resolvers); + expect(index.byRelationType.get("relFlags")?.[0].code).toBe("C"); + expect(index.byRelationType.get("relOther")?.[0].code).toBe("C"); + }); + + it("ignores scalar fields", () => { + const rt = relationType("relScalar", [{ key: "note", label: "Note", type: "text" }]); + expect(buildValueIndex([rt], resolvers).values).toEqual([]); + }); +}); + +describe("valueIdsForAttributes", () => { + const index = buildValueIndex([flags, selects], resolvers); + + it("returns only the flags that are on", () => { + expect(valueIdsForAttributes("relFlags", { alpha: true, beta: false }, index)).toEqual([ + "relFlags.alpha", + ]); + }); + + it("ignores absent and falsy flags", () => { + expect(valueIdsForAttributes("relFlags", {}, index)).toEqual([]); + expect(valueIdsForAttributes("relFlags", undefined, index)).toEqual([]); + }); + + it("returns the selected option of an enum", () => { + expect(valueIdsForAttributes("relSelects", { usage: "owner" }, index)).toEqual([ + "relSelects.usage:owner", + ]); + }); + + it("skips a value the metamodel no longer knows", () => { + // An option deleted after the relation was set must not invent a legend entry. + expect(valueIdsForAttributes("relSelects", { usage: "deleted" }, index)).toEqual([]); + }); + + it("only reads dimensions belonging to the given relation type", () => { + expect(valueIdsForAttributes("relSelects", { alpha: true }, index)).toEqual([]); + }); + + it("returns ids in schema order regardless of key order in the bag", () => { + const ids = valueIdsForAttributes("relFlags", { delta: true, alpha: true }, index); + expect(ids).toEqual(["relFlags.alpha", "relFlags.delta"]); + }); +}); diff --git a/frontend/src/features/reports/matrixDimensions.ts b/frontend/src/features/reports/matrixDimensions.ts new file mode 100644 index 000000000..567c153cb --- /dev/null +++ b/frontend/src/features/reports/matrixDimensions.ts @@ -0,0 +1,212 @@ +/** + * Turns a relation type's `attributes_schema` into the things the Matrix report + * can draw, filter and export. + * + * Everything here is derived from the schema at runtime. No attribute key is + * ever named in code: a CRUD-style set of booleans, a usage-type select and an + * admin's own dimension added last week all travel the same path. The seeded + * relation types are just the ones that happen to exist today. + * + * Two levels: + * + * - a **dimension** is one field on one relation type. `flag` (boolean) and + * `enum` (single_select) dimensions have a bounded set of values, so they can + * be coded into a glyph, filtered, and given a legend entry. `scalar` + * dimensions (text, number, date, …) can only be shown as text. + * - a **value** is one legend entry: a flag, or one option of an enum. This is + * the unit a cell renders and the legend explains. + */ + +import { CATEGORICAL_COLORS } from "@/theme/tokens"; +import type { FieldDef, FieldOption, RelationType } from "@/types"; + +export type DimensionKind = "flag" | "enum" | "scalar"; + +export interface MatrixDimension { + /** `${relationTypeKey}.${fieldKey}` — unique across the axis pair. */ + id: string; + relationTypeKey: string; + field: FieldDef; + kind: DimensionKind; +} + +export interface MatrixValue { + /** `${relationTypeKey}.${fieldKey}` for a flag, plus `:${optionKey}` for an enum. */ + id: string; + dimensionId: string; + relationTypeKey: string; + fieldKey: string; + /** Absent for a flag: the field itself is the value. */ + optionKey?: string; + kind: "flag" | "enum"; + /** 1–3 characters for the dense grid. */ + code: string; + /** Full localized text for the legend, chips, tooltips and export. */ + label: string; + color: string; +} + +export interface MatrixValueIndex { + dimensions: MatrixDimension[]; + values: MatrixValue[]; + byId: Map; + /** Values contributed by each relation type, in schema order. */ + byRelationType: Map; +} + +/** Label resolvers, injected so this module stays free of React hooks. */ +export interface LabelResolvers { + fieldLabel: (field: FieldDef) => string; + optionLabel: (option: FieldOption) => string; +} + +function isEnum(field: FieldDef): boolean { + return field.type === "single_select" && (field.options ?? []).length > 0; +} + +/** + * Classify every field of every relation type connecting the two axes. + * + * `flowDirection` is deliberately *not* special-cased. It is a single_select + * like any other, and its options carry their own translated labels, so it + * codes and filters exactly as an admin-defined dimension would. Structural + * direction — which end of the relation the row card sits on — is a separate + * concept the payload carries per edge, not an attribute. + */ +export function buildDimensions(relationTypes: RelationType[]): MatrixDimension[] { + const dimensions: MatrixDimension[] = []; + for (const rt of relationTypes) { + for (const field of rt.attributes_schema ?? []) { + const kind: DimensionKind = + field.type === "boolean" ? "flag" : isEnum(field) ? "enum" : "scalar"; + dimensions.push({ id: `${rt.key}.${field.key}`, relationTypeKey: rt.key, field, kind }); + } + } + return dimensions; +} + +/** + * Shortest prefix of `label` not already in `taken`, up to 3 characters, then a + * numeric suffix. Splitting by grapheme rather than by code unit keeps CJK and + * Arabic labels intact instead of slicing a surrogate pair in half. + */ +export function shortCode(label: string, taken: Set): string { + const graphemes = Array.from(label.trim()); + if (graphemes.length === 0) return "?"; + for (let len = 1; len <= Math.min(3, graphemes.length); len++) { + const candidate = graphemes.slice(0, len).join(""); + const cased = len === 1 ? candidate.toUpperCase() : candidate; + if (!taken.has(cased)) return cased; + } + const base = graphemes[0].toUpperCase(); + for (let n = 2; ; n++) { + const candidate = `${base}${n}`; + if (!taken.has(candidate)) return candidate; + } +} + +/** + * Colour for a value the metamodel left uncoloured, by its position within its + * relation type. + * + * Assigning by position rather than by hashing the key is deliberate: the + * values of one relation type are read side by side in a cell and in the + * legend, so what matters is that neighbours look different. A hash gives two + * of four CRUD flags near-identical reds often enough to matter. + */ +export function fallbackColor(index: number): string { + return CATEGORICAL_COLORS[index % CATEGORICAL_COLORS.length]; +} + +/** + * Every codeable value across the axis pair, with its glyph, label and colour. + * + * Codes are unique per relation type, not globally: a cell only ever shows the + * values of the relations inside it, and the legend is grouped by relation + * type, so scoping the uniqueness there keeps codes as short as possible. + */ +export function buildValueIndex( + relationTypes: RelationType[], + { fieldLabel, optionLabel }: LabelResolvers, +): MatrixValueIndex { + const dimensions = buildDimensions(relationTypes); + const values: MatrixValue[] = []; + const byRelationType = new Map(); + + for (const rt of relationTypes) { + const taken = new Set(); + const forType: MatrixValue[] = []; + + for (const dim of dimensions.filter((d) => d.relationTypeKey === rt.key)) { + if (dim.kind === "flag") { + const label = fieldLabel(dim.field); + const code = shortCode(label, taken); + taken.add(code); + forType.push({ + id: dim.id, + dimensionId: dim.id, + relationTypeKey: rt.key, + fieldKey: dim.field.key, + kind: "flag", + code, + label, + color: fallbackColor(forType.length), + }); + } else if (dim.kind === "enum") { + for (const option of dim.field.options ?? []) { + if (option.hidden) continue; + const label = optionLabel(option); + const code = shortCode(label, taken); + taken.add(code); + forType.push({ + id: `${dim.id}:${option.key}`, + dimensionId: dim.id, + relationTypeKey: rt.key, + fieldKey: dim.field.key, + optionKey: option.key, + kind: "enum", + code, + label, + color: option.color || fallbackColor(forType.length), + }); + } + } + } + + if (forType.length > 0) byRelationType.set(rt.key, forType); + values.push(...forType); + } + + return { + dimensions, + values, + byId: new Map(values.map((v) => [v.id, v])), + byRelationType, + }; +} + +/** + * The value ids an attribute bag carries — flags that are on, and the selected + * option of each enum. Values the index doesn't know (an option deleted from + * the metamodel after the relation was set) are skipped rather than guessed at. + */ +export function valueIdsForAttributes( + relationTypeKey: string, + attributes: Record | undefined, + index: MatrixValueIndex, +): string[] { + if (!attributes) return []; + const ids: string[] = []; + for (const dim of index.dimensions) { + if (dim.relationTypeKey !== relationTypeKey) continue; + const raw = attributes[dim.field.key]; + if (dim.kind === "flag") { + if (raw === true) ids.push(dim.id); + } else if (dim.kind === "enum") { + if (typeof raw !== "string" || !raw) continue; + const id = `${dim.id}:${raw}`; + if (index.byId.has(id)) ids.push(id); + } + } + return ids; +} diff --git a/frontend/src/features/reports/matrixHierarchy.test.ts b/frontend/src/features/reports/matrixHierarchy.test.ts index 99cae9f6f..cd215bddb 100644 --- a/frontend/src/features/reports/matrixHierarchy.test.ts +++ b/frontend/src/features/reports/matrixHierarchy.test.ts @@ -6,10 +6,11 @@ import { getLeafNodes, buildColumnHeaderRows, buildRowHeaderLayout, - aggregateCount, getEffectiveLeafIds, buildAllNodesMap, filterRelatedSubtrees, + addSelfNodes, + cardIdOf, type MatrixItem, } from "./matrixHierarchy"; @@ -311,27 +312,6 @@ describe("buildRowHeaderLayout", () => { }); }); -// --------------------------------------------------------------------------- -// aggregateCount -// --------------------------------------------------------------------------- -describe("aggregateCount", () => { - it("sums intersections for given row and col leaf IDs", () => { - const map = new Map(); - map.set("r1:c1", ["x", "y"]); - map.set("r1:c2", ["z"]); - map.set("r2:c1", ["w"]); - - expect(aggregateCount(["r1"], ["c1"], map)).toBe(2); - expect(aggregateCount(["r1"], ["c1", "c2"], map)).toBe(3); - expect(aggregateCount(["r1", "r2"], ["c1"], map)).toBe(3); - }); - - it("returns 0 for no intersections", () => { - const map = new Map(); - expect(aggregateCount(["r1"], ["c1"], map)).toBe(0); - }); -}); - // --------------------------------------------------------------------------- // getEffectiveLeafIds // --------------------------------------------------------------------------- @@ -357,6 +337,64 @@ describe("getEffectiveLeafIds", () => { expect(ids).toContain("c1"); expect(ids).toContain("c2"); }); + + it("includes the collapsed group's own id", () => { + // A card can carry relations of its own. Returning only its descendants + // meant those relations had no cell to land in and were dropped. + const items: MatrixItem[] = [ + { id: "r", name: "Root", parent_id: null }, + { id: "c1", name: "A", parent_id: "r" }, + ]; + const pruned = pruneTreeToDepth(buildTree(items).roots, 0); + expect(getEffectiveLeafIds(pruned[0])).toEqual(["r", "c1"]); + }); +}); + +// --------------------------------------------------------------------------- +// addSelfNodes +// --------------------------------------------------------------------------- +describe("addSelfNodes", () => { + const family: MatrixItem[] = [ + { id: "p", name: "Parent", parent_id: null }, + { id: "c1", name: "Child One", parent_id: "p" }, + { id: "c2", name: "Child Two", parent_id: "p" }, + ]; + + it("gives a parent a leaf of its own", () => { + const roots = addSelfNodes(buildTree(family).roots, new Set(["p"])); + const leaves = getLeafNodes(roots); + + expect(leaves.map(cardIdOf)).toEqual(["p", "c1", "c2"]); + expect(leaves[0].isSelfNode).toBe(true); + expect(getEffectiveLeafIds(leaves[0])).toEqual(["p"]); + }); + + it("leaves the tree alone when the parent carries nothing of its own", () => { + const roots = addSelfNodes(buildTree(family).roots, new Set(["c1"])); + expect(getLeafNodes(roots).map((n) => n.item.id)).toEqual(["c1", "c2"]); + }); + + it("keeps leafCount consistent so the header spans stay correct", () => { + const roots = addSelfNodes(buildTree(family).roots, new Set(["p"])); + expect(roots[0].leafCount).toBe(3); + expect(roots[0].leafDescendants).toEqual(["p", "c1", "c2"]); + // Its own node key is distinct so it cannot collide with its parent in the + // header layout, which indexes nodes by `item.id`. + expect(roots[0].children[0].item.id).not.toBe("p"); + expect(roots[0].children[0].item.parent_id).toBe("p"); + }); + + it("sits at the same depth as its siblings, so maxDepth is unchanged", () => { + const tree = buildTree(family); + const roots = addSelfNodes(tree.roots, new Set(["p"])); + expect(roots[0].children[0].depth).toBe(1); + expect(Math.max(...getLeafNodes(roots).map((n) => n.depth))).toBe(tree.maxDepth); + }); + + it("does nothing to a childless node", () => { + const roots = addSelfNodes(buildTree([{ id: "a", name: "A", parent_id: null }]).roots, new Set(["a"])); + expect(roots[0].children).toHaveLength(0); + }); }); // --------------------------------------------------------------------------- @@ -380,6 +418,28 @@ describe("buildAllNodesMap", () => { // filterRelatedSubtrees // --------------------------------------------------------------------------- describe("filterRelatedSubtrees", () => { + it("keeps a related parent whose children are all unrelated", () => { + // The toggle is meant to keep related cards. Testing only descendants + // removed a card that is itself related. + const items: MatrixItem[] = [ + { id: "p", name: "Parent", parent_id: null }, + { id: "c1", name: "Child", parent_id: "p" }, + ]; + const kept = filterRelatedSubtrees(buildTree(items).roots, new Set(["p"])); + expect(kept).toHaveLength(1); + expect(kept[0].item.id).toBe("p"); + }); + + it("keeps a self node when its parent is related", () => { + const items: MatrixItem[] = [ + { id: "p", name: "Parent", parent_id: null }, + { id: "c1", name: "Child", parent_id: "p" }, + ]; + const withSelf = addSelfNodes(buildTree(items).roots, new Set(["p"])); + const kept = filterRelatedSubtrees(withSelf, new Set(["p"])); + expect(getLeafNodes(kept).map(cardIdOf)).toEqual(["p"]); + }); + it("drops flat leaves that are not in the related set", () => { const items: MatrixItem[] = [ { id: "a", name: "Alpha", parent_id: null }, diff --git a/frontend/src/features/reports/matrixHierarchy.ts b/frontend/src/features/reports/matrixHierarchy.ts index 99d370493..62e5d7f3f 100644 --- a/frontend/src/features/reports/matrixHierarchy.ts +++ b/frontend/src/features/reports/matrixHierarchy.ts @@ -18,8 +18,17 @@ export interface TreeNode { leafDescendants: string[]; isPrunedGroup: boolean; originalLeafCount: number; + /** + * A synthetic row/column standing for a parent card itself rather than for + * its children — see {@link addSelfNodes}. Its `item.id` is synthetic; the + * card it stands for is `leafDescendants[0]`, or {@link cardIdOf}. + */ + isSelfNode?: boolean; } +/** Prefix marking a synthetic self node's id apart from any real card id. */ +const SELF_NODE_PREFIX = "__self__:"; + export interface ColumnHeaderCell { node: TreeNode; colspan: number; @@ -155,24 +164,28 @@ export function pruneTreeToDepth(roots: TreeNode[], visibleDepth: number): TreeN // --------------------------------------------------------------------------- /** - * Return a copy of the tree keeping only nodes that have at least one leaf - * descendant in `relatedIds`. Ancestor chains of surviving leaves are kept so - * merged parent-group headers stay consistent with the visible leaves; a leaf - * (or pruned group) survives if any of its aggregated cards is related, and + * Return a copy of the tree keeping only nodes that are related, or that have a + * related descendant. Ancestor chains of surviving leaves are kept so merged + * parent-group headers stay consistent with the visible leaves, and * `leafCount` / `leafDescendants` are recomputed from the surviving subtree. * Returns `null` for a node that is entirely unrelated (so callers filter it out). + * + * A node's **own** id counts, not just its descendants'. A card can carry + * relations of its own while none of its children do; testing descendants alone + * made the hide-unrelated toggle remove a card that is, in fact, related. */ export function filterRelatedSubtrees(roots: TreeNode[], relatedIds: Set): TreeNode[] { const filter = (node: TreeNode): TreeNode | null => { + const selfRelated = relatedIds.has(node.item.id); if (node.children.length === 0) { // Leaf or pruned group: keep if any aggregated card is related - const survives = node.leafDescendants.some((id) => relatedIds.has(id)); + const survives = selfRelated || node.leafDescendants.some((id) => relatedIds.has(id)); return survives ? { ...node, leafDescendants: [...node.leafDescendants] } : null; } const keptChildren = node.children .map(filter) .filter((c): c is TreeNode => c !== null); - if (keptChildren.length === 0) return null; + if (keptChildren.length === 0) return selfRelated ? { ...node, children: [] } : null; return { ...node, children: keptChildren, @@ -183,6 +196,59 @@ export function filterRelatedSubtrees(roots: TreeNode[], relatedIds: Set return roots.map(filter).filter((n): n is TreeNode => n !== null); } +// --------------------------------------------------------------------------- +// addSelfNodes – give a parent card a row/column of its own +// --------------------------------------------------------------------------- + +/** + * Insert a synthetic leaf under every node that still has children and whose + * own id is in `ids`, so a card that carries relations of its own has somewhere + * to show them. + * + * Without this a parent is only a group header spanning its children: it has no + * cell row, its id never reaches the cell index, and every relation attached to + * the card itself is dropped without trace — while the toggles and the metrics, + * which read raw card ids, still count it. + * + * The self node sits at the same depth as its siblings, so the tree's maximum + * depth — and the depth stepper built from it — is unchanged. Only parents that + * actually carry their own relations get one, so a model without parent-level + * relations renders exactly as before. + * + * Call it *after* pruning: a collapsed parent is already a leaf that stands for + * itself (see {@link getEffectiveLeafIds}) and needs no extra row. + */ +export function addSelfNodes(roots: TreeNode[], ids: Set): TreeNode[] { + const visit = (node: TreeNode): TreeNode => { + if (node.children.length === 0) return node; + const children = node.children.map(visit); + if (ids.has(node.item.id)) { + children.unshift({ + // A distinct id, parented to the card it stands for: the header layout + // keys nodes by `item.id` and rebuilds ancestry from `item.parent_id`, + // so reusing the parent's item would make the self node overwrite its + // own parent and erase the group header. The real card id lives on + // `leafDescendants`, which is what the cell index and `cardIdOf` read. + item: { id: `${SELF_NODE_PREFIX}${node.item.id}`, name: node.item.name, parent_id: node.item.id }, + children: [], + depth: node.depth + 1, + leafCount: 1, + leafDescendants: [node.item.id], + isPrunedGroup: false, + originalLeafCount: 1, + isSelfNode: true, + }); + } + return { + ...node, + children, + leafCount: children.reduce((sum, c) => sum + c.leafCount, 0), + leafDescendants: children.flatMap((c) => c.leafDescendants), + }; + }; + return roots.map(visit); +} + // --------------------------------------------------------------------------- // getLeafOrder – ordered leaf IDs from the tree (replaces hierarchySort) // --------------------------------------------------------------------------- @@ -386,36 +452,27 @@ export function buildRowHeaderLayout( return result; } -// --------------------------------------------------------------------------- -// aggregateCount – sum intersections for a (possibly pruned) node pair -// --------------------------------------------------------------------------- - -export function aggregateCount( - rowLeafIds: string[], - colLeafIds: string[], - intersectionMap: Map, -): number { - let count = 0; - for (const rId of rowLeafIds) { - for (const cId of colLeafIds) { - const k = `${rId}:${cId}`; - count += intersectionMap.get(k)?.length || 0; - } - } - return count; -} - // --------------------------------------------------------------------------- // getEffectiveLeafIds – get leaf IDs for a node (handles pruned groups) // --------------------------------------------------------------------------- export function getEffectiveLeafIds(node: TreeNode): string[] { + // A self node's `item.id` is synthetic; the card it stands for is on + // `leafDescendants`. + if (node.isSelfNode) return node.leafDescendants; if (node.isPrunedGroup) { - return node.leafDescendants; + // A collapsed group stands for the card itself as well as everything under + // it, so relations attached to the parent are counted rather than lost. + return [node.item.id, ...node.leafDescendants]; } return node.children.length === 0 ? [node.item.id] : node.leafDescendants; } +/** The card a node stands for — a self node's own `item.id` is synthetic. */ +export function cardIdOf(node: TreeNode): string { + return node.isSelfNode ? node.leafDescendants[0] : node.item.id; +} + // --------------------------------------------------------------------------- // buildAllNodesMap – collect all nodes from pruned tree into a map // --------------------------------------------------------------------------- diff --git a/frontend/src/i18n/locales/ar/reports.json b/frontend/src/i18n/locales/ar/reports.json index 5ccdefb39..ca503f809 100644 --- a/frontend/src/i18n/locales/ar/reports.json +++ b/frontend/src/i18n/locales/ar/reports.json @@ -218,13 +218,43 @@ "matrix.rows": "الصفوف", "matrix.columns": "الأعمدة", "matrix.cellDisplay": "عرض الخلية", + "matrix.cell": "خلية", "matrix.existsDot": "موجود (نقطة)", "matrix.countHeatmap": "العدد (خريطة حرارية)", + "matrix.codes": "القيم (رموز)", + "matrix.labels": "القيم (تسميات)", + "matrix.valuesUnavailable": "العلاقات بين هذين النوعين لا تحمل قيمًا لعرضها.", + "matrix.filters": "مرشّح العلاقات", + "matrix.relationType": "نوع العلاقة", + "matrix.direction": "الاتجاه", + "matrix.directionAny": "أي اتجاه", + "matrix.directionForward": "الصف هو المصدر", + "matrix.directionReverse": "الصف هو الهدف", + "matrix.directionBoth": "كلا الاتجاهين", + "matrix.clearFilters": "مسح الكل", + "matrix.moreFilters": "+{{count}} أخرى", + "matrix.activeFilters_one": "مرشّح واحد نشط ({{count}})", + "matrix.activeFilters_other": "{{count}} مرشّحات نشطة", + "matrix.noRelationTypes": "لا يوجد نوع علاقة يربط {{row}} و{{col}}.", + "matrix.showOnlyGaps": "إظهار الفجوات فقط", + "matrix.hideNonMatching": "إخفاء البطاقات غير المطابقة", + "matrix.uncoveredRows": "{{type}} بلا علاقة", + "matrix.uncoveredColumns": "{{type}} بلا علاقة", + "matrix.searchRows": "البحث عن صف", + "matrix.searchColumns": "البحث عن عمود", + "matrix.transpose": "تبديل الصفوف والأعمدة", + "matrix.aggregatedHint": "مجمَّع عبر المجموعة المطوية — وسِّع مستوى لرؤية القيم.", + "matrix.tooLarge": "تحتوي هذه الشبكة على {{count}} خلية. اطوِ مستوى أو ضيّق المرشّح لتمرير أكثر سلاسة.", + "matrix.truncated": "عدد العلاقات كبير جدًا لعرضها كلها. ضيّق المرشّح لرؤية الباقي.", + "matrix.exportGridSheet": "الشبكة", + "matrix.exportRelationsSheet": "العلاقات", + "matrix.total": "الإجمالي", "matrix.sortRows": "ترتيب الصفوف", "matrix.sortColumns": "ترتيب الأعمدة", "matrix.alphaSort": "أ → ي", "matrix.byCount": "حسب العدد", "matrix.hierarchy": "التسلسل الهرمي", + "matrix.selfRow": "(نفسه)", "matrix.relations": "العلاقات", "matrix.coverage": "التغطية", "matrix.noData": "لم يتم العثور على بيانات لهذا المزيج.", diff --git a/frontend/src/i18n/locales/da/reports.json b/frontend/src/i18n/locales/da/reports.json index eb6571545..6695a7274 100644 --- a/frontend/src/i18n/locales/da/reports.json +++ b/frontend/src/i18n/locales/da/reports.json @@ -218,13 +218,43 @@ "matrix.rows": "Rækker", "matrix.columns": "Kolonner", "matrix.cellDisplay": "Cellevisning", + "matrix.cell": "Celle", "matrix.existsDot": "Eksisterer (prik)", "matrix.countHeatmap": "Antal (varmekort)", + "matrix.codes": "Værdier (koder)", + "matrix.labels": "Værdier (etiketter)", + "matrix.valuesUnavailable": "Relationerne mellem disse to typer indeholder ingen værdier at vise.", + "matrix.filters": "Relationsfilter", + "matrix.relationType": "Relationstype", + "matrix.direction": "Retning", + "matrix.directionAny": "Enhver retning", + "matrix.directionForward": "Rækken er kilden", + "matrix.directionReverse": "Rækken er målet", + "matrix.directionBoth": "Begge retninger", + "matrix.clearFilters": "Ryd alle", + "matrix.moreFilters": "+{{count}} mere", + "matrix.activeFilters_one": "{{count}} filter aktivt", + "matrix.activeFilters_other": "{{count}} filtre aktive", + "matrix.noRelationTypes": "Ingen relationstype forbinder {{row}} og {{col}}.", + "matrix.showOnlyGaps": "Vis kun huller", + "matrix.hideNonMatching": "Skjul kort uden match", + "matrix.uncoveredRows": "{{type}} uden relation", + "matrix.uncoveredColumns": "{{type}} uden relation", + "matrix.searchRows": "Find række", + "matrix.searchColumns": "Find kolonne", + "matrix.transpose": "Byt rækker og kolonner", + "matrix.aggregatedHint": "Aggregeret over den sammenklappede gruppe — udvid et niveau for at se værdierne.", + "matrix.tooLarge": "Dette gitter har {{count}} celler. Klap et niveau sammen eller indsnævr filteret for jævnere rulning.", + "matrix.truncated": "For mange relationer til at vise dem alle. Indsnævr filteret for at se resten.", + "matrix.exportGridSheet": "Gitter", + "matrix.exportRelationsSheet": "Relationer", + "matrix.total": "Total", "matrix.sortRows": "Sortér rækker", "matrix.sortColumns": "Sortér kolonner", "matrix.alphaSort": "A → Å", "matrix.byCount": "Efter antal", "matrix.hierarchy": "Hierarki", + "matrix.selfRow": "(selv)", "matrix.relations": "Relationer", "matrix.coverage": "Dækning", "matrix.noData": "Ingen data fundet for denne kombination.", diff --git a/frontend/src/i18n/locales/de/reports.json b/frontend/src/i18n/locales/de/reports.json index 68166e81a..5b945ede4 100644 --- a/frontend/src/i18n/locales/de/reports.json +++ b/frontend/src/i18n/locales/de/reports.json @@ -218,13 +218,43 @@ "matrix.rows": "Zeilen", "matrix.columns": "Spalten", "matrix.cellDisplay": "Zellenanzeige", + "matrix.cell": "Zelle", "matrix.existsDot": "Vorhanden (Punkt)", "matrix.countHeatmap": "Anzahl (Heatmap)", + "matrix.codes": "Werte (Kürzel)", + "matrix.labels": "Werte (Beschriftungen)", + "matrix.valuesUnavailable": "Die Beziehungen zwischen diesen beiden Typen enthalten keine anzeigbaren Werte.", + "matrix.filters": "Beziehungsfilter", + "matrix.relationType": "Beziehungstyp", + "matrix.direction": "Richtung", + "matrix.directionAny": "Beliebige Richtung", + "matrix.directionForward": "Zeile ist die Quelle", + "matrix.directionReverse": "Zeile ist das Ziel", + "matrix.directionBoth": "Beide Richtungen", + "matrix.clearFilters": "Alle löschen", + "matrix.moreFilters": "+{{count}} weitere", + "matrix.activeFilters_one": "{{count}} Filter aktiv", + "matrix.activeFilters_other": "{{count}} Filter aktiv", + "matrix.noRelationTypes": "Kein Beziehungstyp verbindet {{row}} und {{col}}.", + "matrix.showOnlyGaps": "Nur Lücken anzeigen", + "matrix.hideNonMatching": "Nicht passende Karten ausblenden", + "matrix.uncoveredRows": "{{type}} ohne Beziehung", + "matrix.uncoveredColumns": "{{type}} ohne Beziehung", + "matrix.searchRows": "Zeile suchen", + "matrix.searchColumns": "Spalte suchen", + "matrix.transpose": "Zeilen und Spalten tauschen", + "matrix.aggregatedHint": "Über die eingeklappte Gruppe aggregiert — eine Ebene ausklappen, um die Werte zu sehen.", + "matrix.tooLarge": "Dieses Raster hat {{count}} Zellen. Klappen Sie eine Ebene ein oder engen Sie den Filter ein, damit das Scrollen flüssiger wird.", + "matrix.truncated": "Zu viele Beziehungen, um alle anzuzeigen. Engen Sie den Filter ein, um den Rest zu sehen.", + "matrix.exportGridSheet": "Raster", + "matrix.exportRelationsSheet": "Beziehungen", + "matrix.total": "Gesamt", "matrix.sortRows": "Zeilen sortieren", "matrix.sortColumns": "Spalten sortieren", "matrix.alphaSort": "A → Z", "matrix.byCount": "Nach Anzahl", "matrix.hierarchy": "Hierarchie", + "matrix.selfRow": "(selbst)", "matrix.relations": "Beziehungen", "matrix.coverage": "Abdeckung", "matrix.noData": "Keine Daten für diese Kombination gefunden.", diff --git a/frontend/src/i18n/locales/en/reports.json b/frontend/src/i18n/locales/en/reports.json index fdb346b6c..ebcdf969a 100644 --- a/frontend/src/i18n/locales/en/reports.json +++ b/frontend/src/i18n/locales/en/reports.json @@ -218,13 +218,43 @@ "matrix.rows": "Rows", "matrix.columns": "Columns", "matrix.cellDisplay": "Cell Display", + "matrix.cell": "Cell", "matrix.existsDot": "Exists (dot)", "matrix.countHeatmap": "Count (heatmap)", + "matrix.codes": "Values (codes)", + "matrix.labels": "Values (labels)", + "matrix.valuesUnavailable": "The relations between these two types carry no values to show.", + "matrix.filters": "Relation filter", + "matrix.relationType": "Relation type", + "matrix.direction": "Direction", + "matrix.directionAny": "Any direction", + "matrix.directionForward": "Row is the source", + "matrix.directionReverse": "Row is the target", + "matrix.directionBoth": "Both directions", + "matrix.clearFilters": "Clear all", + "matrix.moreFilters": "+{{count}} more", + "matrix.activeFilters_one": "{{count}} filter active", + "matrix.activeFilters_other": "{{count}} filters active", + "matrix.noRelationTypes": "No relation type connects {{row}} and {{col}}.", + "matrix.showOnlyGaps": "Show only gaps", + "matrix.hideNonMatching": "Hide non-matching cards", + "matrix.uncoveredRows": "{{type}} with no relation", + "matrix.uncoveredColumns": "{{type}} with no relation", + "matrix.searchRows": "Find row", + "matrix.searchColumns": "Find column", + "matrix.transpose": "Swap rows and columns", + "matrix.aggregatedHint": "Aggregated across the collapsed group — expand a level to see the values.", + "matrix.tooLarge": "This grid has {{count}} cells. Collapse a level or narrow the filter for smoother scrolling.", + "matrix.truncated": "Too many relations to show them all. Narrow the filter to see the rest.", + "matrix.exportGridSheet": "Grid", + "matrix.exportRelationsSheet": "Relations", + "matrix.total": "Total", "matrix.sortRows": "Sort Rows", "matrix.sortColumns": "Sort Columns", "matrix.alphaSort": "A → Z", "matrix.byCount": "By count", "matrix.hierarchy": "Hierarchy", + "matrix.selfRow": "(itself)", "matrix.relations": "Relations", "matrix.coverage": "Coverage", "matrix.noData": "No data found for this combination.", diff --git a/frontend/src/i18n/locales/es/reports.json b/frontend/src/i18n/locales/es/reports.json index 1bedd03b2..d29c022e4 100644 --- a/frontend/src/i18n/locales/es/reports.json +++ b/frontend/src/i18n/locales/es/reports.json @@ -218,13 +218,43 @@ "matrix.rows": "Filas", "matrix.columns": "Columnas", "matrix.cellDisplay": "Visualización de celda", + "matrix.cell": "Celda", "matrix.existsDot": "Existe (punto)", "matrix.countHeatmap": "Cantidad (mapa de calor)", + "matrix.codes": "Valores (códigos)", + "matrix.labels": "Valores (etiquetas)", + "matrix.valuesUnavailable": "Las relaciones entre estos dos tipos no tienen valores que mostrar.", + "matrix.filters": "Filtro de relación", + "matrix.relationType": "Tipo de relación", + "matrix.direction": "Dirección", + "matrix.directionAny": "Cualquier dirección", + "matrix.directionForward": "La fila es el origen", + "matrix.directionReverse": "La fila es el destino", + "matrix.directionBoth": "Ambas direcciones", + "matrix.clearFilters": "Borrar todo", + "matrix.moreFilters": "+{{count}} más", + "matrix.activeFilters_one": "{{count}} filtro activo", + "matrix.activeFilters_other": "{{count}} filtros activos", + "matrix.noRelationTypes": "Ningún tipo de relación conecta {{row}} y {{col}}.", + "matrix.showOnlyGaps": "Mostrar solo brechas", + "matrix.hideNonMatching": "Ocultar tarjetas que no coinciden", + "matrix.uncoveredRows": "{{type}} sin relación", + "matrix.uncoveredColumns": "{{type}} sin relación", + "matrix.searchRows": "Buscar fila", + "matrix.searchColumns": "Buscar columna", + "matrix.transpose": "Intercambiar filas y columnas", + "matrix.aggregatedHint": "Agregado sobre el grupo contraído — expanda un nivel para ver los valores.", + "matrix.tooLarge": "Esta cuadrícula tiene {{count}} celdas. Contraiga un nivel o restrinja el filtro para un desplazamiento más fluido.", + "matrix.truncated": "Hay demasiadas relaciones para mostrarlas todas. Restrinja el filtro para ver el resto.", + "matrix.exportGridSheet": "Cuadrícula", + "matrix.exportRelationsSheet": "Relaciones", + "matrix.total": "Total", "matrix.sortRows": "Ordenar filas", "matrix.sortColumns": "Ordenar columnas", "matrix.alphaSort": "A → Z", "matrix.byCount": "Por cantidad", "matrix.hierarchy": "Jerarquía", + "matrix.selfRow": "(él mismo)", "matrix.relations": "Relaciones", "matrix.coverage": "Cobertura", "matrix.noData": "No se encontraron datos para esta combinación.", diff --git a/frontend/src/i18n/locales/fr/reports.json b/frontend/src/i18n/locales/fr/reports.json index a2004beef..3c38c86a8 100644 --- a/frontend/src/i18n/locales/fr/reports.json +++ b/frontend/src/i18n/locales/fr/reports.json @@ -218,13 +218,43 @@ "matrix.rows": "Lignes", "matrix.columns": "Colonnes", "matrix.cellDisplay": "Affichage des cellules", + "matrix.cell": "Cellule", "matrix.existsDot": "Existence (point)", "matrix.countHeatmap": "Nombre (carte de chaleur)", + "matrix.codes": "Valeurs (codes)", + "matrix.labels": "Valeurs (libellés)", + "matrix.valuesUnavailable": "Les relations entre ces deux types ne portent aucune valeur à afficher.", + "matrix.filters": "Filtre de relation", + "matrix.relationType": "Type de relation", + "matrix.direction": "Sens", + "matrix.directionAny": "Tous les sens", + "matrix.directionForward": "La ligne est la source", + "matrix.directionReverse": "La ligne est la cible", + "matrix.directionBoth": "Les deux sens", + "matrix.clearFilters": "Tout effacer", + "matrix.moreFilters": "+{{count}} autres", + "matrix.activeFilters_one": "{{count}} filtre actif", + "matrix.activeFilters_other": "{{count}} filtres actifs", + "matrix.noRelationTypes": "Aucun type de relation ne relie {{row}} et {{col}}.", + "matrix.showOnlyGaps": "Afficher seulement les manques", + "matrix.hideNonMatching": "Masquer les cartes non correspondantes", + "matrix.uncoveredRows": "{{type}} sans relation", + "matrix.uncoveredColumns": "{{type}} sans relation", + "matrix.searchRows": "Trouver une ligne", + "matrix.searchColumns": "Trouver une colonne", + "matrix.transpose": "Permuter lignes et colonnes", + "matrix.aggregatedHint": "Agrégé sur le groupe replié — développez d'un niveau pour voir les valeurs.", + "matrix.tooLarge": "Cette grille compte {{count}} cellules. Repliez un niveau ou affinez le filtre pour un défilement plus fluide.", + "matrix.truncated": "Trop de relations pour toutes les afficher. Affinez le filtre pour voir le reste.", + "matrix.exportGridSheet": "Grille", + "matrix.exportRelationsSheet": "Relations", + "matrix.total": "Total", "matrix.sortRows": "Trier les lignes", "matrix.sortColumns": "Trier les colonnes", "matrix.alphaSort": "A → Z", "matrix.byCount": "Par nombre", "matrix.hierarchy": "Hiérarchie", + "matrix.selfRow": "(lui-même)", "matrix.relations": "Relations", "matrix.coverage": "Couverture", "matrix.noData": "Aucune donnée trouvée pour cette combinaison.", diff --git a/frontend/src/i18n/locales/it/reports.json b/frontend/src/i18n/locales/it/reports.json index 119289bdf..61812a4ce 100644 --- a/frontend/src/i18n/locales/it/reports.json +++ b/frontend/src/i18n/locales/it/reports.json @@ -218,13 +218,43 @@ "matrix.rows": "Righe", "matrix.columns": "Colonne", "matrix.cellDisplay": "Visualizzazione cella", + "matrix.cell": "Cella", "matrix.existsDot": "Esistenza (punto)", "matrix.countHeatmap": "Conteggio (heatmap)", + "matrix.codes": "Valori (codici)", + "matrix.labels": "Valori (etichette)", + "matrix.valuesUnavailable": "Le relazioni tra questi due tipi non contengono valori da mostrare.", + "matrix.filters": "Filtro relazioni", + "matrix.relationType": "Tipo di relazione", + "matrix.direction": "Direzione", + "matrix.directionAny": "Qualsiasi direzione", + "matrix.directionForward": "La riga è l'origine", + "matrix.directionReverse": "La riga è la destinazione", + "matrix.directionBoth": "Entrambe le direzioni", + "matrix.clearFilters": "Cancella tutto", + "matrix.moreFilters": "+{{count}} altri", + "matrix.activeFilters_one": "{{count}} filtro attivo", + "matrix.activeFilters_other": "{{count}} filtri attivi", + "matrix.noRelationTypes": "Nessun tipo di relazione collega {{row}} e {{col}}.", + "matrix.showOnlyGaps": "Mostra solo le lacune", + "matrix.hideNonMatching": "Nascondi le schede non corrispondenti", + "matrix.uncoveredRows": "{{type}} senza relazioni", + "matrix.uncoveredColumns": "{{type}} senza relazioni", + "matrix.searchRows": "Trova riga", + "matrix.searchColumns": "Trova colonna", + "matrix.transpose": "Scambia righe e colonne", + "matrix.aggregatedHint": "Aggregato sul gruppo compresso — espandi un livello per vedere i valori.", + "matrix.tooLarge": "Questa griglia ha {{count}} celle. Comprimi un livello o restringi il filtro per uno scorrimento più fluido.", + "matrix.truncated": "Troppe relazioni per mostrarle tutte. Restringi il filtro per vedere il resto.", + "matrix.exportGridSheet": "Griglia", + "matrix.exportRelationsSheet": "Relazioni", + "matrix.total": "Totale", "matrix.sortRows": "Ordina righe", "matrix.sortColumns": "Ordina colonne", "matrix.alphaSort": "A → Z", "matrix.byCount": "Per conteggio", "matrix.hierarchy": "Gerarchia", + "matrix.selfRow": "(esso stesso)", "matrix.relations": "Relazioni", "matrix.coverage": "Copertura", "matrix.noData": "Nessun dato trovato per questa combinazione.", diff --git a/frontend/src/i18n/locales/pt/reports.json b/frontend/src/i18n/locales/pt/reports.json index 30db8cf0f..ed3823642 100644 --- a/frontend/src/i18n/locales/pt/reports.json +++ b/frontend/src/i18n/locales/pt/reports.json @@ -218,13 +218,43 @@ "matrix.rows": "Linhas", "matrix.columns": "Colunas", "matrix.cellDisplay": "Exibição da célula", + "matrix.cell": "Célula", "matrix.existsDot": "Existe (ponto)", "matrix.countHeatmap": "Contagem (mapa de calor)", + "matrix.codes": "Valores (códigos)", + "matrix.labels": "Valores (rótulos)", + "matrix.valuesUnavailable": "As relações entre estes dois tipos não têm valores para mostrar.", + "matrix.filters": "Filtro de relação", + "matrix.relationType": "Tipo de relação", + "matrix.direction": "Direção", + "matrix.directionAny": "Qualquer direção", + "matrix.directionForward": "A linha é a origem", + "matrix.directionReverse": "A linha é o destino", + "matrix.directionBoth": "Ambas as direções", + "matrix.clearFilters": "Limpar tudo", + "matrix.moreFilters": "+{{count}} mais", + "matrix.activeFilters_one": "{{count}} filtro ativo", + "matrix.activeFilters_other": "{{count}} filtros ativos", + "matrix.noRelationTypes": "Nenhum tipo de relação liga {{row}} e {{col}}.", + "matrix.showOnlyGaps": "Mostrar apenas lacunas", + "matrix.hideNonMatching": "Ocultar cartões não correspondentes", + "matrix.uncoveredRows": "{{type}} sem relação", + "matrix.uncoveredColumns": "{{type}} sem relação", + "matrix.searchRows": "Procurar linha", + "matrix.searchColumns": "Procurar coluna", + "matrix.transpose": "Trocar linhas e colunas", + "matrix.aggregatedHint": "Agregado sobre o grupo recolhido — expanda um nível para ver os valores.", + "matrix.tooLarge": "Esta grelha tem {{count}} células. Recolha um nível ou restrinja o filtro para um deslocamento mais suave.", + "matrix.truncated": "Demasiadas relações para mostrar todas. Restrinja o filtro para ver as restantes.", + "matrix.exportGridSheet": "Grelha", + "matrix.exportRelationsSheet": "Relações", + "matrix.total": "Total", "matrix.sortRows": "Ordenar linhas", "matrix.sortColumns": "Ordenar colunas", "matrix.alphaSort": "A → Z", "matrix.byCount": "Por contagem", "matrix.hierarchy": "Hierarquia", + "matrix.selfRow": "(ele próprio)", "matrix.relations": "Relações", "matrix.coverage": "Cobertura", "matrix.noData": "Nenhum dado encontrado para esta combinação.", diff --git a/frontend/src/i18n/locales/ru/reports.json b/frontend/src/i18n/locales/ru/reports.json index d0b9cdd2f..b231c7671 100644 --- a/frontend/src/i18n/locales/ru/reports.json +++ b/frontend/src/i18n/locales/ru/reports.json @@ -218,13 +218,43 @@ "matrix.rows": "Строки", "matrix.columns": "Столбцы", "matrix.cellDisplay": "Отображение ячеек", + "matrix.cell": "Ячейка", "matrix.existsDot": "Наличие (точка)", "matrix.countHeatmap": "Количество (тепловая карта)", + "matrix.codes": "Значения (коды)", + "matrix.labels": "Значения (подписи)", + "matrix.valuesUnavailable": "Связи между этими двумя типами не содержат значений для отображения.", + "matrix.filters": "Фильтр связей", + "matrix.relationType": "Тип связи", + "matrix.direction": "Направление", + "matrix.directionAny": "Любое направление", + "matrix.directionForward": "Строка — источник", + "matrix.directionReverse": "Строка — цель", + "matrix.directionBoth": "Оба направления", + "matrix.clearFilters": "Очистить всё", + "matrix.moreFilters": "ещё {{count}}", + "matrix.activeFilters_one": "активен {{count}} фильтр", + "matrix.activeFilters_other": "активно фильтров: {{count}}", + "matrix.noRelationTypes": "Ни один тип связи не соединяет {{row}} и {{col}}.", + "matrix.showOnlyGaps": "Только пробелы", + "matrix.hideNonMatching": "Скрыть несовпадающие карточки", + "matrix.uncoveredRows": "{{type}} без связей", + "matrix.uncoveredColumns": "{{type}} без связей", + "matrix.searchRows": "Найти строку", + "matrix.searchColumns": "Найти столбец", + "matrix.transpose": "Поменять строки и столбцы", + "matrix.aggregatedHint": "Агрегировано по свёрнутой группе — разверните уровень, чтобы увидеть значения.", + "matrix.tooLarge": "В этой сетке {{count}} ячеек. Сверните уровень или сузьте фильтр для более плавной прокрутки.", + "matrix.truncated": "Слишком много связей, чтобы показать все. Сузьте фильтр, чтобы увидеть остальные.", + "matrix.exportGridSheet": "Сетка", + "matrix.exportRelationsSheet": "Связи", + "matrix.total": "Итого", "matrix.sortRows": "Сортировка строк", "matrix.sortColumns": "Сортировка столбцов", "matrix.alphaSort": "А → Я", "matrix.byCount": "По количеству", "matrix.hierarchy": "Иерархия", + "matrix.selfRow": "(сам)", "matrix.relations": "Связи", "matrix.coverage": "Покрытие", "matrix.noData": "Данные для этой комбинации не найдены.", diff --git a/frontend/src/i18n/locales/zh/reports.json b/frontend/src/i18n/locales/zh/reports.json index 3ce153294..04f515fa4 100644 --- a/frontend/src/i18n/locales/zh/reports.json +++ b/frontend/src/i18n/locales/zh/reports.json @@ -218,13 +218,43 @@ "matrix.rows": "行", "matrix.columns": "列", "matrix.cellDisplay": "单元格显示", + "matrix.cell": "单元格", "matrix.existsDot": "存在(点)", "matrix.countHeatmap": "计数(热力图)", + "matrix.codes": "取值(代码)", + "matrix.labels": "取值(标签)", + "matrix.valuesUnavailable": "这两种类型之间的关系没有可显示的取值。", + "matrix.filters": "关系筛选", + "matrix.relationType": "关系类型", + "matrix.direction": "方向", + "matrix.directionAny": "任意方向", + "matrix.directionForward": "行为来源方", + "matrix.directionReverse": "行为目标方", + "matrix.directionBoth": "双向", + "matrix.clearFilters": "全部清除", + "matrix.moreFilters": "另外 {{count}} 个", + "matrix.activeFilters_one": "已启用 {{count}} 个筛选", + "matrix.activeFilters_other": "已启用 {{count}} 个筛选", + "matrix.noRelationTypes": "没有关系类型连接 {{row}} 与 {{col}}。", + "matrix.showOnlyGaps": "仅显示缺口", + "matrix.hideNonMatching": "隐藏不匹配的卡片", + "matrix.uncoveredRows": "无关系的{{type}}", + "matrix.uncoveredColumns": "无关系的{{type}}", + "matrix.searchRows": "查找行", + "matrix.searchColumns": "查找列", + "matrix.transpose": "行列互换", + "matrix.aggregatedHint": "已在折叠分组上汇总 — 展开一层即可查看取值。", + "matrix.tooLarge": "该网格有 {{count}} 个单元格。折叠一层或收窄筛选可获得更流畅的滚动。", + "matrix.truncated": "关系过多,无法全部显示。请收窄筛选以查看其余部分。", + "matrix.exportGridSheet": "网格", + "matrix.exportRelationsSheet": "关系", + "matrix.total": "合计", "matrix.sortRows": "行排序", "matrix.sortColumns": "列排序", "matrix.alphaSort": "A → Z", "matrix.byCount": "按数量", "matrix.hierarchy": "层级", + "matrix.selfRow": "(自身)", "matrix.relations": "关系", "matrix.coverage": "覆盖率", "matrix.noData": "此组合未找到数据。", diff --git a/frontend/src/theme/tokens.ts b/frontend/src/theme/tokens.ts index 17c2f37b1..f57ebb4c4 100644 --- a/frontend/src/theme/tokens.ts +++ b/frontend/src/theme/tokens.ts @@ -202,6 +202,30 @@ export const TIMELINE_COLORS = { reset: "#ef6c00", } as const; +// ── Categorical series ────────────────────────────────────────────────── + +/** + * Qualitative palette for values that have no colour of their own — an + * admin-defined select option left uncoloured, a boolean flag, a series the + * metamodel says nothing about. Assign by a stable hash of the value's key so + * the same value keeps the same colour across renders, reports and sessions. + * + * Hues are spread far enough apart to stay distinguishable next to each other + * and legible on both the light and dark paper surfaces. + */ +export const CATEGORICAL_COLORS = [ + "#1976d2", + "#7b1fa2", + "#00897b", + "#ef6c00", + "#c2185b", + "#3949ab", + "#00838f", + "#5d4037", + "#558b2f", + "#d32f2f", +] as const; + // ── Non-color tokens ──────────────────────────────────────────────────── /** Border radius scale (px). MUI's `borderRadius` `sx` prop also accepts numbers from the spacing scale. */ diff --git a/scripts/screenshots/pages.ts b/scripts/screenshots/pages.ts index 635279269..e340c8067 100644 --- a/scripts/screenshots/pages.ts +++ b/scripts/screenshots/pages.ts @@ -899,7 +899,9 @@ export const DOC_PAGES: PageDef[] = [ id: "35_report_matrix", route: "/reports/matrix", waitFor: ".MuiPaper-root", - actions: [{ type: "wait", ms: 800 }], + // Longer than most: the filter bar and the gap tiles settle after the + // metamodel and the matrix payload have both landed. + actions: [{ type: "wait", ms: 1200 }], filenames: { en: "35_report_matrix", de: "35_bericht_matrix", @@ -911,7 +913,6 @@ export const DOC_PAGES: PageDef[] = [ ru: "35_otchet_matritsa", }, }, - // ── Saved Reports ─────────────────────────────────────────────────────── { id: "36_saved_reports",