Source code for exlab_wizard.ui.components.tree

"""Project / equipment tree (Frontend Spec §3.5).

Renders the ``<equipment>/<project>/<run>`` hierarchy:

* Equipment node -- equipment ID in heading color.
* Project node -- human name + short_id, with optional archived /
  deleted-from-LIMS treatment.
* Run node (experimental) -- ``Run_<DATE>`` + label.
* Run node (test) -- dimmed styling + ``TestRun_`` prefix in
  warning-tier color + a *"Test"* pill.

Run rows also carry a small colour-coded **rollup sync icon** to the left
of the label (two-icon sync-presence design, 2026-05-30):

* ``sync_local.svg`` on a blue background -- run data is still on local
  disk (not fully backed up).
* ``sync_nas.svg`` on a green background -- the run is fully backed up on
  the NAS.
* ``sync_nas.svg`` on a red / amber background with a ``✕`` / ``!`` badge
  -- a problem (upload failed) or a held (blocked-by-validation) run.

The run-node rollup discriminator is derived from the run's
``sync_state.json`` + persisted ``creation.json`` failure status by the
browse router; ``RunNode.sync_status`` carries that string, which
:func:`~exlab_wizard.ui.components.sync_status_icon.file_sync_view` maps
to a :class:`~exlab_wizard.ui.components.sync_status_icon.FileSyncView`.

``.exlab-wizard/`` folders are hidden by default (Frontend §13.1) and
hidden filtering is the caller's concern.

The component returns a NiceGUI ``ui.tree`` configured with a list of
node dicts; tests can assert on the data shape without spinning up
NiceGUI.
"""

from __future__ import annotations

from collections.abc import Callable, Iterable
from dataclasses import dataclass, field
from typing import Any

from exlab_wizard.constants.enums import RunKind, TreeProjectStatus
from exlab_wizard.logging import get_logger
from exlab_wizard.ui.components.sync_status_icon import (
    file_sync_view,
    sync_rollup_icon_props,
)

_log = get_logger(__name__)


# Node kinds.
KIND_EQUIPMENT = "equipment"
KIND_RECEIVED_EQUIPMENT = "received_equipment"
KIND_PROJECT = "project"
KIND_RUN_EXPERIMENTAL = "run_experimental"
KIND_RUN_TEST = "run_test"

_RUN_KINDS: frozenset[str] = frozenset({KIND_RUN_EXPERIMENTAL, KIND_RUN_TEST})
_EQUIPMENT_KINDS: frozenset[str] = frozenset({KIND_EQUIPMENT, KIND_RECEIVED_EQUIPMENT})


# Node-kind -> (MDI glyph, Okabe-Ito colour token). Distinct from the
# file-type map (different domain): equipment/project/run, not file extensions.
_NODE_TYPE_PROPS: dict[str, tuple[str, str]] = {
    KIND_EQUIPMENT: ("mdi-microscope", "--oi-blue"),
    KIND_RECEIVED_EQUIPMENT: ("mdi-microscope", "--oi-blue"),
    KIND_PROJECT: ("mdi-folder", "--oi-grey"),
    KIND_RUN_EXPERIMENTAL: ("mdi-file-document", "--oi-sky"),
    KIND_RUN_TEST: ("mdi-flask-outline", "--oi-purple"),
}


def _node_type_props(kind: str) -> tuple[str, str]:
    """Return the ``(icon, colour_var)`` for a tree node kind."""
    return _NODE_TYPE_PROPS.get(kind, ("mdi-folder-outline", "--oi-grey"))


# Map internal kind to the testid suffix the Playwright flows expect.
# Both run_experimental and run_test collapse to "run" (the e2e contract
# treats them interchangeably for selection / context-menu purposes).
_TESTID_KIND_BY_KIND: dict[str, str] = {
    KIND_EQUIPMENT: "equipment",
    KIND_RECEIVED_EQUIPMENT: "received_equipment",
    KIND_PROJECT: "project",
    KIND_RUN_EXPERIMENTAL: "run",
    KIND_RUN_TEST: "run",
}

# Static URLs served by ``ui/theme.py:register_static_assets``. The rollup
# props come from :mod:`sync_status_icon`; these names are retained as the
# module's public asset-URL surface (asserted by the tree tests).
SYNC_ICON_LOCAL_URL = "/assets/sync_local.svg"
SYNC_ICON_NAS_URL = "/assets/sync_nas.svg"


