Source code for daitum_ui.roster_view

# Copyright 2026 Daitum
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""
Roster view components for the UI Generator framework.

A `RosterView` lays out resource-based scheduling data as a grid: resources
(employees, machines, rooms, ...) are rows and shifts (days, time slots, ...)
are columns. Each row is sourced from `source_table`, and cell contents are
rendered via `Card` templates registered on the view.

A roster is composed of:
    - A **resource column** identifying each row's resource (e.g. employee name).
    - One or more **shift columns** holding the per-slot values being rostered.
    - An optional **summary column** showing per-row aggregates
      (e.g. total hours).

Classes:
    - `RosterColumn`: Configuration of a single column within the roster.
    - `RosterTaskDefinition`: Drag-and-drop behaviour for the cards in a
      shift column — whether cards are draggable, which fields swap
      between rows on drop, and the requirement/capability matching used
      to gate valid drop targets.
    - `RosterView`: The view itself, owning the resource/shift/summary
      columns and the shared card templates.

Example:
    >>> shifts_table = Table("employee_shifts")
    >>>
    >>> builder = UiBuilder()
    >>> roster_view = builder.add_roster_view(
    ...     source_table=shifts_table,
    ...     display_name="Weekly Schedule",
    ... )
    >>> roster_view.set_freeze_headers(True)
    >>>
    >>> roster_view.set_resource_column(
    ...     RosterColumn(
    ...         table_field_reference=Field("employee_name", DataType.STRING),
    ...         minimum_width="200px",
    ...     )
    ... )
    >>> roster_view.set_summary_column(
    ...     RosterColumn(
    ...         table_field_reference=Field("total_hours", DataType.DECIMAL),
    ...         minimum_width="100px",
    ...     )
    ... )
    >>>
    >>> roster_view.add_shift_column(
    ...     table_field_reference=Field("monday_hours", DataType.DECIMAL),
    ...     minimum_width="80px",
    ... )
