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 **sync icon** to the left of the label:

* ``sync_local.svg`` -- run data is still on local disk (any sync
  status other than ``cleaned``).
* ``sync_cloud.svg`` -- run has been synced, verified, and locally
  cleaned (``sync_status == "cleaned"``); only the ``.exlab-wizard/``
  cache subtree remains on disk (§7.1.10).

``.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, SyncStatus, TreeProjectStatus
from exlab_wizard.logging import get_logger

_log = get_logger(__name__)


# Node kinds.
KIND_EQUIPMENT = "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})

# Static URLs served by ``ui/theme.py:register_static_assets``.
SYNC_ICON_LOCAL_URL = "/assets/sync_local.svg"
SYNC_ICON_CLOUD_URL = "/assets/sync_cloud.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
[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=f"{equipment.equipment_id}/{project.short_id}/{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.short_id}", 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_EQUIPMENT, children=tuple(project_nodes), ) ) return nodes
def _sync_icon_url(node: TreeNode) -> str | None: """Return the per-row sync-icon URL, or ``None`` for non-run rows. Run rows get one of the two ``/assets/sync_*.svg`` URLs depending on whether the run has been locally cleaned (``sync_cloud.svg``) or still has data on disk (``sync_local.svg``). Equipment / project rows render unchanged. """ if node.kind not in _RUN_KINDS: return None if node.sync_status == SyncStatus.CLEANED.value: return SYNC_ICON_CLOUD_URL return SYNC_ICON_LOCAL_URL
[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`. """ out: list[dict[str, Any]] = [] for node in nodes: payload: dict[str, Any] = { "id": node.node_id, "label": node.label, "kind": node.kind, "badges": list(node.badges), "children": to_nicegui_nodes(node.children), } icon_url = _sync_icon_url(node) if icon_url is not None: payload["sync_icon"] = icon_url payload["sync_status"] = node.sync_status or "" 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. # We drop in a single ``default-header`` template that renders the run's # sync icon (when present) immediately to the left of the node label. _TREE_DEFAULT_HEADER_SLOT = ( '<div class="row items-center" style="gap: 0.4rem">' '<img v-if="props.node.sync_icon" :src="props.node.sync_icon" ' 'style="width: 1rem; height: 1rem; flex-shrink: 0;" ' ":alt=\"props.node.sync_status || ''\" />" '<span :data-kind="props.node.kind" ' ":data-sync-status=\"props.node.sync_status || ''\">" "{{ props.node.label }}" "</span>" "</div>" )
[docs] def build_tree( *, hierarchy: dict[EquipmentNode, dict[ProjectNode, list[RunNode]]], on_select: Callable[[str], None] | None = None, filters: TreeFilters | None = None, expand_all: bool = False, ) -> 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. """ 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"') tree.add_slot("default-header", _TREE_DEFAULT_HEADER_SLOT) 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) return tree