[docs] @dataclass(frozen=True) class TreeFilters: """Filter chip state passed to the tree (Frontend §3.5.4).""" active: bool = True archived: bool = False test_runs: bool = True search: str = ""
[docs] @dataclass(frozen=True) class EquipmentNode: equipment_id: str # Redesign §3.3: relay equipment (received from another workstation) # renders the same row but disables creation actions and uses a # different context-menu surface (none, per decision 3). relay: bool = False
[docs] @dataclass(frozen=True) class ProjectNode: short_id: str name: str status: TreeProjectStatus = TreeProjectStatus.ACTIVE
[docs] @dataclass(frozen=True) class RunNode: directory_name: str run_kind: RunKind label: str | None = None sync_status: str | None = None # one of SyncStatus values; None when unknown
[docs] @dataclass(frozen=True) class TreeNode: """A renderable tree node (post-filter).""" node_id: str label: str kind: str children: tuple[TreeNode, ...] = field(default_factory=tuple) badges: tuple[str, ...] = field(default_factory=tuple) style_hints: dict[str, str] = field(default_factory=dict) sync_status: str | None = None # set on run nodes only
def _matches_search(text: str, query: str) -> bool: """Case-insensitive substring match used by the search box.""" if not query: return True return query.lower() in text.lower()
[docs] def filter_project(project: ProjectNode, filters: TreeFilters) -> bool: """Return ``True`` when ``project`` should be rendered. Active default-on; Archived default-off. Deleted-from-LIMS rows always render (Frontend §3.5.3). """ if project.status == TreeProjectStatus.DELETED: return True if project.status == TreeProjectStatus.ACTIVE and not filters.active: return False return not (project.status == TreeProjectStatus.ARCHIVED and not filters.archived)
[docs] def filter_run(run: RunNode, filters: TreeFilters) -> bool: """Return ``True`` when ``run`` should be rendered. Test runs default-on; toggling the chip off hides them. """ return not (run.run_kind == RunKind.TEST and not filters.test_runs)
[docs] def build_nodes( *, hierarchy: dict[EquipmentNode, dict[ProjectNode, list[RunNode]]], filters: TreeFilters, ) -> list[TreeNode]: """Translate a hierarchy into a list of :class:`TreeNode`.""" nodes: list[TreeNode] = [] for equipment, projects in hierarchy.items(): project_nodes: list[TreeNode] = [] for project, runs in projects.items(): if not filter_project(project, filters): continue project_label = f"{project.name} · {project.short_id}" project_search = f"{project.name} {project.short_id}" run_nodes: list[TreeNode] = [] for run in runs: if not filter_run(run, filters): continue if ( run.label and not _matches_search(f"{run.directory_name} {run.label}", filters.search) and not _matches_search(project_search, filters.search) ): continue if ( not run.label and not _matches_search(run.directory_name, filters.search) and not _matches_search(project_search, filters.search) ): continue badges: tuple[str, ...] if run.run_kind == RunKind.TEST: style_hints = {"variant": "dim", "prefix_color": "--color-warning"} badges = ("Test",) kind = KIND_RUN_TEST else: style_hints = {"variant": "default"} badges = () kind = KIND_RUN_EXPERIMENTAL run_label = run.directory_name + (f" -- {run.label}" if run.label else "") run_nodes.append( TreeNode( # node_id mirrors the on-disk path: the project # segment is the human-readable name (§3.2). node_id=f"{equipment.equipment_id}/{project.name}/{run.directory_name}", label=run_label, kind=kind, badges=badges, style_hints=style_hints, sync_status=run.sync_status, ) ) if not run_nodes and not _matches_search(project_search, filters.search): continue project_style: dict[str, str] = {} project_badges: tuple[str, ...] = () if project.status == TreeProjectStatus.ARCHIVED: project_style["text_decoration"] = "line-through" project_badges = ("(archived)",) elif project.status == TreeProjectStatus.DELETED: project_style["text_color"] = "var(--color-warning)" project_badges = ("(LIMS project removed)",) project_nodes.append( TreeNode( node_id=f"{equipment.equipment_id}/{project.name}", label=project_label, kind=KIND_PROJECT, children=tuple(run_nodes), badges=project_badges, style_hints=project_style, ) ) nodes.append( TreeNode( node_id=equipment.equipment_id, label=equipment.equipment_id, kind=KIND_RECEIVED_EQUIPMENT if equipment.relay else KIND_EQUIPMENT, children=tuple(project_nodes), ) ) return nodes
def _sync_rollup_props(node: TreeNode) -> dict[str, str] | None: """Return the single rollup-icon props for a run row, or None for non-run rows. Run rows are summarised by a single colour-coded rollup icon derived from the run's discriminator (``node.sync_status``) via the two-icon presentation map: ``sync_nas.svg`` (green) when fully backed up, ``sync_local.svg`` (blue) while local files remain, plus red/amber problem/held variants with a badge. Equipment / project rows render no sync icon. """ if node.kind not in _RUN_KINDS: return None return sync_rollup_icon_props(file_sync_view(node.sync_status))
[docs] def to_nicegui_nodes(nodes: Iterable[TreeNode]) -> list[dict[str, Any]]: """Convert :class:`TreeNode` instances to NiceGUI ``ui.tree`` dicts. Run rows additionally carry a ``sync_icon`` URL string and a ``sync_status`` string used by the ``default-header`` scoped-slot template attached in :func:`build_tree`. Each row also carries a ``testid_kind`` field — the suffix the Playwright flows expect on ``data-testid="tree-node-<suffix>"``. Both run kinds collapse to ``"run"`` so the e2e selectors can treat experimental and test runs interchangeably. """ out: list[dict[str, Any]] = [] for node in nodes: type_icon, type_color = _node_type_props(node.kind) payload: dict[str, Any] = { "id": node.node_id, "label": node.label, "kind": node.kind, "testid_kind": _TESTID_KIND_BY_KIND.get(node.kind, node.kind), "badges": list(node.badges), "children": to_nicegui_nodes(node.children), "type_icon": type_icon, "type_color": type_color, } rollup = _sync_rollup_props(node) if rollup is not None: payload["sync_icon"] = rollup["svg"] payload["sync_bg"] = rollup["bg_var"] payload["sync_badge"] = rollup["badge"] payload["sync_badge_bg"] = rollup.get("badge_bg", "--color-sync-problem") payload["sync_title"] = rollup["tooltip"] payload["sync_status"] = file_sync_view(node.sync_status).value out.append(payload) return out
# Quasar ``q-tree`` does not honour an ``icon`` / ``img`` field on plain # node dicts; per-node images must come through a scoped slot template. # The ``default-header`` template renders the per-row sync icon (when # present), emits the data-testid / data-node-id attributes the # Playwright flows assert on, and inlines the right-click context menus # (owned equipment / runs) as Vue ``q-menu`` children. Each q-menu # auto-attaches to its parent row (no ``target`` selector required, so # we sidestep both NiceGUI's props-parser quote handling and Quasar's # mount-before-DOM-ready target-resolution race). Menu items emit the # concrete NiceGUI websocket event payload for the tree listener. A # native DOM CustomEvent is not enough here because NiceGUI's # ``element.on`` registers Vue component listeners, and q-menu content # is teleported outside the tree row. def _ctx_emit(*, tree_id: int, listener_id: str, kind: str, action: str) -> str: """Return the Vue ``@click`` expression that reaches NiceGUI's listener. Uses ``&quot;`` for inner double quotes so the expression survives HTML attribute parsing inside the slot template. """ return ( "$event.view.socket?.emit(&quot;event&quot;, " f"{{id: {tree_id}, client_id: $event.view.clientId, listener_id: &quot;{listener_id}&quot;, " "args: [$event.view.JSON.stringify(" f"{{node_id: props.node.id, kind: '{kind}', action: '{action}'}}" ")]})" ) def _tree_header_slot(*, tree_id: int, listener_id: str) -> str: return ( '<div class="row items-center" style="gap: 0.4rem">' '<q-icon v-if="props.node.type_icon" :name="props.node.type_icon" ' ":style=\"{ color: 'var(' + props.node.type_color + ')', fontSize: '1rem', flexShrink: 0 }\"></q-icon>" '<span v-if="props.node.sync_icon" ' ':data-sync-bg="props.node.sync_bg" ' 'style="position: relative; display: inline-flex; align-items: center; ' "justify-content: center; width: 1.25rem; height: 1.25rem; " 'border-radius: var(--radius-sm); flex-shrink: 0;" ' ":style=\"{ background: 'var(' + props.node.sync_bg + ')' }\" " ":title=\"props.node.sync_title || ''\">" '<img :src="props.node.sync_icon" ' 'style="width: 0.85rem; height: 0.85rem;" ' ":alt=\"props.node.sync_status || ''\" />" '<span v-if="props.node.sync_badge" data-sync-badge="true" ' 'style="position: absolute; top: -3px; right: -3px; font-size: 0.55rem; ' "font-weight: 700; color: var(--color-surface); border-radius: 50%; " "width: 0.75rem; height: 0.75rem; display: flex; align-items: center; " 'justify-content: center;" ' ":style=\"{ background: 'var(' + (props.node.sync_badge_bg || '--color-sync-problem') + ')' }\">" "{{ props.node.sync_badge }}</span>" "</span>" "<span :data-testid=\"'tree-node-' + props.node.testid_kind\" " ':data-node-id="props.node.id" ' ':data-kind="props.node.kind" ' ":data-sync-status=\"props.node.sync_status || ''\">" "{{ props.node.label }}" "</span>" # Owned-equipment context menu (Edit / Remove). "<q-menu v-if=\"props.node.kind === 'equipment'\" context-menu auto-close " 'data-testid="tree-context-menu">' "<q-list dense>" '<q-item clickable v-close-popup data-testid="tree-context-edit-equipment" ' f'@click="{_ctx_emit(tree_id=tree_id, listener_id=listener_id, kind="equipment", action="edit_equipment")}">' "<q-item-section>Edit equipment…</q-item-section>" "</q-item>" '<q-item clickable v-close-popup data-testid="tree-context-remove-equipment" ' f'@click="{_ctx_emit(tree_id=tree_id, listener_id=listener_id, kind="equipment", action="remove_equipment")}">' "<q-item-section>Remove…</q-item-section>" "</q-item>" "</q-list>" "</q-menu>" # Run context menu (Force sync / Clear verified / View log). "<q-menu v-if=\"props.node.kind === 'run_experimental' || props.node.kind === 'run_test'\" " 'context-menu auto-close data-testid="run-context-menu">' "<q-list dense>" '<q-item clickable v-close-popup data-testid="run-context-force-sync" ' f'@click="{_ctx_emit(tree_id=tree_id, listener_id=listener_id, kind="run", action="force_sync")}">' "<q-item-section>Force sync</q-item-section>" "</q-item>" '<q-item clickable v-close-popup data-testid="run-context-clear-verified" ' f'@click="{_ctx_emit(tree_id=tree_id, listener_id=listener_id, kind="run", action="clear_verified")}">' "<q-item-section>Clear verified</q-item-section>" "</q-item>" '<q-item clickable v-close-popup data-testid="run-context-view-log" ' f'@click="{_ctx_emit(tree_id=tree_id, listener_id=listener_id, kind="run", action="view_log")}">' "<q-item-section>View log</q-item-section>" "</q-item>" "</q-list>" "</q-menu>" "</div>" ) _TREE_DEFAULT_HEADER_SLOT = _tree_header_slot(tree_id=0, listener_id="listener")
[docs] def build_tree( *, hierarchy: dict[EquipmentNode, dict[ProjectNode, list[RunNode]]], on_select: Callable[[str], None] | None = None, on_equipment_context_action: Callable[[str, str], None] | None = None, on_run_context_action: Callable[[str, str], None] | None = None, filters: TreeFilters | None = None, expand_all: bool = False, selected_node: str | None = None, ) -> Any: """Build the project / equipment tree. Returns the NiceGUI ``ui.tree`` element, or the immutable nodes list when called outside of a NiceGUI app context (tests). ``expand_all`` toggles Quasar's ``default-expand-all`` prop -- used by e2e tests that need every node visible in the DOM without having to click expand carets. ``on_equipment_context_action`` and ``on_run_context_action`` receive ``(node_id, action)`` when the operator picks an item from the per-row right-click menu (Redesign §4.6, dec. 4A). Action strings match the constants in :mod:`exlab_wizard.ui.components.tree_context_menu` (``edit_equipment`` / ``remove_equipment`` / ``force_sync`` / ``clear_verified`` / ``view_log``). Received- equipment rows never raise these callbacks (no context menu). """ f = filters or TreeFilters() nodes = build_nodes(hierarchy=hierarchy, filters=f) payload = to_nicegui_nodes(nodes) try: from nicegui import ui except Exception: return payload tree = ui.tree(payload, label_key="label", node_key="id").props('data-testid="main-tree"') if selected_node: # Seed Quasar's v-model:selected so the current node renders with the # selected fill + 3px accent bar (the theme's .q-tree__node--selected # rule mirrors the file-row selection). The id can contain spaces / # slashes, so set the prop dict directly rather than via the # whitespace-splitting props-string parser (OQ-6). tree._props["selected"] = selected_node if expand_all: # NiceGUI's wrapper for Quasar's expandAll() method. tree.expand() if on_select is not None: def _selected(event: Any) -> None: on_select(event.value) tree.on_select(_selected) def _on_context_action(event: Any) -> None: detail = event.args if isinstance(detail, list) and detail: detail = detail[0] if not isinstance(detail, dict): return kind = detail.get("kind", "") node_id = detail.get("node_id", "") action = detail.get("action", "") if not node_id or not action: return if kind == "equipment" and on_equipment_context_action is not None: on_equipment_context_action(node_id, action) elif kind == "run" and on_run_context_action is not None: on_run_context_action(node_id, action) existing_listeners = set(tree._event_listeners) tree.on("tree-context-action", _on_context_action) listener_ids = [key for key in tree._event_listeners if key not in existing_listeners] listener_id = listener_ids[-1] if listener_ids else "" tree.add_slot( "default-header", _tree_header_slot(tree_id=tree.id, listener_id=listener_id), ) return tree