"""

from daitum_model import Calculation, Field, Parameter, Table
from typeguard import typechecked

from daitum_ui._buildable import Buildable, json_type_info
from daitum_ui.base_view import BaseView
from daitum_ui.elements import Card, TemplateBindingKey
from daitum_ui.filter_component import FilterableView, FilterComponent
from daitum_ui.model_event import ModelEvent


[docs] @typechecked class RosterTaskDefinition(Buildable): """ Drag-and-drop configuration for the cards rendered in a roster column. Attached to a `RosterColumn` via `RosterColumn.set_task_definition(...)`, this describes only how cards in that column behave when the user drags them — it does not affect rendering, data binding, or which fields are displayed. Drags are always within a single column: a card is dragged from one row onto another row of the same column, and the drop swaps the values of the configured `swap_fields` between those two rows. The column's `table_field_reference` is typically a calculation that recomputes naturally from the swapped values; if it is a data field that should also move, add it to `swap_fields` explicitly. Attributes: enable_drag_and_drop_field: Field ID of a per-row boolean field on the source table that gates whether a given card is draggable. highlight_whole_column_on_drag: UI hint controlling drop-target highlighting. If True, every card in the column is shaded green/red up-front (so the user can see which rows are valid drops before dragging over them). If False, only the card currently hovered is highlighted. swap_fields: Complete list of field IDs whose values are swapped between the source and target rows when a drop occurs. required_value_fields: Field IDs on the dragged card carrying scalar requirement values. Paired positionally with `capability_fields` to gate which target rows are valid drop targets: a drop is only permitted when, for every index ``i``, the target row's `capability_fields[i]` (an array) contains the dragged card's `required_value_fields[i]` (a scalar of the matching type). capability_fields: Field IDs on the target row holding array-valued capabilities, aligned positionally with `required_value_fields`. """
[docs] def __init__(self, enable_drag_and_drop_field: Field, highlight_whole_column_on_drag: bool): """ Args: enable_drag_and_drop_field: Per-row boolean field gating whether each card is draggable. highlight_whole_column_on_drag: If True, highlight the whole destination column while dragging. """ self.enable_drag_and_drop_field = enable_drag_and_drop_field.id self.highlight_whole_column_on_drag: bool = highlight_whole_column_on_drag self.swap_fields: list[str] | None = None self.required_value_fields: list[str] | None = None self.capability_fields: list[str] | None = None
[docs] def add_swap_field(self, field: Field) -> "RosterTaskDefinition": """ Register a field whose value is swapped between the source and target rows on drop. Args: field: Field whose value moves with the card: on drop, the source row's value and the target row's value are exchanged. """ if self.swap_fields is None: self.swap_fields = [] self.swap_fields.append(field.id) return self
[docs] def add_requirement( self, required_field: Field, capability_field: Field ) -> "RosterTaskDefinition": """ Add a (requirement, capability) pair gating valid drop targets. A drop is only permitted when the target row's `capability_field` (an array) contains the dragged card's `required_field` value (a scalar). The capability field's data type must therefore be the array form of the required field's data type. Args: required_field: Scalar field on the dragged card supplying the required value. capability_field: Array-valued field on the target row whose contents must include `required_field`'s value for the drop to be valid. Raises: ValueError: If `capability_field`'s data type is not the array form of `required_field`'s data type. """ expected = required_field.to_data_type().to_array() if capability_field.to_data_type() != expected: raise ValueError( f"Capability field '{capability_field.id}' must be of type " f"{expected}; got {capability_field.to_data_type()}." ) if self.required_value_fields is None: self.required_value_fields = [] if self.capability_fields is None: self.capability_fields = [] self.required_value_fields.append(required_field.id) self.capability_fields.append(capability_field.id) return self
[docs] @typechecked class RosterColumn(Buildable): """ Configuration for a single column within a `RosterView`. A column either binds directly to a source-table field (via `table_field_reference`) or renders its cells with a card template (via `default_card_template_key`); at least one of the two must be supplied. Attributes: table_field_reference: Field ID of the source-table field providing this column's value. Required unless `default_card_template_key` is set. default_card_template_key: Key of a card template registered on the parent `RosterView` used to render normal (non-header) cells for this column. header_card_template_key: Optional card-template key used to render the column header. minimum_width: Optional CSS width hint (e.g. ``"120px"``) setting the column's minimum width. template_field_mappings: Card-template placeholder keys (`TemplateBindingKey`) mapped to the model `Field`/`Calculation`/`Parameter` strings substituted in at render time. model_event_mappings: Card-template placeholder keys mapped to `ModelEvent` instances fired when the corresponding UI element is activated. task_definition: Optional `RosterTaskDefinition` configuring drag-and-drop behaviour for the cards rendered in this column. Has no effect on rendering or data binding. """ def __init__( self, table_field_reference: Field | None = None, default_card_template_key: str | None = None, header_card_template_key: str | None = None, minimum_width: str | None = None, ): if table_field_reference is None and default_card_template_key is None: raise ValueError( f"{default_card_template_key} must be defined if {table_field_reference} " f"is not defined." ) self.table_field_reference = table_field_reference.id if table_field_reference else None self.default_card_template_key = default_card_template_key self.header_card_template_key = header_card_template_key self.minimum_width = minimum_width self.template_field_mappings: dict[str, str] = {} self.model_event_mappings: dict[str, ModelEvent] = {} self.task_definition: RosterTaskDefinition | None = None
[docs] def add_template_field_mapping( self, key: TemplateBindingKey, value: Field | Calculation | Parameter ) -> "RosterColumn": """ Bind a card-template placeholder to a model value. Args: key: Placeholder key declared in the card template. value: `Field`, `Calculation`, or `Parameter` whose string form is substituted into the template at render time. Returns: This `RosterColumn`, for fluent chaining. """ self.template_field_mappings[key.to_string()] = value.to_string() return self
[docs] def add_model_event_mapping(self, key: TemplateBindingKey, event: ModelEvent) -> "RosterColumn": """ Bind a card-template placeholder to a `ModelEvent`. Args: key: Placeholder key representing an interactive element in the card template. event: `ModelEvent` fired when that element is activated. Returns: This `RosterColumn`, for fluent chaining. """ self.model_event_mappings[key.to_string()] = event return self
[docs] def set_task_definition(self, task_definition: "RosterTaskDefinition") -> "RosterColumn": """ Attach drag-and-drop configuration for cards in this column. Args: task_definition: `RosterTaskDefinition` describing draggability, which fields swap between rows on drop, and requirement/capability matching for valid drop targets. Returns: This `RosterColumn`, for fluent chaining. """ self.task_definition = task_definition return self
[docs] @typechecked @json_type_info("roster") class RosterView(BaseView, FilterableView): """ Resource-by-shift tabular view. Each row in `source_table` is rendered as a resource row; the columns are the resource column (leftmost identifier), the shift columns (one per slot being rostered), and an optional summary column. Attributes: source_table: Table ID of the source table whose rows back the roster. card_templates: Shared `Card` templates, keyed by the strings referenced from `RosterColumn.default_card_template_key` and `RosterColumn.header_card_template_key`. resource_column: Leftmost column identifying each row's resource. Required for the view to be valid. shift_columns: Ordered list of shift columns appearing after the resource column. summary_column: Optional rightmost column showing per-row aggregates. freeze_headers: If True, the header row stays fixed while the body scrolls vertically. """ def __init__( self, source_table: Table, display_name: str | None = None, hidden: bool = False, ): # Initialise BaseView fields BaseView.__init__(self, hidden) if display_name is not None: self._display_name = display_name FilterableView.__init__(self) self.source_table = source_table.id self.card_templates: dict[str, Card] = {} self.resource_column: RosterColumn | None = None self.shift_columns: list[RosterColumn] = [] self.summary_column: RosterColumn | None = None self.freeze_headers: bool = False
[docs] def set_resource_column(self, col: "RosterColumn") -> "RosterView": """Set the resource (leftmost) column. Returns self for chaining.""" self.resource_column = col return self
[docs] def set_summary_column(self, col: "RosterColumn") -> "RosterView": """Set the optional summary (rightmost) column. Returns self for chaining.""" self.summary_column = col return self
[docs] def set_freeze_headers(self, freeze_headers: bool) -> "RosterView": """Set whether the header row stays fixed during vertical scrolling.""" self.freeze_headers = freeze_headers return self
[docs] def set_use_filter(self, use_filter: "FilterComponent") -> "RosterView": """ Attach a `FilterComponent` to this view and display its UI. Sets both the bound filter (`use_filter`) and the displayed filter (`show_filter`) to `use_filter.filter_name`. """ self.use_filter = use_filter.filter_name self.show_filter = use_filter.filter_name return self
[docs] def add_card_template(self, key: str, card: Card): """ Register a shared card template on the view. Card templates are looked up by `key` from `RosterColumn.default_card_template_key` and `RosterColumn.header_card_template_key`. Args: key: Unique identifier referenced from `RosterColumn` template keys. card: `Card` defining the template's layout and content. """ self.card_templates[key] = card
[docs] def add_shift_column( self, table_field_reference: Field | None = None, default_card_template_key: str | None = None, header_card_template_key: str | None = None, minimum_width: str | None = None, ) -> RosterColumn: """ Append a shift column to the roster. At least one of `table_field_reference` and `default_card_template_key` must be supplied. Any card-template keys provided must already be registered via `add_card_template`. Args: table_field_reference: Source-table `Field` providing this column's value. default_card_template_key: Key of the card template used to render normal cells. header_card_template_key: Optional key of the card template used to render the header. minimum_width: Optional CSS minimum width (e.g. ``"80px"``). Returns: The newly created `RosterColumn`, already appended to `shift_columns`. Raises: ValueError: If a referenced card-template key is not registered, or if neither `table_field_reference` nor `default_card_template_key` is provided. """ if default_card_template_key is not None: if default_card_template_key not in self.card_templates: raise ValueError( f"Card template '{default_card_template_key}' not found in " f"RosterView.card_templates." ) if header_card_template_key is not None: if header_card_template_key not in self.card_templates: raise ValueError( f"Card template '{header_card_template_key}' not found in " f"RosterView.card_templates." ) if table_field_reference is None and default_card_template_key is None: raise ValueError( f"{default_card_template_key} must be defined if " f"{table_field_reference} is not defined." ) column = RosterColumn( table_field_reference, default_card_template_key, header_card_template_key, minimum_width, ) self.shift_columns.append(column) return column