"""Generic expandable map-of-sub-model configuration card for EOSdash. This module provides `ConfigMapCard`, a reusable FastHTML/MonsterUI card component that renders any ``dict[str, PydanticSubModel]`` config field as a collapsible outer card containing one collapsible inner card per map entry, keyed by a user-supplied string name. It is intentionally free of imports from ``configuration.py`` to avoid circular dependencies. The one runtime dependency on ``create_config_details`` is injected by the caller. The structure mirrors ``itemscard.py`` with these key differences: - The stored value is ``dict[str, dict]`` rather than ``list[dict]``. - The "Add entry" control includes a text input for the key name. - Delete removes by key rather than by index. - Inner card headers display the string key instead of a numeric index. - ``create_config_details`` is called with the string key as the final ``values_prefix`` segment. Typical usage in ``configuration.py``:: from akkudoktoreos.server.dash.mapcard import ConfigMapCard hint = UI_HINTS.get(config["name"]) if hint and hint.form == "map_items" and not config["deprecated"]: rows.append( ConfigMapCard( config=config, hint=hint, config_details=config_details, config_update_latest=config_update_latest, create_config_details=create_config_details, ) ) """ import json from typing import Any, Callable, Optional from loguru import logger from monsterui.franken import ( H4, Card, Details, Div, DivHStacked, DivLAligned, DivRAligned, Form, Grid, Input, Kbd, P, Summary, UkIcon, ) from akkudoktoreos.server.dash.carditems import ( item_model_defaults, required_field_inputs, ) from akkudoktoreos.server.dash.components import ( ConfigButton, ConfigCard, JsonView, UpdateError, ) from akkudoktoreos.server.dash.context import request_url_for from akkudoktoreos.server.dash.markdown import Markdown from akkudoktoreos.server.dash.uihints import ( UiHint, hint_for_indexed_field, resolve_form_factory, resolve_item_model, ) # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _add_control( config_name: str, items_map: dict, item_model: Any, config_id: str, read_only: str, ) -> Any: """Build the "Add entry" control. Always includes a text input for the new key name. When the item model can be fully defaulted, that's the only input needed — clicking "Add entry" reads the key and submits the map with the new key set to the model defaults. When required fields have no default (e.g. `consumption_wh`/`duration_h` on `HomeApplianceCommonSettings`), the control also renders inputs for those fields, all wrapped in a form so HTML `required` blocks submission until the key and every required field are filled in — otherwise clicking "Add entry" would persist an entry that fails the model's own validation. If the typed key already exists the existing entry is overwritten — this is intentional and allows renaming-by-copy when combined with delete. Args: config_name: Dotted config key name. items_map: The current map, used as the base for the JS merge. item_model: The Pydantic model class or instance for one map entry. config_id: CSS-safe version of ``config_name`` (dots replaced with hyphens), used to scope element ids and the key input's name. Returns: A ``Form`` containing the key input, any required-field inputs, and the "Add entry" button. """ new_entry_defaults, required_missing = item_model_defaults(item_model) id_prefix = f"{config_id}-new-entry" inputs, js_pairs = required_field_inputs(item_model, required_missing, id_prefix) extra_js = f"{{ {', '.join(js_pairs)} }}" if js_pairs else "{}" current_json = json.dumps(items_map) defaults_json = json.dumps(new_entry_defaults) build_value_expr = f"""(() => {{ const k = document.querySelector("[name='{config_id}_new_key']").value.trim(); if (!k) return {json.dumps(json.dumps(items_map))}; const defaults = {defaults_json}; const extra = {extra_js}; Object.assign(defaults, extra); if ('device_id' in defaults) defaults.device_id = k; const updated = Object.assign({{}}, {current_json}, {{ [k]: defaults }}); return JSON.stringify(updated); }})()""" return Form( Grid( Input( placeholder="Entry name / key", name=f"{config_id}_new_key", id=f"{config_id}-new-key", required=True, cls="border rounded px-3 py-2 text-sm", ), *inputs, cols=2, cls="gap-2", ), ConfigButton( UkIcon("plus"), " Add entry", hx_put=request_url_for("/eosdash/configuration"), hx_target="#page-content", hx_swap="innerHTML", hx_vals=f'js:{{ action: "update", key: "{config_name}", value: {build_value_expr} }}', cls="mt-2", ), cls="space-y-2 mt-3", ) def _delete_control(config_name: str, items_map: dict, key: str) -> Details: """Build the two-click delete control for a single inner entry card header. The first click opens a ``
`` panel revealing a red "Confirm delete" button. Clicking outside collapses it. The second click submits an ``hx_put`` with the map minus the given key. Args: config_name: Dotted config key name, e.g. ``"devices.batteries"``. items_map: The current full map of entries. key: The string key of the entry to delete. Returns: A ``Details`` component implementing the two-click confirm pattern. """ remaining = {k: v for k, v in items_map.items() if k != key} remaining_json = json.dumps(json.dumps(remaining)) return Details( Summary( UkIcon("trash-2", cls="text-muted-foreground hover:text-destructive cursor-pointer"), cls="list-none", ), Div( ConfigButton( UkIcon("trash-2"), " Confirm delete", hx_put=request_url_for("/eosdash/configuration"), hx_target="#page-content", hx_swap="innerHTML", hx_vals=f'js:{{ action: "update", key: "{config_name}", value: {remaining_json} }}', cls="px-3 py-1 text-sm bg-destructive text-destructive-foreground hover:bg-destructive/90", ), cls="absolute z-10 mt-1 p-2 rounded-md border bg-background shadow-md", ), cls="relative", ) def _inner_card( config_name: str, item_path: str, key: str, item_value: str, is_empty: bool, read_only: str, item_rows: list, item_update_open: bool, delete_control: Optional[Details], ) -> Card: """Render a single collapsible inner card for one map entry. Args: config_name: Dotted config key of the parent map field. item_path: Dotted path prefix for this entry type, e.g. ``"devices.batteries"``. key: The string key identifying this entry in the map. item_value: JSON-encoded current value of this entry. is_empty: ``True`` when the entry dict is falsy (empty or ``None``). read_only: ``"rw"`` or ``"ro"`` inherited from the parent field. item_rows: Pre-built list of ``ConfigCard`` children for this entry. item_update_open: Whether this card should start expanded. delete_control: The two-click delete ``Details`` widget, or ``None`` for read-only fields. Returns: A ``Card`` component for this map entry. """ return Card( Details( Summary( Grid( Grid( DivLAligned( UkIcon(icon="play"), H4( f"{item_path}.{key}", cls="text-muted-foreground" if is_empty else "", ), delete_control, ), DivRAligned( P( "empty" if is_empty else read_only, cls="text-xs text-muted-foreground" if is_empty else "", ), ), ), JsonView(json.loads(item_value)), ), cls="list-none", ), *item_rows, cls="space-y-4 gap-4", open=item_update_open, ), cls=f"w-full {'opacity-60' if is_empty else ''}", ) def _outer_card( config_name: str, config_type: str, read_only: str, value: str, default: str, description: str, scope: Optional[list[str]], num_entries: int, items_update_value: str, items_update_error: Optional[str], items_update_open: bool, rows: list, add_control: Optional[Grid], ) -> Card: """Render the outer collapsible card for the whole map field. Args: config_name: Dotted config key name. config_type: Human-readable type string from config details. read_only: ``"rw"`` or ``"ro"``. value: JSON-encoded current map value. default: JSON-encoded default value. description: Field description text. num_entries: Current number of entries, shown as a badge. items_update_value: Value to pre-fill the fallback text input. items_update_error: Error string from the last failed update, or ``None``. items_update_open: Whether the outer card starts expanded. rows: Pre-built list of inner ``Card`` components. add_control: The "Add entry" ``Grid`` widget, or ``None`` for read-only fields. Returns: The outer ``Card`` component. """ return Card( Details( Summary( Grid( Grid( DivLAligned( UkIcon(icon="play"), P(config_name), P( f"{num_entries} entr{'ies' if num_entries != 1 else 'y'}", cls="ml-2 text-xs text-muted-foreground", ), ), DivRAligned(P(read_only)), ), JsonView(json.loads(value)), ), cls="list-none", ), # Add entry control below summary add_control, Grid( Div( DivHStacked(*[Kbd(s) for s in scope]) if scope else None, Markdown(description), ), P(config_type), ), # Default value row Grid( DivRAligned(P("default")), P(default), ) if read_only == "rw" else None, # Raw JSON fallback update form Grid( DivRAligned(P("update")), Grid( Form( Input(value="update", type="hidden", id="action"), Input(value=config_name, type="hidden", id="key"), Input(value=items_update_value, type="text", id="value"), hx_put=request_url_for("/eosdash/configuration"), hx_target="#page-content", hx_swap="innerHTML", ), ), ) if read_only == "rw" else None, # Last update error Grid( DivRAligned(P("update error")), UpdateError(items_update_error), ) if items_update_error else None, # Per-entry inner cards *rows, cls="space-y-4 gap-4", open=items_update_open, ), cls="w-full", ) # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- def ConfigMapCard( config: dict, hint: UiHint, config_details: dict[str, dict], config_update_latest: dict[str, dict], create_config_details: Callable, ) -> Card: """Creates a styled configuration card for a map of Pydantic sub-model entries. Renders a collapsible outer card representing the map field as a whole, containing one collapsible inner card per map entry keyed by a string name. Each inner card expands into individual ``ConfigCard`` rows for every field of the entry's Pydantic sub-model. The map contents are driven entirely by user interaction. An "Add entry" control at the bottom of the outer card accepts a key name and appends a new entry pre-filled with the sub-model's Pydantic field defaults. Each inner card header carries a trash icon that arms on first click (showing a red "Confirm delete" button via a ``
`` toggle) and deletes on the second click, with no modal required. Per-entry field forms are resolved via ``hint_for_indexed_field`` using the parent hint's ``item_path``, so per-field UI customisation (dropdowns, selects, etc.) is driven entirely by ``UI_HINTS`` entries — no hard-coded field-name checks are needed here. The outer card always includes a plain-text fallback update form for the whole map value so that recovery from a validation error is always possible. Args: config: A single entry from the ``config_details`` dict produced by ``create_config_details()``. Must contain the keys ``"name"``, ``"type"``, ``"read-only"``, ``"value"``, ``"default"``, ``"description"``, ``"deprecated"``, and ``"scope"``. hint: The ``UiHint`` for this field. Must have ``form == "map_items"`` and valid ``item_model`` (resolved via ``resolve_item_model``) and ``item_path`` values. config_details: The full config detail dict for the current page render, used to look up per-entry field update state. config_update_latest: The module-level dict that tracks the most recent update attempt for each config key, with sub-keys ``"error"``, ``"value"``, and ``"open"``. create_config_details: The ``create_config_details`` callable from ``configuration.py``, injected to avoid a circular import. Signature: ``(model, values, values_prefix) -> dict[str, dict]``. Returns: Card: A fully rendered outer ``Card`` component containing the map summary with entry count, description, default value row, a raw-JSON fallback update form, an optional error row, one collapsible inner ``Card`` per existing entry each with a two-click delete control, and an "Add entry" control at the bottom. Raises: TypeError: If ``update_error``, ``update_value``, or ``update_open`` retrieved from ``config_update_latest`` are not of the expected types (``str | None``, ``str | None``, ``bool | None`` respectively). This should never trigger in normal operation but is checked explicitly to satisfy static analysis. Example: Typical call from inside the ``Configuration()`` render loop:: from akkudoktoreos.server.dash.mapcard import ConfigMapCard hint = UI_HINTS.get(config["name"]) if hint and hint.form == "map_items" and not config["deprecated"]: rows.append( ConfigMapCard( config=config, hint=hint, config_details=config_details, config_update_latest=config_update_latest, create_config_details=create_config_details, ) ) """ config_name = config["name"] config_type = config["type"] read_only = config["read-only"] value = config["value"] default = config["default"] description = config["description"] config_id = config_name.lower().replace(".", "-") item_model = resolve_item_model(hint) if item_model is None: raise ValueError(f"Hint for {config_name} needs item_model to be listed. Got {hint}") item_path = hint.item_path # e.g. "devices.batteries" if item_path is None: raise ValueError(f"Hint for {config_name} needs item_path to be listed. Got {hint}") path_parts = item_path.split(".") # e.g. ["devices", "batteries"] items_map = json.loads(value) or {} num_entries = len(items_map) # Outer card update state — resolved once before the inner loop items_update_error = config_update_latest.get(config_name, {}).get("error") items_update_value = config_update_latest.get(config_name, {}).get("value") or value items_update_open = config_update_latest.get(config_name, {}).get("open") or False # Add entry control (key input + button) shown at the bottom of the card. # One-click append when the item model is fully defaulted, otherwise an inline form that # collects required fields before the PUT fires (see _add_control / _item_model_defaults # docstrings). add_control = _add_control( config_name=config_name, items_map=items_map, item_model=item_model, config_id=config_id, read_only=read_only, ) # Build inner cards — one per map key, sorted for stable ordering rows = [] for key in sorted(items_map.keys()): entry = items_map[key] # Synthetic wrapper: e.g. {"devices": {"batteries": {"bat1": {...}}}} wrapped = {key: entry} for part in reversed(path_parts): wrapped = {part: wrapped} item_config = create_config_details( item_model, wrapped, values_prefix=path_parts + [key], ) item_rows = [] item_update_open = False item_value = json.dumps(entry) if entry is not None else json.dumps(None) is_empty = not entry for field_key in sorted(item_config.keys()): sub = item_config[field_key] update_error = config_update_latest.get(sub["name"], {}).get("error") update_value = config_update_latest.get(sub["name"], {}).get("value") update_open = config_update_latest.get(sub["name"], {}).get("open") if update_open: items_update_open = True # bubble up to outer card item_update_open = True # Make mypy happy — should never trigger if ( not isinstance(update_error, (str, type(None))) or not isinstance(update_value, (str, type(None))) or not isinstance(update_open, (bool, type(None))) ): error_msg = "update_error or update_value or update_open of wrong type." logger.error(error_msg) raise TypeError(error_msg) sub_hint = hint_for_indexed_field(sub["name"], item_path) update_form_factory = ( resolve_form_factory(sub_hint, config_details) if sub_hint else None ) item_rows.append( ConfigCard( sub["name"], sub["type"], sub["read-only"], sub["value"], sub["default"], sub["description"], sub["deprecated"], sub["scope"], update_error, update_value, update_open, update_form_factory, ) ) rows.append( _inner_card( config_name=config_name, item_path=item_path, key=key, item_value=item_value, is_empty=is_empty, read_only=read_only, item_rows=item_rows, item_update_open=item_update_open, delete_control=_delete_control(config_name, items_map, key) if read_only == "rw" else None, ) ) return _outer_card( config_name=config_name, config_type=config_type, read_only=read_only, value=value, default=default, description=description, scope=config.get("scope"), num_entries=num_entries, items_update_value=items_update_value, items_update_error=items_update_error, items_update_open=items_update_open, rows=rows, add_control=add_control, )