From fda01a5686efa205fbb54160e684d10434059800 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Fournier?= Date: Wed, 25 Mar 2026 21:26:14 -0400 Subject: [PATCH 1/9] feat: add richer pandas table filters --- README.md | 25 +- docs/architecture.md | 2 +- docs/examples.md | 3 +- docs/plugin.md | 5 +- docs/product-roadmap.md | 1 + docs/table-filter-operators-plan.md | 113 ++++ src/nicegui_builder/core/datetime_inputs.py | 196 +++++++ src/nicegui_builder/core/filter_operators.py | 34 ++ src/nicegui_builder/core/form.py | 56 +- src/nicegui_builder/core/table.py | 15 +- .../examples/10_pandas_table_filters.py | 36 +- src/nicegui_builder/plugins/pandas/plugin.py | 490 ++++++++++++++++-- .../plugins/pydantic/resolve.py | 48 +- tests/test_nicegui_builder/core/test_table.py | 51 +- .../plugins/pandas/test_plugin.py | 355 ++++++++++++- 15 files changed, 1266 insertions(+), 164 deletions(-) create mode 100644 docs/table-filter-operators-plan.md create mode 100644 src/nicegui_builder/core/datetime_inputs.py create mode 100644 src/nicegui_builder/core/filter_operators.py diff --git a/README.md b/README.md index 72aa29d..16e0fb7 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,28 @@ For the `pandas` plugin, a richer filtered table variant is also available: handle = table(df, variant="filters") ``` +The built-in filtered table UI exposes operator symbols out of the box: + +- `∋` contains +- `=` equals +- `≠` not equals +- `>` greater than +- `≥` greater than or equal +- `<` less than +- `≤` less than or equal +- `⋖` starts with +- `⋗` ends with +- `∈` in +- `∉` not in +- `≈` regex +- `⋯` between + +The UI lets you choose a field, an operator, and one or more values, then add that filter to an active filter list. +Active filters can be enabled or disabled with a checkbox and removed from the list without losing the builder state. +For `in` and `notIn`, the current UI accepts comma-separated values. +For numeric and datetime columns, `between` renders two inputs. +For `datetime` columns, the built-in filter UI uses the same split `date_input + time_input` pattern as the `pydantic` forms. + ## Working With Form Handles `form(...)` returns a `FormHandle`. @@ -269,8 +291,9 @@ Filter: ```python handle.set_filter("name", "ada", op="contains") handle.set_filter("score", [10, 20], op="between") +handle.set_filter("status", "confirmed, waitlist", op="in") handle.apply_filters() -handle.clear_filters().apply_filters() +handle.clear_filters() ``` CRUD-style actions: diff --git a/docs/architecture.md b/docs/architecture.md index 935c093..a8db78a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -364,7 +364,7 @@ Examples of notable behavior: - sortable table defaults - pagination and selection support -- richer filtering workflow +- richer filtering workflow with operator-aware filter building and an active filter list ## Design Principles diff --git a/docs/examples.md b/docs/examples.md index fd3c798..f7d45ef 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -196,8 +196,9 @@ Goal: Covers: - rich plugin rendering -- filter controls +- a filter builder with an active filter list - automatic filter metadata +- operator symbols, comma-separated `in` / `notIn`, and `between` Suggested file: diff --git a/docs/plugin.md b/docs/plugin.md index c78a850..ad39ce8 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -148,7 +148,8 @@ Its code is organized under: The YAML file is plugin-local on purpose, so the plugin stays self-contained. One useful pattern in the `pydantic` plugin is the `datetime` split field. -The resolver keeps `date_input` and `time_input` grouped as one logical field, while still allowing the layout node to override the wrapper container. +That split `date_input + time_input` behavior is now centralized in the core so multiple plugins can reuse the same widget pattern while still treating it as one logical value. +The `pydantic` resolver keeps those inputs grouped as one logical field while still allowing the layout node to override the wrapper container. Example: @@ -169,7 +170,7 @@ It shows how to: - inspect a `DataFrame` - infer column/filter metadata - provide default table widgets -- optionally render a richer filtered table variant +- optionally render a richer filtered table variant with a filter builder and active filter list See: diff --git a/docs/product-roadmap.md b/docs/product-roadmap.md index d19eb49..c073d8f 100644 --- a/docs/product-roadmap.md +++ b/docs/product-roadmap.md @@ -65,6 +65,7 @@ Goals: Delivered: - richer filter operators in the `pandas` plugin +- filter-builder UI with an active filter list - `TableHandle.set_filter(...)` - `TableHandle.clear_filters()` - stateful `TableHandle.apply_filters(...)` diff --git a/docs/table-filter-operators-plan.md b/docs/table-filter-operators-plan.md new file mode 100644 index 0000000..a5c0b20 --- /dev/null +++ b/docs/table-filter-operators-plan.md @@ -0,0 +1,113 @@ +# Table Filter Operators Plan + +[Home](../README.md) + +This document captures the implementation plan for out-of-the-box filter operator selection in table layouts, primarily for the `pandas` table plugin. + +## Decisions + +- Include the following built-in filter operators: + - `contains` `∋` + - `equals` `=` + - `notEquals` `≠` + - `gt` `>` + - `gte` `≥` + - `lt` `<` + - `lte` `≤` + - `startsWith` `⋖` + - `endsWith` `⋗` + - `in` `∈` + - `notIn` `∉` + - `regex` `≈` + - `between` `⋯` +- Support `between` in v1 for numeric and datetime filters. +- Do not expose `between` for text or boolean filters in v1. +- Start `in` and `notIn` with comma-separated input. +- Use operator symbols only in the UI. +- Let developers control which filter operators are offered later through metadata/configuration. + +## Plan + +### 1. Create a centralized operator definition + +- Add a canonical registry for: + - `contains`, `equals`, `notEquals`, `gt`, `gte`, `lt`, `lte`, `startsWith`, `endsWith`, `in`, `notIn`, `regex`, `between` +- Store at minimum: + - identifier + - Unicode symbol +- Reuse this registry for: + - `pandas` filtering logic + - filter UI rendering + - tests + +### 2. Update operator availability by pandas column type + +- Text: + - `contains`, `equals`, `notEquals`, `startsWith`, `endsWith`, `in`, `notIn`, `regex` +- Numeric: + - `equals`, `notEquals`, `gt`, `gte`, `lt`, `lte`, `in`, `notIn`, `between` +- Boolean: + - `equals`, `notEquals` +- Datetime: + - `equals`, `notEquals`, `gt`, `gte`, `lt`, `lte`, `between` +- Select/choices: + - `equals`, `notEquals`, `in`, `notIn` + +### 3. Extend the pandas filtering engine + +- Implement missing text operators: + - `notEquals`, `startsWith`, `endsWith`, `notIn`, `regex` +- Implement missing scalar/datetime operators: + - `notEquals`, `gte`, `lte`, `notIn` +- Keep `between` for numeric and datetime values. +- Support: + - `in` / `notIn` via comma-separated parsing + - `between` via two-value input + +### 4. Enrich filter metadata for UI rendering + +- Extend filter `FieldSpec.source_meta` with: + - available operators + - filter kind + - value mode: + - `single` + - `list` + - `range` + +### 5. Evolve the `filters` table UI + +- Add an operator selector for each filter field. +- Display the operator symbol only. +- Render the correct value input shape: + - single-value operators: one field + - `in` / `notIn`: one comma-separated text field + - `between`: two fields +- Store filter values in normalized form: + - `{"op": ..., "value": ...}` + +### 6. Keep `TableHandle` integration stable + +- Keep `set_filter(field_name, value, op=...)` as the primary API. +- Ensure `normalized_filter_values()` and `apply_filters()` support the new operator/value formats without breaking current usage. + +### 7. Add tests + +- Text operator coverage +- Numeric and datetime operator coverage +- Comma-separated `in` / `notIn` +- Numeric and datetime `between` +- Filter UI operator selection +- `TableHandle` API integration + +### 8. Update docs and examples + +- Document the available operators. +- Add at least one `between` example. +- Update the filtered pandas table example to show operator selection. + +## Recommended Implementation Order + +1. Central operator registry and pandas filtering engine +2. Filter UI updates +3. Tests +4. Documentation and examples diff --git a/src/nicegui_builder/core/datetime_inputs.py b/src/nicegui_builder/core/datetime_inputs.py new file mode 100644 index 0000000..9f577ba --- /dev/null +++ b/src/nicegui_builder/core/datetime_inputs.py @@ -0,0 +1,196 @@ +from datetime import date as date_type, datetime, time as time_type +import locale +from typing import Callable + +from nicegui import ui + + +def _time_to_string(value) -> str: + if not isinstance(value, time_type): + return str(value) + + if value.microsecond: + return value.isoformat(timespec="microseconds") + if value.second: + return value.isoformat(timespec="seconds") + return value.isoformat(timespec="minutes") + + +def split_datetime_value(value) -> tuple[str | None, str | None]: + if value in (None, ""): + return (None, None) + + if hasattr(value, "to_pydatetime"): + value = value.to_pydatetime() + + if isinstance(value, datetime): + return (value.date().isoformat(), _time_to_string(value.time())) + + if isinstance(value, date_type) and not isinstance(value, datetime): + return (value.isoformat(), None) + + if isinstance(value, str): + text = value.strip() + if not text: + return (None, None) + + normalized = text.replace("Z", "+00:00") + try: + parsed = datetime.fromisoformat(normalized) + except ValueError: + if "T" in text: + date_value, time_value = text.split("T", 1) + return (date_value or None, time_value or None) + if " " in text: + date_value, time_value = text.split(" ", 1) + return (date_value or None, time_value or None) + return (text, None) + + return (parsed.date().isoformat(), _time_to_string(parsed.time())) + + return (str(value), None) + + +def combine_datetime_value(date_value, time_value): + if date_value in (None, "") and time_value in (None, ""): + return None + if date_value in (None, ""): + return time_value + if time_value in (None, ""): + return date_value + return f"{date_value}T{time_value}" + + +def normalize_datetime_input(raw_value): + if raw_value in (None, ""): + return raw_value + if hasattr(raw_value, "to_pydatetime"): + return raw_value.to_pydatetime() + if isinstance(raw_value, datetime): + return raw_value + if isinstance(raw_value, date_type): + return datetime.combine(raw_value, datetime.min.time()) + if isinstance(raw_value, str): + try: + return datetime.fromisoformat(raw_value) + except ValueError: + return raw_value + return raw_value + + +def datetime_to_input_value(value) -> str: + date_value, time_value = split_datetime_value(value) + return combine_datetime_value(date_value, time_value) or "" + + +def format_datetime_for_display(value) -> str: + normalized = normalize_datetime_input(value) + if not isinstance(normalized, datetime): + return str(value) + + try: + current_locale = locale.setlocale(locale.LC_TIME) + locale.setlocale(locale.LC_TIME, "") + try: + formatted = normalized.strftime("%x %X").strip() + finally: + locale.setlocale(locale.LC_TIME, current_locale) + if formatted: + return formatted + except locale.Error: + pass + + return normalized.strftime("%Y-%m-%d %H:%M") + + +def build_split_datetime_node( + *, + field_name: str, + label: str, + raw_value=None, + container_methods: str = "row", + container_params: dict | None = None, + container_props: str = "", + container_classes: str = "w-full items-end gap-2", + date_ref: str | None = None, + time_ref: str | None = None, + date_label: str | None = None, + time_label: str | None = None, + date_props: str = "clearable", + time_props: str = "clearable", + date_classes: str = "col", + time_classes: str = "col", +) -> dict: + date_value = None + time_value = None + if raw_value not in (None, ""): + date_value, time_value = split_datetime_value(raw_value) + + return { + "methods": container_methods, + "params": dict(container_params or {}), + "props": container_props, + "classes": container_classes, + "children": [ + { + "date_input": { + "ref": date_ref or f"field:{field_name}:date", + "params": { + "value": date_value, + "label": date_label or f"{label} date", + }, + "props": date_props, + "classes": date_classes, + } + }, + { + "time_input": { + "ref": time_ref or f"field:{field_name}:time", + "params": { + "value": time_value, + "label": time_label or f"{label} time", + }, + "props": time_props, + "classes": time_classes, + } + }, + ], + } + + +def render_split_datetime_inputs( + *, + value, + on_change: Callable[[object], None], + date_label: str = "Date", + time_label: str = "Time", + row_classes: str = "items-end gap-2", + date_props: str = "clearable", + time_props: str = "clearable", +): + date_value, time_value = split_datetime_value(value) + state = { + "date": date_value, + "time": time_value, + } + + with ui.row().classes(row_classes): + date_control = ui.date_input(value=date_value, label=date_label).props(date_props) + time_control = ui.time_input(value=time_value, label=time_label).props(time_props) + + def _emit(): + combined = combine_datetime_value(state["date"], state["time"]) + on_change(normalize_datetime_input(combined)) + + def _on_date_change(event): + state["date"] = event.value + _emit() + + def _on_time_change(event): + state["time"] = event.value + _emit() + + date_control.on_value_change(_on_date_change) + time_control.on_value_change(_on_time_change) + + return date_control, time_control diff --git a/src/nicegui_builder/core/filter_operators.py b/src/nicegui_builder/core/filter_operators.py new file mode 100644 index 0000000..035dccc --- /dev/null +++ b/src/nicegui_builder/core/filter_operators.py @@ -0,0 +1,34 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class FilterOperator: + name: str + symbol: str + + +FILTER_OPERATORS: dict[str, FilterOperator] = { + "contains": FilterOperator(name="contains", symbol="∋"), + "equals": FilterOperator(name="equals", symbol="="), + "notEquals": FilterOperator(name="notEquals", symbol="≠"), + "gt": FilterOperator(name="gt", symbol=">"), + "gte": FilterOperator(name="gte", symbol="≥"), + "lt": FilterOperator(name="lt", symbol="<"), + "lte": FilterOperator(name="lte", symbol="≤"), + "startsWith": FilterOperator(name="startsWith", symbol="⋖"), + "endsWith": FilterOperator(name="endsWith", symbol="⋗"), + "in": FilterOperator(name="in", symbol="∈"), + "notIn": FilterOperator(name="notIn", symbol="∉"), + "regex": FilterOperator(name="regex", symbol="≈"), + "between": FilterOperator(name="between", symbol="⋯"), +} + + +LEGACY_FILTER_OPERATOR_NAMES = { + "ge": "gte", + "le": "lte", +} + + +def normalize_filter_operator(name: str) -> str: + return LEGACY_FILTER_OPERATOR_NAMES.get(name, name) diff --git a/src/nicegui_builder/core/form.py b/src/nicegui_builder/core/form.py index 7ec4ed9..2cbfa6f 100644 --- a/src/nicegui_builder/core/form.py +++ b/src/nicegui_builder/core/form.py @@ -2,13 +2,14 @@ from collections.abc import Callable from contextlib import nullcontext from decimal import Decimal -from datetime import date as date_type, datetime, time as time_type +from datetime import datetime import enum import json from nicegui import ui from .actions import FORM_ACTION_SPECS, apply_action_intent, get_action_spec +from .datetime_inputs import _time_to_string, combine_datetime_value, split_datetime_value from .models import ActionSpec, FieldSpec, FormSpec from .view import ViewHandle @@ -191,59 +192,6 @@ def resolve_live_strategy( ) -def _time_to_string(value) -> str: - if not isinstance(value, time_type): - return str(value) - - if value.microsecond: - return value.isoformat(timespec="microseconds") - if value.second: - return value.isoformat(timespec="seconds") - return value.isoformat(timespec="minutes") - - -def split_datetime_value(value) -> tuple[str | None, str | None]: - if value in (None, ""): - return (None, None) - - if isinstance(value, datetime): - return (value.date().isoformat(), _time_to_string(value.time())) - - if isinstance(value, date_type) and not isinstance(value, datetime): - return (value.isoformat(), None) - - if isinstance(value, str): - text = value.strip() - if not text: - return (None, None) - - normalized = text.replace("Z", "+00:00") - try: - parsed = datetime.fromisoformat(normalized) - except ValueError: - if "T" in text: - date_value, time_value = text.split("T", 1) - return (date_value or None, time_value or None) - if " " in text: - date_value, time_value = text.split(" ", 1) - return (date_value or None, time_value or None) - return (text, None) - - return (parsed.date().isoformat(), _time_to_string(parsed.time())) - - return (str(value), None) - - -def combine_datetime_value(date_value, time_value): - if date_value in (None, "") and time_value in (None, ""): - return None - if date_value in (None, ""): - return time_value - if time_value in (None, ""): - return date_value - return f"{date_value}T{time_value}" - - @dataclass(slots=True) class LiveBinding: refresh: Callable diff --git a/src/nicegui_builder/core/table.py b/src/nicegui_builder/core/table.py index 3424d9a..4556ca2 100644 --- a/src/nicegui_builder/core/table.py +++ b/src/nicegui_builder/core/table.py @@ -6,6 +6,7 @@ from nicegui import ui from .actions import TABLE_ACTION_SPECS, apply_action_intent, get_action_spec +from .filter_operators import normalize_filter_operator from .models import ActionSpec, TableSpec from .view import ViewHandle @@ -29,6 +30,11 @@ def _filter_store(self) -> dict[str, object]: self.filter_values = component_filters return self.filter_values + def _refresh_filter_ui(self) -> None: + callback = getattr(self.component, "refresh_filters_ui", None) + if callable(callback): + callback() + def normalized_filter_values(self) -> dict[str, object]: normalized: dict[str, object] = {} for field_name, raw_value in self._filter_store().items(): @@ -36,7 +42,9 @@ def normalized_filter_values(self) -> dict[str, object]: continue if isinstance(raw_value, dict): - operator = raw_value.get("op", "equals") + if raw_value.get("enabled") is False: + continue + operator = normalize_filter_operator(raw_value.get("op", "equals")) value = raw_value.get("value") if value in (None, "", []): continue @@ -259,10 +267,14 @@ def filter_rows(self, filter_values: dict[str, object]) -> list[dict]: def set_filter(self, field_name: str, value, *, op: str | None = None): store = self._filter_store() store[field_name] = {"op": op, "value": value} if op else value + self._refresh_filter_ui() return self def clear_filters(self): self._filter_store().clear() + self._refresh_filter_ui() + if self.plugin is not None and hasattr(self.plugin, "filter_rows"): + return self.set_rows(self.filter_rows(self.normalized_filter_values())) return self def apply_filters(self, filter_values: dict[str, object] | None = None): @@ -270,4 +282,5 @@ def apply_filters(self, filter_values: dict[str, object] | None = None): store = self._filter_store() store.clear() store.update(filter_values) + self._refresh_filter_ui() return self.set_rows(self.filter_rows(self.normalized_filter_values())) diff --git a/src/nicegui_builder/examples/10_pandas_table_filters.py b/src/nicegui_builder/examples/10_pandas_table_filters.py index 197907c..646756f 100644 --- a/src/nicegui_builder/examples/10_pandas_table_filters.py +++ b/src/nicegui_builder/examples/10_pandas_table_filters.py @@ -19,7 +19,7 @@ def _registrations_dataframe() -> pd.DataFrame: "participant_name": "Mira the Unmatched", "contest_title": "Midnight Parade of Respectable Nonsense", "status": "Confirmed", - "checked_in_at": datetime(2026, 6, 12, 20, 50).isoformat(timespec="minutes"), + "checked_in_at": datetime(2026, 6, 12, 20, 50), }, { "participant_name": "Bernard Two-Left-Socks", @@ -31,13 +31,13 @@ def _registrations_dataframe() -> pd.DataFrame: "participant_name": "Clara of the Fierce Ankles", "contest_title": "Midnight Parade of Respectable Nonsense", "status": "Confirmed", - "checked_in_at": datetime(2026, 6, 12, 20, 58).isoformat(timespec="minutes"), + "checked_in_at": datetime(2026, 6, 12, 20, 58), }, { "participant_name": "Lucian the Mildly Dramatic", "contest_title": "Interpretive Heel Rotation", "status": "Dramatically late", - "checked_in_at": datetime(2026, 6, 12, 21, 17).isoformat(timespec="minutes"), + "checked_in_at": datetime(2026, 6, 12, 21, 17), }, ] ) @@ -48,6 +48,12 @@ def build_ui(): ui.label( "Small filters appear automatically so the desk can find people before the ceremonial confusion begins." ).classes("text-body2 text-grey-7") + ui.label( + "Build filters by choosing a field, an operator symbol, and one or more values, then add them to the active list." + ).classes("text-body2 text-grey-6") + ui.label( + "Active filters can be toggled with a checkbox or removed, with comma-separated input for in/not in and two values for between." + ).classes("text-body2 text-grey-6") handle = table(_registrations_dataframe(), variant="filters") @@ -61,9 +67,31 @@ def build_ui(): } ), ) + ui.button( + "Checked in after 21:00", + on_click=lambda: handle.apply_filters( + { + "checked_in_at": { + "op": "gte", + "value": "2026-06-12T21:00", + } + } + ), + ) + ui.button( + "Exclude waitlist and late", + on_click=lambda: handle.apply_filters( + { + "status": { + "op": "notIn", + "value": "Waitlist, Dramatically late", + } + } + ), + ) ui.button( "Clear filters", - on_click=lambda: handle.clear_filters().apply_filters({}), + on_click=lambda: handle.clear_filters(), ) ui.button( "Show active filters", diff --git a/src/nicegui_builder/plugins/pandas/plugin.py b/src/nicegui_builder/plugins/pandas/plugin.py index 51963b5..e58f199 100644 --- a/src/nicegui_builder/plugins/pandas/plugin.py +++ b/src/nicegui_builder/plugins/pandas/plugin.py @@ -1,5 +1,14 @@ +from datetime import date, datetime + from nicegui import ui +from nicegui_builder.core.datetime_inputs import ( + datetime_to_input_value, + format_datetime_for_display, + normalize_datetime_input, + render_split_datetime_inputs, +) +from nicegui_builder.core.filter_operators import FILTER_OPERATORS, normalize_filter_operator from nicegui_builder.core.models import CollectionSpec, FieldSpec, TableSpec, WidgetSpec INTERNAL_ROW_ID = "nicegui_builder_row_id" @@ -72,16 +81,19 @@ def _build_column_spec(column_name, series) -> FieldSpec: def _build_filter_spec(column: FieldSpec) -> FieldSpec: filter_kind = "text" - operators = ["contains", "equals", "in"] + operators = ["contains", "equals", "notEquals", "startsWith", "endsWith", "in", "notIn", "regex"] if column.python_type is bool: filter_kind = "boolean" - operators = ["equals", "in"] + operators = ["equals", "notEquals"] elif column.python_type in {int, float}: filter_kind = "number" - operators = ["equals", "gt", "ge", "lt", "le", "between", "in"] + operators = ["equals", "notEquals", "gt", "gte", "lt", "lte", "in", "notIn", "between"] + elif column.python_type == "datetime": + filter_kind = "datetime" + operators = ["equals", "notEquals", "gt", "gte", "lt", "lte", "between"] elif column.choices and column.python_type is not str: filter_kind = "select" - operators = ["equals", "in"] + operators = ["equals", "notEquals", "in", "notIn"] return FieldSpec( name=column.name, @@ -94,6 +106,7 @@ def _build_filter_spec(column: FieldSpec) -> FieldSpec: "filter": True, "filter_kind": filter_kind, "filter_operators": operators, + "filter_default_operator": "contains" if filter_kind == "text" else "equals", }, ) @@ -101,13 +114,13 @@ def _build_filter_spec(column: FieldSpec) -> FieldSpec: def _normalize_filter_clause(value, column: FieldSpec): if isinstance(value, dict): return { - "op": value.get("op", "equals"), + "op": normalize_filter_operator(value.get("op", "equals")), "value": value.get("value"), } - default_op = "equals" - if column.source_meta.get("filter_kind") == "text": - default_op = "contains" + default_op = column.source_meta.get("filter_default_operator") + if default_op is None: + default_op = "contains" if column.source_meta.get("filter_kind") == "text" else "equals" return { "op": default_op, @@ -115,7 +128,52 @@ def _normalize_filter_clause(value, column: FieldSpec): } +def _parse_csv_values(raw_value): + if isinstance(raw_value, str): + return [item.strip() for item in raw_value.split(",") if item.strip()] + if isinstance(raw_value, (list, tuple, set)): + return [item for item in raw_value if item not in (None, "")] + return [raw_value] + + +def _coerce_datetime_value(raw_value): + pd = _import_pandas() + if pd is None: + return raw_value + return pd.to_datetime(raw_value) + + +def _coerce_filter_values(column: FieldSpec, operator: str, raw_value): + operator = normalize_filter_operator(operator) + + if operator == "between": + if not isinstance(raw_value, (list, tuple)) or len(raw_value) != 2: + raise ValueError("between operator expects a two-item list or tuple") + lower, upper = raw_value + if column.python_type == "datetime": + return [_coerce_datetime_value(lower), _coerce_datetime_value(upper)] + if column.python_type in {int, float}: + caster = int if column.python_type is int else float + return [caster(lower), caster(upper)] + return [lower, upper] + + if operator in {"in", "notIn"}: + values = _parse_csv_values(raw_value) + if column.python_type == "datetime": + return [_coerce_datetime_value(value) for value in values] + if column.python_type in {int, float}: + caster = int if column.python_type is int else float + return [caster(value) for value in values] + return values + + if column.python_type == "datetime" and raw_value not in (None, ""): + return _coerce_datetime_value(raw_value) + + return raw_value + + def _apply_text_filter(filtered, column_name: str, operator: str, raw_value): + operator = normalize_filter_operator(operator) series = filtered[column_name].astype(str) if operator == "contains": @@ -124,30 +182,51 @@ def _apply_text_filter(filtered, column_name: str, operator: str, raw_value): if operator == "equals": return filtered[series.str.lower() == str(raw_value).lower()] + if operator == "notEquals": + return filtered[series.str.lower() != str(raw_value).lower()] + + if operator == "startsWith": + return filtered[series.str.lower().str.startswith(str(raw_value).lower(), na=False)] + + if operator == "endsWith": + return filtered[series.str.lower().str.endswith(str(raw_value).lower(), na=False)] + if operator == "in": - values = raw_value if isinstance(raw_value, list) else [raw_value] + values = _parse_csv_values(raw_value) normalized = {str(item).lower() for item in values} return filtered[series.str.lower().isin(normalized)] + if operator == "notIn": + values = _parse_csv_values(raw_value) + normalized = {str(item).lower() for item in values} + return filtered[~series.str.lower().isin(normalized)] + + if operator == "regex": + return filtered[series.str.contains(str(raw_value), case=False, na=False, regex=True)] + raise ValueError(f"unsupported text filter operator: {operator}") def _apply_scalar_filter(filtered, column_name: str, operator: str, raw_value): + operator = normalize_filter_operator(operator) series = filtered[column_name] if operator == "equals": return filtered[series == raw_value] + if operator == "notEquals": + return filtered[series != raw_value] + if operator == "gt": return filtered[series > raw_value] - if operator == "ge": + if operator == "gte": return filtered[series >= raw_value] if operator == "lt": return filtered[series < raw_value] - if operator == "le": + if operator == "lte": return filtered[series <= raw_value] if operator == "between": @@ -157,21 +236,244 @@ def _apply_scalar_filter(filtered, column_name: str, operator: str, raw_value): return filtered[series.between(lower, upper)] if operator == "in": - values = raw_value if isinstance(raw_value, list) else [raw_value] + values = _parse_csv_values(raw_value) + if _is_datetime_dtype(series.dtype): + values = [_coerce_datetime_value(value) for value in values] + elif _is_numeric_dtype(series.dtype): + caster = int if "int" in str(series.dtype) else float + values = [caster(value) for value in values] return filtered[series.isin(values)] + if operator == "notIn": + values = _parse_csv_values(raw_value) + if _is_datetime_dtype(series.dtype): + values = [_coerce_datetime_value(value) for value in values] + elif _is_numeric_dtype(series.dtype): + caster = int if "int" in str(series.dtype) else float + values = [caster(value) for value in values] + return filtered[~series.isin(values)] + raise ValueError(f"unsupported scalar filter operator: {operator}") +def _build_operator_options(field: FieldSpec) -> dict[str, str]: + return { + operator: FILTER_OPERATORS[operator].symbol + for operator in field.source_meta.get("filter_operators", []) + if operator in FILTER_OPERATORS + } + + +def _enabled_filter_values(filter_values: dict[str, object]) -> dict[str, object]: + enabled: dict[str, object] = {} + for field_name, value in filter_values.items(): + if isinstance(value, dict) and value.get("enabled") is False: + continue + enabled[field_name] = value + return enabled + + +def _is_empty_filter_value(operator: str, value) -> bool: + operator = normalize_filter_operator(operator) + if operator == "between": + if not isinstance(value, (list, tuple)) or len(value) != 2: + return True + return any(item in (None, "") for item in value) + return value in (None, "", []) + + +def _format_filter_value(field: FieldSpec, value) -> str: + filter_kind = field.source_meta.get("filter_kind") + if filter_kind == "datetime": + if isinstance(value, (list, tuple)): + return " .. ".join(format_datetime_for_display(item) for item in value) + return format_datetime_for_display(value) + if isinstance(value, (list, tuple)): + return " .. ".join(str(item) for item in value) + return str(value) + + +def _normalize_range_value(value) -> list[object]: + if not isinstance(value, (list, tuple)) or len(value) != 2: + return ["", ""] + return list(value) + + +def _set_range_item(state: dict[str, object], state_key: str, index: int, value) -> None: + values = _normalize_range_value(state.get(state_key)) + values[index] = value + state[state_key] = values + + +def _set_select_options(control, options: dict[str, str]) -> None: + if hasattr(control, "set_options"): + control.set_options(options) + else: + control.options = options + if hasattr(control, "update"): + control.update() + + +def _default_builder_state(spec: CollectionSpec) -> dict[str, object]: + if not spec.filters: + return { + "field_name": "", + "operator": "equals", + "value": "", + } + + initial_field = spec.filters[0] + initial_operator = initial_field.source_meta.get("filter_default_operator", "equals") + initial_value = ["", ""] if initial_operator == "between" else "" + return { + "field_name": initial_field.name, + "operator": initial_operator, + "value": initial_value, + } + + +def _bind_textual_value_control(control, state: dict[str, object], state_key: str) -> None: + current_value = state.get(state_key, "") + if current_value not in (None, "") and hasattr(control, "value"): + control.value = current_value + + def _on_change(event): + state[state_key] = event.value + + control.on_value_change(_on_change) + + +def _render_between_value_controls(filter_kind: str, state: dict[str, object], state_key: str) -> None: + left_value, right_value = _normalize_range_value(state.get(state_key)) + + with ui.row().classes("items-end gap-2"): + if filter_kind == "number": + left_control = ui.number(label="From") + right_control = ui.number(label="To") + if left_value not in (None, "") and hasattr(left_control, "value"): + left_control.value = left_value + if right_value not in (None, "") and hasattr(right_control, "value"): + right_control.value = right_value + + left_control.on_value_change(lambda event: _set_range_item(state, state_key, 0, event.value)) + right_control.on_value_change(lambda event: _set_range_item(state, state_key, 1, event.value)) + return + + with ui.column().classes("gap-2"): + render_split_datetime_inputs( + value=left_value, + on_change=lambda new_value: _set_range_item(state, state_key, 0, new_value), + date_label="From date", + time_label="From time", + ) + with ui.column().classes("gap-2"): + render_split_datetime_inputs( + value=right_value, + on_change=lambda new_value: _set_range_item(state, state_key, 1, new_value), + date_label="To date", + time_label="To time", + ) + + +def _render_single_value_control(field: FieldSpec, operator: str, state: dict[str, object], state_key: str) -> None: + filter_kind = field.source_meta.get("filter_kind", "text") + + if operator in {"in", "notIn"}: + _bind_textual_value_control(ui.input(label="Values").props("clearable"), state, state_key) + return + + if filter_kind == "select" and operator in {"equals", "notEquals"}: + control = ui.select( + options=field.choices, + label="Value", + clearable=True, + ) + _bind_textual_value_control(control, state, state_key) + return + + if filter_kind == "number": + _bind_textual_value_control(ui.number(label="Value"), state, state_key) + return + + if filter_kind == "boolean": + control = ui.select( + options=[True, False], + label="Value", + clearable=True, + ) + _bind_textual_value_control(control, state, state_key) + return + + if filter_kind == "datetime": + render_split_datetime_inputs( + value=state.get(state_key, ""), + on_change=lambda new_value: state.__setitem__(state_key, new_value), + date_label="Date", + time_label="Time", + ) + return + + _bind_textual_value_control(ui.input(label="Value").props("clearable"), state, state_key) + + +def _render_filter_value_controls(field: FieldSpec, operator: str, state: dict[str, object], state_key: str) -> None: + if operator == "between": + _render_between_value_controls(field.source_meta.get("filter_kind", "text"), state, state_key) + return + + _render_single_value_control(field, operator, state, state_key) + + def _rows_from_dataframe(source) -> list[dict]: rows = [] for row_id, record in enumerate(source.to_dict(orient="records")): - row = dict(record) + row = {} + for key, value in dict(record).items(): + if isinstance(value, (datetime, date)): + row[key] = value.isoformat(timespec="minutes") if isinstance(value, datetime) else value.isoformat() + else: + row[key] = value row[INTERNAL_ROW_ID] = row_id rows.append(row) return rows +def _render_active_filters_list( + active_filters_host, + filter_values: dict[str, object], + filter_fields: dict[str, FieldSpec], + on_toggle, + on_remove, +) -> None: + active_filters_host.clear() + with active_filters_host: + ui.label("Active filters").classes("text-subtitle2") + if not filter_values: + ui.label("No active filters").classes("text-body2 text-grey-6") + return + + for field_name, clause in filter_values.items(): + if not isinstance(clause, dict): + continue + field = filter_fields.get(field_name) + if field is None: + continue + operator = normalize_filter_operator(clause.get("op", "equals")) + symbol = FILTER_OPERATORS.get(operator, FILTER_OPERATORS["equals"]).symbol + value = clause.get("value") + enabled = clause.get("enabled", True) + + with ui.row().classes("w-full items-center gap-2"): + ui.checkbox( + value=enabled, + on_change=lambda event, name=field_name: on_toggle(name, event.value), + ) + ui.button(icon="delete", on_click=lambda *_args, name=field_name, **_kwargs: on_remove(name)).props("flat round dense") + ui.label( + f"{field.title or field.name} {symbol} {_format_filter_value(field, value)}" + ).classes("text-body2") + + class PandasPlugin: name = "pandas" @@ -254,22 +556,25 @@ def filter_rows(self, source, filter_values: dict[str, object]) -> list[dict]: value = filter_values.get(column.name) if value in (None, "", []): continue + if isinstance(value, dict) and value.get("enabled") is False: + continue clause = _normalize_filter_clause(value, column) operator = clause["op"] raw_value = clause["value"] - if raw_value in (None, "", []): + if _is_empty_filter_value(operator, raw_value): continue + coerced_value = _coerce_filter_values(column, operator, raw_value) if column.source_meta.get("filter_kind") in {"select", "boolean"}: - filtered = _apply_scalar_filter(filtered, column.name, operator, raw_value) + filtered = _apply_scalar_filter(filtered, column.name, operator, coerced_value) continue - if column.python_type in {int, float}: - filtered = _apply_scalar_filter(filtered, column.name, operator, raw_value) + if column.python_type in {int, float} or column.python_type == "datetime": + filtered = _apply_scalar_filter(filtered, column.name, operator, coerced_value) continue - filtered = _apply_text_filter(filtered, column.name, operator, raw_value) + filtered = _apply_text_filter(filtered, column.name, operator, coerced_value) return _rows_from_dataframe(filtered) @@ -288,40 +593,131 @@ def render_collection( ) rows = self.prepare_rows(source) filter_values: dict[str, object] = {} + filter_fields = {field.name: field for field in spec.filters} + field_options = { + field.name: field.title or field.name + for field in spec.filters + } table_component = None def apply_filters(): - table_component.rows = self.filter_rows(source, filter_values) - table_component.update() + try: + table_component.rows = self.filter_rows(source, _enabled_filter_values(filter_values)) + table_component.update() + except ValueError: + return + + builder_state = _default_builder_state(spec) with ui.card().classes("w-full gap-4"): - with ui.row().classes("w-full items-end gap-3"): - for field in spec.filters: - label = field.title or field.name - filter_kind = field.source_meta.get("filter_kind", "text") - - if filter_kind == "select": - control = ui.select( - options=field.choices, - label=label, - clearable=True, - ) - elif filter_kind == "number": - control = ui.number(label=label) - elif filter_kind == "boolean": - control = ui.select( - options=[True, False], - label=label, - clearable=True, - ) - else: - control = ui.input(label=label).props("clearable") - - def _on_change(event, field_name=field.name): - filter_values[field_name] = event.value + if spec.filters: + with ui.row().classes("w-full items-end gap-3"): + syncing_controls = { + "field": False, + "operator": False, + } + initial_field = filter_fields[str(builder_state["field_name"])] + field_control = ui.select( + options=field_options, + value=builder_state["field_name"], + clearable=False, + label="Field", + ) + operator_control = ui.select( + options=_build_operator_options(initial_field), + value=builder_state["operator"], + clearable=False, + ) + value_host = ui.column().classes("gap-2") + + def _current_field() -> FieldSpec: + return filter_fields[str(builder_state["field_name"])] + + def _render_builder_value_controls(): + value_host.clear() + with value_host: + _render_filter_value_controls( + _current_field(), + str(builder_state["operator"]), + builder_state, + "value", + ) + + def _reset_builder_value(): + builder_state["value"] = ["", ""] if builder_state["operator"] == "between" else "" + + def _set_operator(operator_name: str): + builder_state["operator"] = normalize_filter_operator(operator_name) + _reset_builder_value() + syncing_controls["operator"] = True + try: + operator_control.value = builder_state["operator"] + finally: + syncing_controls["operator"] = False + _render_builder_value_controls() + if hasattr(operator_control, "update"): + operator_control.update() + + def _on_field_change(event): + if syncing_controls["field"]: + return + field_name = str(event.value) + builder_state["field_name"] = field_name + field = filter_fields[field_name] + _set_select_options(operator_control, _build_operator_options(field)) + _set_operator(field.source_meta.get("filter_default_operator", "equals")) + + def _on_operator_change(event): + if syncing_controls["operator"]: + return + _set_operator(str(event.value)) + + def _add_filter(): + field_name = str(builder_state["field_name"]) + operator = str(builder_state["operator"]) + value = builder_state["value"] + if _is_empty_filter_value(operator, value): + return + filter_values[field_name] = { + "op": operator, + "value": value, + "enabled": True, + } + render_active_filters() apply_filters() - control.on_value_change(_on_change) + field_control.on_value_change(_on_field_change) + operator_control.on_value_change(_on_operator_change) + _render_builder_value_controls() + ui.button("Add filter", on_click=lambda *_args, **_kwargs: _add_filter()) + + active_filters_host = ui.column().classes("w-full gap-2") + + def _toggle_filter(field_name: str, enabled: bool | None = None): + current = filter_values.get(field_name) + if not isinstance(current, dict): + return + if enabled is None: + enabled = not current.get("enabled", True) + current["enabled"] = enabled + render_active_filters() + apply_filters() + + def _remove_filter(field_name: str): + filter_values.pop(field_name, None) + render_active_filters() + apply_filters() + + def render_active_filters(): + _render_active_filters_list( + active_filters_host, + filter_values, + filter_fields, + _toggle_filter, + _remove_filter, + ) + + render_active_filters() with ui.element("div").classes("w-full"): table_component = ui.table( @@ -345,5 +741,9 @@ def _on_change(event, field_name=field.name): table_component.filter_values = filter_values except Exception: pass + try: + table_component.refresh_filters_ui = render_active_filters if spec.filters else (lambda: None) + except Exception: + pass return table_component diff --git a/src/nicegui_builder/plugins/pydantic/resolve.py b/src/nicegui_builder/plugins/pydantic/resolve.py index 82298f8..4a41dad 100644 --- a/src/nicegui_builder/plugins/pydantic/resolve.py +++ b/src/nicegui_builder/plugins/pydantic/resolve.py @@ -1,4 +1,5 @@ from .inspect import build_field_context +from nicegui_builder.core.datetime_inputs import build_split_datetime_node from .mapping import ( build_validation_props, get_defaults_from_map, @@ -11,14 +12,6 @@ def _build_datetime_split_node(field_ctx: dict, default_info: dict, value: dict value = value or {} label = field_ctx.get("attributes_title") or field_ctx["fieldname"] raw_value = field_ctx.get("fieldvalue") - date_value = None - time_value = None - - if raw_value not in (None, ""): - from nicegui_builder.core.form import split_datetime_value - - date_value, time_value = split_datetime_value(raw_value) - container_methods = value.get("container", default_info.get("methods")) container_params = dict(default_info.get("params", {})) container_params.update(value.get("params") or {}) @@ -39,36 +32,15 @@ def _build_datetime_split_node(field_ctx: dict, default_info: dict, value: dict if part ) - return { - "methods": container_methods, - "params": container_params, - "props": container_props, - "classes": container_classes, - "children": [ - { - "date_input": { - "ref": f"field:{field_ctx['fieldname']}:date", - "params": { - "value": date_value, - "label": f"{label} date", - }, - "props": "clearable", - "classes": "col", - } - }, - { - "time_input": { - "ref": f"field:{field_ctx['fieldname']}:time", - "params": { - "value": time_value, - "label": f"{label} time", - }, - "props": "clearable", - "classes": "col", - } - }, - ], - } + return build_split_datetime_node( + field_name=field_ctx["fieldname"], + label=label, + raw_value=raw_value, + container_methods=container_methods, + container_params=container_params, + container_props=container_props, + container_classes=container_classes, + ) def resolve_field_node(model_class, model_instance, fieldname: str, value: dict | None) -> dict: diff --git a/tests/test_nicegui_builder/core/test_table.py b/tests/test_nicegui_builder/core/test_table.py index b988209..700971f 100644 --- a/tests/test_nicegui_builder/core/test_table.py +++ b/tests/test_nicegui_builder/core/test_table.py @@ -147,7 +147,7 @@ def update(self): assert handle.filter_values["name"]["op"] == "contains" assert [row["name"] for row in handle.get_rows()] == ["Ada", "Grace"] - handle.clear_filters().apply_filters() + handle.clear_filters() assert handle.filter_values == {} assert len(handle.get_rows()) == 3 @@ -181,6 +181,52 @@ def update(self): assert handle.normalized_filter_values() == {"name": {"op": "contains", "value": "ad"}} +def test_table_handle_refreshes_filter_ui_when_filters_change(): + refreshed = [] + component = type( + "Component", + (), + { + "filter_values": {}, + "refresh_filters_ui": lambda self=None: refreshed.append("refresh"), + "rows": [], + "update": lambda self=None: None, + }, + )() + handle = _make_table_handle(component=component, plugin=type("Plugin", (), {"filter_rows": lambda self, source, values: []})()) + + handle.set_filter("name", "Ada", op="equals") + handle.apply_filters({"score": {"op": "between", "value": [10, 20]}}) + handle.clear_filters() + + assert refreshed == ["refresh", "refresh", "refresh"] + + +def test_table_handle_clear_filters_reapplies_unfiltered_rows(): + class Component: + def __init__(self): + self.filter_values = {"name": {"op": "equals", "value": "Ada"}} + self.rows = [{"name": "Ada"}] + self.updated = False + + def update(self): + self.updated = True + + component = Component() + plugin = type( + "Plugin", + (), + {"filter_rows": lambda self, source, values: [{"name": "Ada"}, {"name": "Grace"}]}, + )() + handle = _make_table_handle(component=component, plugin=plugin) + + result = handle.clear_filters() + + assert result is handle + assert component.filter_values == {} + assert handle.get_rows() == [{"name": "Ada"}, {"name": "Grace"}] + + def test_table_handle_supports_sort_pagination_selection_and_csv_export(monkeypatch): df = pandas.DataFrame( [ @@ -351,6 +397,8 @@ def test_table_handle_normalized_filter_values_skips_empty_entries(): "score": {"op": "between", "value": [10, 20]}, "ignored_dict": {"op": "contains", "value": ""}, "defaulted_dict": {"value": "Grace"}, + "legacy_dict": {"op": "ge", "value": 10}, + "disabled_dict": {"op": "equals", "value": "Ada", "enabled": False}, } ) @@ -358,6 +406,7 @@ def test_table_handle_normalized_filter_values_skips_empty_entries(): "name": "Ada", "score": {"op": "between", "value": [10, 20]}, "defaulted_dict": {"op": "equals", "value": "Grace"}, + "legacy_dict": {"op": "gte", "value": 10}, } diff --git a/tests/test_nicegui_builder/plugins/pandas/test_plugin.py b/tests/test_nicegui_builder/plugins/pandas/test_plugin.py index 16c9939..a229b21 100644 --- a/tests/test_nicegui_builder/plugins/pandas/test_plugin.py +++ b/tests/test_nicegui_builder/plugins/pandas/test_plugin.py @@ -1,4 +1,5 @@ import builtins +from datetime import datetime from nicegui_builder.core.models import CollectionSpec, FieldSpec, TableSpec, WidgetSpec from nicegui_builder.plugins.pandas import pandas_plugin @@ -36,23 +37,29 @@ def fake_import(name, *args, **kwargs): def test_build_column_and_filter_specs_cover_more_types(): bool_series = pandas.Series([True, False], name="active") float_series = pandas.Series([1.5, 2.5], name="ratio") + datetime_series = pandas.to_datetime(["2026-03-21", "2026-03-22"]) text_series = pandas.Series([f"name-{i}" for i in range(13)], name="name") choice_column = FieldSpec(name="status", python_type=int, choices=[1, 2], title="Status") bool_column = pandas_module._build_column_spec("active", bool_series) float_column = pandas_module._build_column_spec("ratio", float_series) + datetime_column = pandas_module._build_column_spec("starts_at", datetime_series) text_column = pandas_module._build_column_spec("name", text_series) numeric_filter = pandas_module._build_filter_spec(choice_column) select_column = FieldSpec(name="state", python_type=object, choices=["queued", "ready"], title="State") select_filter = pandas_module._build_filter_spec(select_column) + datetime_filter = pandas_module._build_filter_spec(datetime_column) assert bool_column.python_type is bool assert float_column.python_type is float assert float_column.constraints == {"min": 1.5, "max": 2.5} + assert datetime_column.python_type == "datetime" assert text_column.choices == [] assert numeric_filter.source_meta["filter_kind"] == "number" assert select_filter.source_meta["filter_kind"] == "select" - assert select_filter.source_meta["filter_operators"] == ["equals", "in"] + assert select_filter.source_meta["filter_operators"] == ["equals", "notEquals", "in", "notIn"] + assert datetime_filter.source_meta["filter_kind"] == "datetime" + assert datetime_filter.source_meta["filter_operators"] == ["equals", "notEquals", "gt", "gte", "lt", "lte", "between"] def test_normalize_and_apply_text_filters_cover_all_operators(): @@ -70,6 +77,10 @@ def test_normalize_and_apply_text_filters_cover_all_operators(): "op": "equals", "value": "Ada", } + assert pandas_module._normalize_filter_clause({"op": "ge", "value": "Ada"}, text_column) == { + "op": "gte", + "value": "Ada", + } assert [row["name"] for row in pandas_module._apply_text_filter(df, "name", "contains", "a").to_dict("records")] == [ "Ada", @@ -79,10 +90,28 @@ def test_normalize_and_apply_text_filters_cover_all_operators(): assert [row["name"] for row in pandas_module._apply_text_filter(df, "name", "equals", "ada").to_dict("records")] == [ "Ada" ] + assert [row["name"] for row in pandas_module._apply_text_filter(df, "name", "notEquals", "ada").to_dict("records")] == [ + "Grace", + "Alan", + ] + assert [row["name"] for row in pandas_module._apply_text_filter(df, "name", "startsWith", "a").to_dict("records")] == [ + "Ada", + "Alan", + ] + assert [row["name"] for row in pandas_module._apply_text_filter(df, "name", "endsWith", "e").to_dict("records")] == [ + "Grace", + ] assert [row["name"] for row in pandas_module._apply_text_filter(df, "name", "in", ["Ada", "Alan"]).to_dict("records")] == [ "Ada", "Alan", ] + assert [row["name"] for row in pandas_module._apply_text_filter(df, "name", "notIn", "Ada, Alan").to_dict("records")] == [ + "Grace" + ] + assert [row["name"] for row in pandas_module._apply_text_filter(df, "name", "regex", "^a").to_dict("records")] == [ + "Ada", + "Alan", + ] try: pandas_module._apply_text_filter(df, "name", "mystery", "Ada") @@ -102,15 +131,17 @@ def test_apply_scalar_filter_covers_all_operators_and_errors(): ) assert [row["score"] for row in pandas_module._apply_scalar_filter(df, "score", "equals", 20).to_dict("records")] == [20] + assert [row["score"] for row in pandas_module._apply_scalar_filter(df, "score", "notEquals", 20).to_dict("records")] == [10, 30] assert [row["score"] for row in pandas_module._apply_scalar_filter(df, "score", "gt", 10).to_dict("records")] == [20, 30] - assert [row["score"] for row in pandas_module._apply_scalar_filter(df, "score", "ge", 20).to_dict("records")] == [20, 30] + assert [row["score"] for row in pandas_module._apply_scalar_filter(df, "score", "gte", 20).to_dict("records")] == [20, 30] assert [row["score"] for row in pandas_module._apply_scalar_filter(df, "score", "lt", 30).to_dict("records")] == [10, 20] - assert [row["score"] for row in pandas_module._apply_scalar_filter(df, "score", "le", 20).to_dict("records")] == [10, 20] + assert [row["score"] for row in pandas_module._apply_scalar_filter(df, "score", "lte", 20).to_dict("records")] == [10, 20] assert [row["score"] for row in pandas_module._apply_scalar_filter(df, "score", "between", [15, 30]).to_dict("records")] == [ 20, 30, ] assert [row["score"] for row in pandas_module._apply_scalar_filter(df, "score", "in", [10, 30]).to_dict("records")] == [10, 30] + assert [row["score"] for row in pandas_module._apply_scalar_filter(df, "score", "notIn", "10, 30").to_dict("records")] == [20] try: pandas_module._apply_scalar_filter(df, "score", "between", [10]) @@ -136,6 +167,24 @@ def test_rows_from_dataframe_adds_internal_row_ids(): assert rows[1]["nicegui_builder_row_id"] == 1 +def test_rows_from_dataframe_serializes_datetime_values_for_ui(): + df = pandas.DataFrame([{"starts_at": pandas.Timestamp("2026-03-21 10:15:00")}]) + + rows = pandas_module._rows_from_dataframe(df) + + assert rows[0]["starts_at"] == "2026-03-21T10:15" + + +def test_datetime_helpers_keep_internal_datetime_and_ui_friendly_values(): + field = FieldSpec(name="starts_at", python_type="datetime", source_meta={"filter_kind": "datetime"}) + + normalized = pandas_module.normalize_datetime_input("2026-03-21T10:15") + + assert isinstance(normalized, datetime) + assert pandas_module.datetime_to_input_value(normalized) == "2026-03-21T10:15" + assert "T" not in pandas_module._format_filter_value(field, normalized) + + def test_pandas_plugin_supports_dataframe(): df = pandas.DataFrame([{"name": "Ada", "score": 10, "active": True}]) @@ -205,22 +254,30 @@ def test_pandas_plugin_filters_rows(): def test_pandas_plugin_supports_richer_filter_operators(): df = pandas.DataFrame( [ - {"name": "Ada", "score": 10, "active": True}, - {"name": "Grace", "score": 20, "active": False}, - {"name": "Alan", "score": 30, "active": True}, + {"name": "Ada", "score": 10, "active": True, "starts_at": pandas.Timestamp("2026-03-21 10:00:00")}, + {"name": "Grace", "score": 20, "active": False, "starts_at": pandas.Timestamp("2026-03-22 10:00:00")}, + {"name": "Alan", "score": 30, "active": True, "starts_at": pandas.Timestamp("2026-03-23 10:00:00")}, ] ) contains_rows = pandas_plugin.filter_rows(df, {"name": {"op": "contains", "value": "a"}}) between_rows = pandas_plugin.filter_rows(df, {"score": {"op": "between", "value": [15, 30]}}) in_rows = pandas_plugin.filter_rows(df, {"name": {"op": "in", "value": ["Ada", "Alan"]}}) + not_in_rows = pandas_plugin.filter_rows(df, {"name": {"op": "notIn", "value": "Ada, Alan"}}) + starts_with_rows = pandas_plugin.filter_rows(df, {"name": {"op": "startsWith", "value": "a"}}) + not_equals_rows = pandas_plugin.filter_rows(df, {"score": {"op": "notEquals", "value": 20}}) bool_rows = pandas_plugin.filter_rows(df, {"active": {"op": "equals", "value": True}}) + datetime_rows = pandas_plugin.filter_rows(df, {"starts_at": {"op": "between", "value": ["2026-03-22T00:00", "2026-03-23T23:59"]}}) skipped_rows = pandas_plugin.filter_rows(df, {"name": {"op": "contains", "value": ""}}) assert [row["name"] for row in contains_rows] == ["Ada", "Grace", "Alan"] assert [row["name"] for row in between_rows] == ["Grace", "Alan"] assert [row["name"] for row in in_rows] == ["Ada", "Alan"] + assert [row["name"] for row in not_in_rows] == ["Grace"] + assert [row["name"] for row in starts_with_rows] == ["Ada", "Alan"] + assert [row["name"] for row in not_equals_rows] == ["Ada", "Alan"] assert [row["name"] for row in bool_rows] == ["Ada", "Alan"] + assert [row["name"] for row in datetime_rows] == ["Grace", "Alan"] assert len(skipped_rows) == 3 @@ -251,6 +308,8 @@ def test_pandas_plugin_render_collection_builds_filter_ui_and_binds_state(monkey bucket = [] controls = [] + buttons = [] + checkboxes = [] class FakeContext: def __init__(self, kind): @@ -268,12 +327,19 @@ def __exit__(self, exc_type, exc, tb): bucket.append(("exit", self.kind)) return False + def clear(self): + bucket.append(("clear", self.kind)) + return None + class FakeControl: def __init__(self, kind, **kwargs): self.kind = kind self.kwargs = kwargs self.callbacks = [] self.props_value = None + self.options = kwargs.get("options") + self.value = kwargs.get("value") + self.updated = 0 controls.append(self) def props(self, value): @@ -284,6 +350,44 @@ def on_value_change(self, callback): self.callbacks.append(callback) return callback + def classes(self, value): + return self + + def update(self): + self.updated += 1 + return None + + def set_options(self, options): + self.options = options + self.kwargs["options"] = options + + class FakeButton: + def __init__(self, label=None, on_click=None, **kwargs): + self.kind = "button" + self.label = label + self.on_click = on_click + self.kwargs = kwargs + self.props_value = None + buttons.append(self) + + def classes(self, value): + return self + + def props(self, value): + self.props_value = value + return self + + class FakeCheckbox: + def __init__(self, value=False, on_change=None, **kwargs): + self.kind = "checkbox" + self.value = value + self.on_change = on_change + self.kwargs = kwargs + checkboxes.append(self) + + def classes(self, value): + return self + class FakeTable: def __init__(self, **kwargs): self.kwargs = kwargs @@ -305,10 +409,16 @@ def update(self): monkeypatch.setattr(pandas_module.ui, "card", lambda: FakeContext("card")) monkeypatch.setattr(pandas_module.ui, "row", lambda: FakeContext("row")) + monkeypatch.setattr(pandas_module.ui, "column", lambda: FakeContext("column")) monkeypatch.setattr(pandas_module.ui, "element", lambda tag: FakeContext(f"element:{tag}")) monkeypatch.setattr(pandas_module.ui, "input", lambda **kwargs: FakeControl("input", **kwargs)) monkeypatch.setattr(pandas_module.ui, "number", lambda **kwargs: FakeControl("number", **kwargs)) monkeypatch.setattr(pandas_module.ui, "select", lambda **kwargs: FakeControl("select", **kwargs)) + monkeypatch.setattr(pandas_module.ui, "date_input", lambda **kwargs: FakeControl("date_input", **kwargs)) + monkeypatch.setattr(pandas_module.ui, "time_input", lambda **kwargs: FakeControl("time_input", **kwargs)) + monkeypatch.setattr(pandas_module.ui, "label", lambda text: FakeControl("label", text=text)) + monkeypatch.setattr(pandas_module.ui, "button", lambda label=None, on_click=None, **kwargs: FakeButton(label, on_click=on_click, **kwargs)) + monkeypatch.setattr(pandas_module.ui, "checkbox", lambda value=False, on_change=None, **kwargs: FakeCheckbox(value=value, on_change=on_change, **kwargs)) monkeypatch.setattr(pandas_module.ui, "table", lambda **kwargs: FakeTable(**kwargs)) rendered = pandas_plugin.render_collection(df, spec, variant="filters", table_spec=table_spec) @@ -319,15 +429,43 @@ def update(self): assert rendered.classes_value == "w-full" assert rendered.props_value == "flat bordered wrap-cells" assert rendered.kwargs["row_key"] == "nicegui_builder_row_id" - assert len(controls) == len(spec.filters) - - text_control = next(control for control in controls if control.kind == "input") - text_control.callbacks[0](type("Event", (), {"value": "ada"})()) + field_control = next(control for control in controls if control.kind == "select" and control.kwargs.get("label") == "Field") + operator_control = next( + control + for control in controls + if control.kind == "select" and control.kwargs.get("options") == {"contains": "∋", "equals": "=", "notEquals": "≠", "startsWith": "⋖", "endsWith": "⋗", "in": "∈", "notIn": "∉", "regex": "≈"} + ) + add_button = next(button for button in buttons if button.label == "Add filter") + + field_control.callbacks[0](type("Event", (), {"value": "active"})()) + assert operator_control.options == {"equals": "=", "notEquals": "≠"} + assert operator_control.updated >= 1 + + field_control.callbacks[0](type("Event", (), {"value": "name"})()) + operator_control.callbacks[0](type("Event", (), {"value": "equals"})()) + text_control = next( + control + for control in reversed(controls) + if control.kind == "input" and control.kwargs.get("label") == "Value" and control.callbacks + ) + text_control.callbacks[0](type("Event", (), {"value": "Ada"})()) + add_button.on_click() assert rendered.updated is True - assert rendered.filter_values["name"] == "ada" + assert rendered.filter_values["name"] == {"op": "equals", "value": "Ada", "enabled": True} assert [row["name"] for row in rendered.rows] == ["Ada"] + filter_checkbox = checkboxes[-1] + filter_checkbox.on_change(type("Event", (), {"value": False})()) + + assert rendered.filter_values["name"]["enabled"] is False + assert [row["name"] for row in rendered.rows] == ["Ada", "Grace"] + + remove_button = next(button for button in reversed(buttons) if button.kwargs.get("icon") == "delete") + remove_button.on_click() + + assert rendered.filter_values == {} + def test_pandas_plugin_render_collection_uses_select_controls_and_tolerates_attachment_failures(monkeypatch): df = pandas.DataFrame( @@ -348,13 +486,21 @@ def test_pandas_plugin_render_collection_uses_select_controls_and_tolerates_atta python_type=object, title="State", choices=["queued", "ready"], - source_meta={"filter_kind": "select"}, + source_meta={ + "filter_kind": "select", + "filter_operators": ["equals", "notEquals", "in", "notIn"], + "filter_default_operator": "equals", + }, ), FieldSpec( name="active", python_type=bool, title="Active", - source_meta={"filter_kind": "boolean"}, + source_meta={ + "filter_kind": "boolean", + "filter_operators": ["equals", "notEquals"], + "filter_default_operator": "equals", + }, ), ], ) @@ -395,11 +541,16 @@ def __enter__(self): def __exit__(self, exc_type, exc, tb): return False + def clear(self): + return None + class FakeControl: def __init__(self, kind, **kwargs): self.kind = kind self.kwargs = kwargs self.callbacks = [] + self.options = kwargs.get("options") + self.value = kwargs.get("value") controls.append(self) def props(self, value): @@ -409,6 +560,32 @@ def on_value_change(self, callback): self.callbacks.append(callback) return callback + def classes(self, value): + return self + + def update(self): + return None + + class FakeButton: + def __init__(self, label=None, on_click=None, **kwargs): + self.label = label + self.on_click = on_click + self.kwargs = kwargs + + def props(self, value): + return self + + def classes(self, value): + return self + + class FakeCheckbox: + def __init__(self, value=False, on_change=None, **kwargs): + self.value = value + self.on_change = on_change + + def classes(self, value): + return self + class FragileTable: def __init__(self, **kwargs): object.__setattr__(self, "kwargs", kwargs) @@ -431,15 +608,161 @@ def update(self): monkeypatch.setattr(pandas_module.ui, "card", lambda: FakeContext("card")) monkeypatch.setattr(pandas_module.ui, "row", lambda: FakeContext("row")) + monkeypatch.setattr(pandas_module.ui, "column", lambda: FakeContext("column")) monkeypatch.setattr(pandas_module.ui, "element", lambda tag: FakeContext(f"element:{tag}")) monkeypatch.setattr(pandas_module.ui, "input", lambda **kwargs: FakeControl("input", **kwargs)) monkeypatch.setattr(pandas_module.ui, "number", lambda **kwargs: FakeControl("number", **kwargs)) monkeypatch.setattr(pandas_module.ui, "select", lambda **kwargs: FakeControl("select", **kwargs)) + monkeypatch.setattr(pandas_module.ui, "date_input", lambda **kwargs: FakeControl("date_input", **kwargs)) + monkeypatch.setattr(pandas_module.ui, "time_input", lambda **kwargs: FakeControl("time_input", **kwargs)) + monkeypatch.setattr(pandas_module.ui, "label", lambda text: FakeControl("label", text=text)) + monkeypatch.setattr(pandas_module.ui, "button", lambda label=None, on_click=None, **kwargs: FakeButton(label, on_click=on_click, **kwargs)) + monkeypatch.setattr(pandas_module.ui, "checkbox", lambda value=False, on_change=None, **kwargs: FakeCheckbox(value=value, on_change=on_change, **kwargs)) monkeypatch.setattr(pandas_module.ui, "table", lambda **kwargs: FragileTable(**kwargs)) rendered = pandas_plugin.render_collection(df, spec, variant="filters", table_spec=table_spec) assert rendered is not None - assert [control.kind for control in controls] == ["select", "select"] - assert controls[0].kwargs == {"options": ["queued", "ready"], "label": "State", "clearable": True} - assert controls[1].kwargs == {"options": [True, False], "label": "Active", "clearable": True} + field_control = next(control for control in controls if control.kind == "select" and control.kwargs.get("label") == "Field") + + field_control.callbacks[0](type("Event", (), {"value": "state"})()) + value_select = next(control for control in reversed(controls) if control.kind == "select" and control.kwargs.get("label") == "Value") + assert value_select.kwargs == {"options": ["queued", "ready"], "label": "Value", "clearable": True} + + field_control.callbacks[0](type("Event", (), {"value": "active"})()) + bool_select = next(control for control in reversed(controls) if control.kind == "select" and control.kwargs.get("label") == "Value") + assert bool_select.kwargs == {"options": [True, False], "label": "Value", "clearable": True} + + +def test_pandas_plugin_render_collection_avoids_recursive_operator_updates(monkeypatch): + df = pandas.DataFrame( + [ + {"participant_name": "Ada", "checked_in_at": pandas.Timestamp("2026-03-21 21:15:00")}, + {"participant_name": "Grace", "checked_in_at": pandas.Timestamp("2026-03-21 20:15:00")}, + ] + ) + spec = pandas_plugin.inspect_collection(df) + widget_spec = pandas_plugin.resolve_collection_widget(spec, variant="filters") + table_spec = TableSpec( + source_class=df.__class__, + collection_spec=spec, + source=df, + widget_spec=widget_spec, + variant="filters", + plugin_name="pandas", + ) + + controls = [] + + class FakeContext: + def __init__(self, kind): + self.kind = kind + + def classes(self, classes): + return self + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def clear(self): + return None + + class ReactiveControl: + def __init__(self, kind, **kwargs): + self.kind = kind + self.kwargs = kwargs + self.callbacks = [] + self.options = kwargs.get("options") + self._value = kwargs.get("value") + controls.append(self) + + @property + def value(self): + return self._value + + @value.setter + def value(self, new_value): + self._value = new_value + event = type("Event", (), {"value": new_value})() + for callback in list(self.callbacks): + callback(event) + + def props(self, value): + return self + + def on_value_change(self, callback): + self.callbacks.append(callback) + return callback + + def classes(self, value): + return self + + def update(self): + return None + + def set_options(self, options): + self.options = options + self.kwargs["options"] = options + + class FakeButton: + def __init__(self, label=None, on_click=None, **kwargs): + self.label = label + self.on_click = on_click + + def props(self, value): + return self + + def classes(self, value): + return self + + class FakeCheckbox: + def __init__(self, value=False, on_change=None, **kwargs): + self.value = value + self.on_change = on_change + + def classes(self, value): + return self + + class FakeTable: + def __init__(self, **kwargs): + self.rows = kwargs["rows"] + + def classes(self, value): + return self + + def props(self, value): + return self + + def update(self): + return None + + monkeypatch.setattr(pandas_module.ui, "card", lambda: FakeContext("card")) + monkeypatch.setattr(pandas_module.ui, "row", lambda: FakeContext("row")) + monkeypatch.setattr(pandas_module.ui, "column", lambda: FakeContext("column")) + monkeypatch.setattr(pandas_module.ui, "element", lambda tag: FakeContext(f"element:{tag}")) + monkeypatch.setattr(pandas_module.ui, "input", lambda **kwargs: ReactiveControl("input", **kwargs)) + monkeypatch.setattr(pandas_module.ui, "number", lambda **kwargs: ReactiveControl("number", **kwargs)) + monkeypatch.setattr(pandas_module.ui, "select", lambda **kwargs: ReactiveControl("select", **kwargs)) + monkeypatch.setattr(pandas_module.ui, "date_input", lambda **kwargs: ReactiveControl("date_input", **kwargs)) + monkeypatch.setattr(pandas_module.ui, "time_input", lambda **kwargs: ReactiveControl("time_input", **kwargs)) + monkeypatch.setattr(pandas_module.ui, "label", lambda text: ReactiveControl("label", text=text)) + monkeypatch.setattr(pandas_module.ui, "button", lambda label=None, on_click=None, **kwargs: FakeButton(label, on_click=on_click, **kwargs)) + monkeypatch.setattr(pandas_module.ui, "checkbox", lambda value=False, on_change=None, **kwargs: FakeCheckbox(value=value, on_change=on_change, **kwargs)) + monkeypatch.setattr(pandas_module.ui, "table", lambda **kwargs: FakeTable(**kwargs)) + + rendered = pandas_plugin.render_collection(df, spec, variant="filters", table_spec=table_spec) + + assert rendered is not None + field_control = next(control for control in controls if control.kind == "select" and control.kwargs.get("label") == "Field") + + field_control.callbacks[0](type("Event", (), {"value": "checked_in_at"})()) + + operator_control = next( + control + for control in controls + if control.kind == "select" and control.kwargs.get("options") == {"equals": "=", "notEquals": "≠", "gt": ">", "gte": "≥", "lt": "<", "lte": "≤", "between": "⋯"} + ) + assert operator_control.value == "equals" From 36574f5f5f6ff8601b111784e7afa90b6ee86ff2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Fournier?= Date: Wed, 25 Mar 2026 21:47:13 -0400 Subject: [PATCH 2/9] docs: add layout schema reference and guide --- README.md | 5 + docs/examples.md | 2 +- docs/layout-schema.md | 268 +++++++++++++++++++++++++++++++++++++ docs/public-api.md | 2 +- schemas/layout.schema.json | 199 +++++++++++++++++++++++++++ 5 files changed, 474 insertions(+), 2 deletions(-) create mode 100644 docs/layout-schema.md create mode 100644 schemas/layout.schema.json diff --git a/README.md b/README.md index 16e0fb7..ce43424 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,11 @@ builder(layout) ui.run() ``` +For the declarative layout language itself, see: + +- [`docs/layout-schema.md`](docs/layout-schema.md) +- [`schemas/layout.schema.json`](schemas/layout.schema.json) + ### `form(source, flavor="")` Use `form(...)` when you want a plugin to inspect a supported source and render a form. diff --git a/docs/examples.md b/docs/examples.md index f7d45ef..2d0e073 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -2,7 +2,7 @@ [Home](../README.md) -[Previous: Public API](public-api.md) | [Next: Product Roadmap](product-roadmap.md) +[Previous: Layout Schema](layout-schema.md) | [Next: Product Roadmap](product-roadmap.md) This document proposes a progressive set of examples to document `nicegui-builder`. diff --git a/docs/layout-schema.md b/docs/layout-schema.md new file mode 100644 index 0000000..3654187 --- /dev/null +++ b/docs/layout-schema.md @@ -0,0 +1,268 @@ +# Layout Schema + +[Home](../README.md) + +[Previous: Public API](public-api.md) | [Next: Examples Roadmap](examples.md) + +This document explains the declarative layout language accepted by `builder(...)`, `form(...)`, and other entry points that eventually delegate to the builder. + +It also documents the JSON Schema shipped with the project: + +- [`schemas/layout.schema.json`](../schemas/layout.schema.json) + +## Goal + +The schema helps you catch structural mistakes early while writing YAML layouts: + +- wrong root shape +- invalid node shape +- misplaced `children` +- unsupported keys like typos in `params` / `classes` / `props` +- plugin expansion nodes missing the expected object shape + +It is intentionally strongest on structure. +It does not try to prove that every method name is a valid NiceGUI API call or that every `field__...` key matches a real model field. + +## The Layout Shape + +At the top level, a layout is a list of entries. +Each entry is an object with exactly one key. + +Example: + +```yaml +- card.tight: + classes: w-full p-4 gap-3 + children: + - label: + params: + text: Hello world +``` + +This corresponds to: + +- one root list +- one node entry: `card.tight` +- one child node entry: `label` + +## Standard Nodes + +A standard node uses its key as the NiceGUI method chain. + +Examples: + +- `label` +- `card` +- `card.tight` +- `grid` + +Supported configuration keys: + +- `params`: keyword arguments passed to the resolved method +- `props`: props string passed to `.props(...)` +- `classes`: classes string passed to `.classes(...)` +- `ref`: optional builder reference name +- `children`: nested layout entries +- `context`: reserved for future layout-time metadata + +Example: + +```yaml +- grid: + params: + columns: 12 + classes: w-full gap-3 + children: + - label: + params: + text: Contact form + classes: text-h6 +``` + +## Expansion Nodes + +An expansion node delegates resolution to a registered callback. +Today the most important built-in family is `field__...`. + +Examples: + +- `field__firstname` +- `field__email` +- `field__starts_at` + +Expansion nodes support the same common keys as standard nodes, plus plugin-specific overrides such as: + +- `methods` +- `container` + +Example: + +```yaml +- field__email: + methods: email + classes: col-span-6 +``` + +Another example with the split `datetime` widget: + +```yaml +- field__starts_at: + container: grid + params: + columns: 2 + classes: col-span-12 gap-2 +``` + +In that case: + +- `field__starts_at` selects the field expansion callback +- `container` overrides the wrapper component used for the split `date_input + time_input` +- `params` and `classes` apply to that wrapper container + +## Null Values + +You can omit the node body entirely by using `null`. + +Example: + +```yaml +- field__owner_name: +``` + +That is equivalent to: + +```yaml +- field__owner_name: {} +``` + +This can be useful when the plugin default is already good enough. + +## `params` Values + +`params` accepts arbitrary JSON-like YAML values: + +- strings +- numbers +- booleans +- `null` +- arrays +- nested objects + +The builder also resolves two special string conventions at runtime: + +- strings starting with `_` + - treated as Python `str.format(**context)` templates +- strings starting with `$` + - treated as `module:function` callbacks that receive the current builder context + +Examples: + +```yaml +- label: + params: + text: _Hello {name} +``` + +```yaml +- label: + params: + text: $my_app.layout_helpers:format_title +``` + +The schema intentionally allows those values as ordinary strings. +Their runtime meaning is documented here because JSON Schema cannot validate the import target itself. + +## What The Schema Validates Well + +The shipped schema is strong at validating: + +- the top-level list shape +- one-entry-per-node objects +- standard vs expansion node shapes +- the presence and type of `params`, `props`, `classes`, `ref`, `children`, and `context` +- plugin override keys currently used in layouts such as `methods` and `container` + +## What The Schema Does Not Fully Validate + +Some things are outside the reach of a practical static schema: + +- whether `card.tight` or another method chain exists in NiceGUI +- whether a specific `field__name` actually exists on your model +- whether a plugin accepts a given `methods` override such as `email` or `textarea` +- whether a `container` override is meaningful for a given field/plugin +- semantic correctness of runtime context expressions in `_...` or `$module:function` strings + +So the schema should be treated as a strong structural guardrail, not as a complete semantic type system. + +## VS Code Setup + +The most practical workflow in VS Code is: + +1. install the `YAML` extension by Red Hat +2. associate your layout files with the shipped schema +3. optionally add a per-file schema directive for standalone layouts + +### Recommended Extension + +- `YAML` by Red Hat + +This extension gives you: + +- YAML validation +- hover help from the schema +- completion from the schema +- error reporting inside the editor + +VS Code's built-in JSON support is usually enough for editing the schema file itself. + +### Workspace Mapping + +In `.vscode/settings.json`, you can associate patterns with the schema: + +```json +{ + "yaml.schemas": { + "./schemas/layout.schema.json": [ + "src/nicegui_builder/examples/**/*.yml", + "src/nicegui_builder/examples/**/*.yaml", + "**/*Builder.yml", + "**/*Builder.yaml", + "**/models/*.yml", + "**/models/*.yaml" + ] + } +} +``` + +Adjust the file globs to match how your project stores layouts. + +### Per-File Directive + +If you want an individual layout file to opt into the schema explicitly, add this as the first line: + +```yaml +# yaml-language-server: $schema=../../schemas/layout.schema.json +``` + +Use a relative path that makes sense from the file location. + +## Practical Writing Advice + +The schema is most useful when you lean into a few habits: + +- keep each node to one key only +- use `children` only for nested nodes +- prefer `params` for widget arguments and `props` / `classes` for view tuning +- use expansion nodes like `field__...` only when a plugin owns that part of the tree +- keep plugin overrides explicit when you need them, for example `methods: email` + +For ordinary layouts, start with standard nodes. +Reach for expansion nodes when a plugin should resolve the final widget for you. + +## Related Files + +- [`README.md`](../README.md) +- [`src/nicegui_builder/builder.py`](../src/nicegui_builder/builder.py) +- [`src/nicegui_builder/core/models.py`](../src/nicegui_builder/core/models.py) +- [`src/nicegui_builder/examples/demo_basic_builder.yml`](../src/nicegui_builder/examples/demo_basic_builder.yml) +- [`src/nicegui_builder/examples/24_field_context_resolution.py`](../src/nicegui_builder/examples/24_field_context_resolution.py) diff --git a/docs/public-api.md b/docs/public-api.md index 0ea4a12..ec1aed3 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -2,7 +2,7 @@ [Home](../README.md) -[Previous: Architecture](architecture.md) | [Next: Examples Roadmap](examples.md) +[Previous: Architecture](architecture.md) | [Next: Layout Schema](layout-schema.md) This document defines the supported public API surface for `nicegui-builder`. diff --git a/schemas/layout.schema.json b/schemas/layout.schema.json new file mode 100644 index 0000000..7aed245 --- /dev/null +++ b/schemas/layout.schema.json @@ -0,0 +1,199 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://nicegui-builder.local/schemas/layout.schema.json", + "title": "nicegui-builder layout schema", + "description": "Structural schema for declarative nicegui-builder layouts expressed as YAML or JSON.", + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/layoutEntry" + }, + "examples": [ + [ + { + "card.tight": { + "classes": "w-full p-4 gap-3", + "children": [ + { + "label": { + "params": { + "text": "Hello world" + } + } + } + ] + } + } + ] + ], + "$defs": { + "jsonValue": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "integer" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/jsonValue" + } + }, + { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/jsonValue" + } + } + ] + }, + "paramsObject": { + "type": "object", + "description": "Keyword arguments passed to the resolved NiceGUI method.", + "default": {}, + "additionalProperties": { + "$ref": "#/$defs/jsonValue" + } + }, + "contextObject": { + "type": "object", + "description": "Reserved for future layout-time context metadata.", + "default": {}, + "additionalProperties": { + "$ref": "#/$defs/jsonValue" + } + }, + "childrenArray": { + "type": "array", + "default": [], + "items": { + "$ref": "#/$defs/layoutEntry" + } + }, + "baseNodeConfig": { + "type": "object", + "properties": { + "params": { + "$ref": "#/$defs/paramsObject" + }, + "props": { + "type": "string", + "description": "Space-separated NiceGUI/Quasar props string." + }, + "classes": { + "type": "string", + "description": "Space-separated CSS utility classes." + }, + "ref": { + "type": "string", + "description": "Optional builder reference name stored in the builder context." + }, + "children": { + "$ref": "#/$defs/childrenArray" + }, + "context": { + "$ref": "#/$defs/contextObject" + } + } + }, + "standardNodeConfig": { + "allOf": [ + { + "$ref": "#/$defs/baseNodeConfig" + }, + { + "type": "object", + "additionalProperties": false + } + ], + "description": "Configuration for a standard builder node whose key already names the NiceGUI method." + }, + "expansionNodeConfig": { + "allOf": [ + { + "$ref": "#/$defs/baseNodeConfig" + }, + { + "type": "object", + "properties": { + "methods": { + "type": "string", + "description": "Plugin-specific method or variant override, for example on field__... nodes." + }, + "container": { + "type": "string", + "description": "Plugin-specific container override, currently used by split datetime field expansion." + } + }, + "additionalProperties": false + } + ], + "description": "Configuration for an expansion node such as field__email." + }, + "standardNodeValue": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/standardNodeConfig" + } + ] + }, + "expansionNodeValue": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/expansionNodeConfig" + } + ] + }, + "standardLayoutEntry": { + "type": "object", + "description": "A single layout entry whose key resolves directly to one or more NiceGUI methods, for example label or card.tight.", + "minProperties": 1, + "maxProperties": 1, + "propertyNames": { + "pattern": "^[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)*$" + }, + "additionalProperties": { + "$ref": "#/$defs/standardNodeValue" + } + }, + "expansionLayoutEntry": { + "type": "object", + "description": "A single layout entry delegated to a registered builder expansion callback, for example field__name.", + "minProperties": 1, + "maxProperties": 1, + "propertyNames": { + "pattern": "^[A-Za-z_][A-Za-z0-9_]*__.+$" + }, + "additionalProperties": { + "$ref": "#/$defs/expansionNodeValue" + } + }, + "layoutEntry": { + "oneOf": [ + { + "$ref": "#/$defs/standardLayoutEntry" + }, + { + "$ref": "#/$defs/expansionLayoutEntry" + } + ] + } + } +} From a59b69bbfe941a13000d8679a68ba3b6a8ad8097 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Fournier?= Date: Wed, 25 Mar 2026 22:19:15 -0400 Subject: [PATCH 3/9] refactor: simplify builder and table internals --- README.md | 10 ++ docs/architecture.md | 9 +- docs/layout-schema.md | 3 + docs/plugin.md | 32 +++++- src/nicegui_builder/builder.py | 98 +++++++++++-------- src/nicegui_builder/core/context.py | 25 +++++ src/nicegui_builder/core/datetime_inputs.py | 34 ++++--- src/nicegui_builder/core/filter_operators.py | 39 ++++++++ src/nicegui_builder/core/table.py | 44 +++++---- src/nicegui_builder/form.py | 22 ++--- src/nicegui_builder/plugins/pandas/plugin.py | 89 ++++++++--------- src/nicegui_builder/plugins/registry.py | 67 ++++++++++++- src/nicegui_builder/table.py | 28 +++--- tests/test_nicegui_builder/core/test_table.py | 33 ++++++- .../plugins/pandas/test_plugin.py | 64 +++++++++++- tests/test_nicegui_builder/test_table.py | 9 +- 16 files changed, 437 insertions(+), 169 deletions(-) diff --git a/README.md b/README.md index ce43424..ea4a893 100644 --- a/README.md +++ b/README.md @@ -298,9 +298,19 @@ handle.set_filter("name", "ada", op="contains") handle.set_filter("score", [10, 20], op="between") handle.set_filter("status", "confirmed, waitlist", op="in") handle.apply_filters() +handle.normalized_filter_values() handle.clear_filters() ``` +`normalized_filter_values()` returns the active filter clauses in normalized form: + +```python +{ + "name": {"op": "contains", "value": "ada"}, + "score": {"op": "between", "value": [10, 20]}, +} +``` + CRUD-style actions: ```python diff --git a/docs/architecture.md b/docs/architecture.md index a8db78a..7aadbab 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -195,9 +195,9 @@ classDiagram class FieldPlugin { <> +inspect_fields(source) + +resolve_field_node(model_class, model_instance, fieldname, value) +build_layout(source, flavor) +build_field_context(model_class, model_instance, fieldname) - +resolve_field_node(model_class, model_instance, fieldname, value) +resolve_widget(spec, variant) +render_form(source, flavor) } @@ -206,6 +206,8 @@ classDiagram <> +inspect_collection(source) +resolve_collection_widget(spec, variant) + +prepare_rows(source) + +filter_rows(source, filter_values) +render_collection(source, spec, variant) } @@ -238,7 +240,7 @@ classDiagram ### Important idea The registry resolves the right plugin for a source. -After that, the rest of the pipeline works through plugin capabilities rather than source-specific conditionals spread across the codebase. +After that, the rest of the pipeline works through an explicit contract for that plugin family rather than repeating capability checks at every call site. ## View 3: Runtime Handles @@ -329,7 +331,7 @@ What matters architecturally is this: - it renders normalized declarative nodes - it resolves context values - it supports plugin-assisted node expansion such as `field__name` -- it registers refs so runtime handles can interact with rendered components +- it keeps a small explicit runtime context for refs and root-component tracking In other words, the builder is the bridge between normalized layout decisions and actual NiceGUI components. @@ -365,6 +367,7 @@ Examples of notable behavior: - sortable table defaults - pagination and selection support - richer filtering workflow with operator-aware filter building and an active filter list +- canonical filter clauses normalized before filtering ## Design Principles diff --git a/docs/layout-schema.md b/docs/layout-schema.md index 3654187..2475715 100644 --- a/docs/layout-schema.md +++ b/docs/layout-schema.md @@ -172,6 +172,9 @@ Examples: The schema intentionally allows those values as ordinary strings. Their runtime meaning is documented here because JSON Schema cannot validate the import target itself. +The runtime context is intentionally small and operational. +In practice, layouts should treat it as helper data for formatting and callbacks, not as a place to depend on large amounts of implicit mutable state. + ## What The Schema Validates Well The shipped schema is strong at validating: diff --git a/docs/plugin.md b/docs/plugin.md index ad39ce8..c1a2741 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -37,15 +37,18 @@ There are currently two practical plugin families. Field plugins power `form(...)`. -They typically implement: +Required entry-point methods: - `supports(source)` - `inspect_fields(source)` +- `resolve_field_node(model_class, model_instance, fieldname, value)` + +Common but optional extension points: + - `build_layout(source, flavor="")` - `build_field_context(model_class, model_instance, fieldname)` -- `resolve_field_node(model_class, model_instance, fieldname, value)` - `resolve_widget(spec, variant="std")` -- optional `render_form(source, flavor="")` +- `render_form(source, flavor="")` See [`src/nicegui_builder/plugins/base.py`](../src/nicegui_builder/plugins/base.py). @@ -53,12 +56,17 @@ See [`src/nicegui_builder/plugins/base.py`](../src/nicegui_builder/plugins/base. Collection plugins power `table(...)`. -They typically implement: +Required entry-point methods: - `supports(source)` - `inspect_collection(source)` - `resolve_collection_widget(spec, variant="std")` -- optional `render_collection(source, spec, variant="std")` + +Common but optional extension points: + +- `prepare_rows(source)` +- `filter_rows(source, filter_values)` +- `render_collection(source, spec, variant="std")` ## Registration @@ -133,6 +141,15 @@ Current example: - the `pandas` plugin uses `render_collection(..., variant="filters")` for a richer filter UI +### 5. Normalize early + +If a plugin accepts multiple convenient input shapes at its public boundary, prefer normalizing them early into one internal form. + +Current examples: + +- table filter clauses are normalized into a canonical internal shape before filtering +- builder nodes are normalized before method-chain rendering + ## Pydantic Plugin Notes The `pydantic` plugin is a good reference for form-oriented plugins. @@ -172,6 +189,11 @@ It shows how to: - provide default table widgets - optionally render a richer filtered table variant with a filter builder and active filter list +It is also a good reference for: + +- canonical filter clause handling +- validating/coercing filter values before applying them to the dataframe + See: - [`src/nicegui_builder/plugins/pandas/plugin.py`](../src/nicegui_builder/plugins/pandas/plugin.py) diff --git a/src/nicegui_builder/builder.py b/src/nicegui_builder/builder.py index 2eb6998..a07bbc8 100644 --- a/src/nicegui_builder/builder.py +++ b/src/nicegui_builder/builder.py @@ -1,6 +1,6 @@ from nicegui import ui from importlib import import_module -from .core.context import builder_ctx +from .core.context import builder_ctx, component_refs, ensure_builder_runtime, get_root_component, set_root_component builder_expansion_registry = {} @@ -19,54 +19,70 @@ def resolve_context_value(value, ctx): return value -def visit(components: list): - for component in components: +def _normalize_layout_entry(component: dict, ctx: dict) -> dict: + key, value = next(iter(component.items())) + if value is None: + value = {} + + if "__" in key: + register_key, builder_key = key.split("__", 1) + resolved = builder_expansion_registry[register_key](builder_key, value) + return { + "methods": resolved.get("methods"), + "params": resolved.get("params") or {}, + "classes": resolved.get("classes", ""), + "props": resolved.get("props", ""), + "ref": resolved.get("ref"), + "children": resolved.get("children", value.get("children", [])), + } + + return { + "methods": key, + "params": value.get("params") or {}, + "classes": value.get("classes", ""), + "props": value.get("props", ""), + "ref": value.get("ref"), + "children": value.get("children", []), + } + + +def _render_method_chain(method_chain: str, params: dict): + ui_component = None + methods = method_chain.split('.') + effective_params = {} + + for i, method in enumerate(methods): + if i == len(methods) - 1: + effective_params = params + ui_component = ( + getattr(ui_component, method)(**effective_params) + if ui_component + else getattr(ui, method)(**effective_params) + ) + + return ui_component + - key, value = next(iter(component.items())) +def visit(components: list): - if value is None: - value = {} + for component in components: ctx = dict(builder_ctx.get()) ctx_token = builder_ctx.set(ctx) - - if "__" in key: - register_key, builder_key = key.split("__", 1) - resolved = builder_expansion_registry[register_key](builder_key, value) - methods = resolved.get("methods") - params = resolved.get("params") or {} - classes = resolved.get("classes", "") - props = resolved.get("props", "") - ref = resolved.get("ref") - children = resolved.get("children", value.get("children", [])) - - else: - methods = key - params = value.get("params") or {} - classes = value.get("classes", "") - props = value.get("props", "") - ref = value.get("ref") - children = value.get("children", []) - - methods = methods.split('.') + normalized = _normalize_layout_entry(component, ctx) + params = dict(normalized["params"]) + classes = normalized["classes"] + props = normalized["props"] + ref = normalized["ref"] + children = normalized["children"] for k, v in params.items(): params[k] = resolve_context_value(v, ctx) classes = resolve_context_value(classes, ctx) props = resolve_context_value(props, ctx) - - ui_component = None - effective_params = {} - for i, method in enumerate(methods): - # params are applied on the last method - if i == len(methods) - 1: - effective_params = params - ui_component = \ - getattr(ui_component, method)(**effective_params) \ - if ui_component else \ - getattr(ui, method)(**effective_params) + ui_component = _render_method_chain(normalized["methods"], params) # apply classes if any if classes: @@ -77,14 +93,14 @@ def visit(components: list): ui_component.props(props) if ref: - ctx.setdefault("_component_refs", {})[ref] = ui_component + component_refs(ctx)[ref] = ui_component # process children if any if children: with ui_component: visit(children) - ctx.setdefault("_builder_state", {}).setdefault("root_component", ui_component) + set_root_component(ctx, ui_component) builder_ctx.reset(ctx_token) @@ -119,9 +135,9 @@ def the_callback(resolver_key: str, node_value: dict) -> method: str, params: di def builder(layout) -> None: ctx = dict(builder_ctx.get()) - ctx.setdefault("_builder_state", {}) + ensure_builder_runtime(ctx) ctx_token = builder_ctx.set(ctx) visit(layout) - root_component = ctx["_builder_state"].get("root_component") + root_component = get_root_component(ctx) builder_ctx.reset(ctx_token) return root_component diff --git a/src/nicegui_builder/core/context.py b/src/nicegui_builder/core/context.py index ec788cf..029c6d3 100644 --- a/src/nicegui_builder/core/context.py +++ b/src/nicegui_builder/core/context.py @@ -2,3 +2,28 @@ builder_ctx: ContextVar[dict] = ContextVar("model_info", default={}) + + +def ensure_builder_runtime(ctx: dict) -> dict: + runtime = ctx.get("_runtime") + if runtime is None: + runtime = { + "component_refs": {}, + "root_component": None, + } + ctx["_runtime"] = runtime + return runtime + + +def component_refs(ctx: dict) -> dict: + return ensure_builder_runtime(ctx)["component_refs"] + + +def set_root_component(ctx: dict, component) -> None: + runtime = ensure_builder_runtime(ctx) + if runtime["root_component"] is None: + runtime["root_component"] = component + + +def get_root_component(ctx: dict): + return ensure_builder_runtime(ctx)["root_component"] diff --git a/src/nicegui_builder/core/datetime_inputs.py b/src/nicegui_builder/core/datetime_inputs.py index 9f577ba..c4f429d 100644 --- a/src/nicegui_builder/core/datetime_inputs.py +++ b/src/nicegui_builder/core/datetime_inputs.py @@ -5,6 +5,20 @@ from nicegui import ui +def _coerce_datetime_like(value): + if hasattr(value, "to_pydatetime"): + return value.to_pydatetime() + return value + + +def _parse_datetime_string(value: str): + normalized = value.replace("Z", "+00:00") + try: + return datetime.fromisoformat(normalized) + except ValueError: + return None + + def _time_to_string(value) -> str: if not isinstance(value, time_type): return str(value) @@ -20,8 +34,7 @@ def split_datetime_value(value) -> tuple[str | None, str | None]: if value in (None, ""): return (None, None) - if hasattr(value, "to_pydatetime"): - value = value.to_pydatetime() + value = _coerce_datetime_like(value) if isinstance(value, datetime): return (value.date().isoformat(), _time_to_string(value.time())) @@ -34,10 +47,8 @@ def split_datetime_value(value) -> tuple[str | None, str | None]: if not text: return (None, None) - normalized = text.replace("Z", "+00:00") - try: - parsed = datetime.fromisoformat(normalized) - except ValueError: + parsed = _parse_datetime_string(text) + if parsed is None: if "T" in text: date_value, time_value = text.split("T", 1) return (date_value or None, time_value or None) @@ -64,17 +75,16 @@ def combine_datetime_value(date_value, time_value): def normalize_datetime_input(raw_value): if raw_value in (None, ""): return raw_value - if hasattr(raw_value, "to_pydatetime"): - return raw_value.to_pydatetime() + raw_value = _coerce_datetime_like(raw_value) if isinstance(raw_value, datetime): return raw_value if isinstance(raw_value, date_type): return datetime.combine(raw_value, datetime.min.time()) if isinstance(raw_value, str): - try: - return datetime.fromisoformat(raw_value) - except ValueError: - return raw_value + parsed = _parse_datetime_string(raw_value) + if parsed is not None: + return parsed + return raw_value return raw_value diff --git a/src/nicegui_builder/core/filter_operators.py b/src/nicegui_builder/core/filter_operators.py index 035dccc..00e9a09 100644 --- a/src/nicegui_builder/core/filter_operators.py +++ b/src/nicegui_builder/core/filter_operators.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import Any @dataclass(frozen=True, slots=True) @@ -32,3 +33,41 @@ class FilterOperator: def normalize_filter_operator(name: str) -> str: return LEGACY_FILTER_OPERATOR_NAMES.get(name, name) + + +def canonical_filter_clause(raw_value, *, default_op: str = "equals") -> dict[str, Any]: + if isinstance(raw_value, dict): + operator = raw_value.get("op", default_op) + value = raw_value.get("value") + enabled = raw_value.get("enabled", True) + else: + operator = default_op + value = raw_value + enabled = True + + return { + "op": normalize_filter_operator(operator), + "value": value, + "enabled": bool(enabled), + } + + +def canonical_filter_store( + filter_values: dict[str, object], + *, + default_op: str = "equals", +) -> dict[str, dict[str, Any]]: + return { + field_name: canonical_filter_clause(raw_value, default_op=default_op) + for field_name, raw_value in filter_values.items() + } + + +def active_filter_clauses( + filter_values: dict[str, dict[str, Any]], +) -> dict[str, dict[str, Any]]: + return { + field_name: clause + for field_name, clause in filter_values.items() + if clause.get("enabled", True) + } diff --git a/src/nicegui_builder/core/table.py b/src/nicegui_builder/core/table.py index 4556ca2..32d5c85 100644 --- a/src/nicegui_builder/core/table.py +++ b/src/nicegui_builder/core/table.py @@ -6,8 +6,9 @@ from nicegui import ui from .actions import TABLE_ACTION_SPECS, apply_action_intent, get_action_spec -from .filter_operators import normalize_filter_operator +from .filter_operators import canonical_filter_clause, canonical_filter_store, normalize_filter_operator from .models import ActionSpec, TableSpec +from ..plugins.registry import filter_collection_rows from .view import ViewHandle @@ -27,7 +28,14 @@ def component(self): def _filter_store(self) -> dict[str, object]: component_filters = getattr(self.component, "filter_values", None) if isinstance(component_filters, dict): + if component_filters: + canonicalized = canonical_filter_store(component_filters) + if canonicalized != component_filters: + component_filters.clear() + component_filters.update(canonicalized) self.filter_values = component_filters + elif self.filter_values: + self.filter_values = canonical_filter_store(self.filter_values) return self.filter_values def _refresh_filter_ui(self) -> None: @@ -37,21 +45,16 @@ def _refresh_filter_ui(self) -> None: def normalized_filter_values(self) -> dict[str, object]: normalized: dict[str, object] = {} - for field_name, raw_value in self._filter_store().items(): - if raw_value in (None, "", []): + for field_name, clause in self._filter_store().items(): + value = clause["value"] + if value in (None, "", []): continue - - if isinstance(raw_value, dict): - if raw_value.get("enabled") is False: - continue - operator = normalize_filter_operator(raw_value.get("op", "equals")) - value = raw_value.get("value") - if value in (None, "", []): - continue - normalized[field_name] = {"op": operator, "value": value} + if clause["enabled"] is False: continue - - normalized[field_name] = raw_value + normalized[field_name] = { + "op": normalize_filter_operator(clause["op"]), + "value": value, + } return normalized @@ -259,21 +262,22 @@ def crud_bar( return parts def filter_rows(self, filter_values: dict[str, object]) -> list[dict]: - if self.plugin is None or not hasattr(self.plugin, "filter_rows"): + if self.plugin is None: raise TypeError("filter_rows() is not supported for this table handle") - - return self.plugin.filter_rows(self.table_spec.source, filter_values) + return filter_collection_rows(self.plugin, self.table_spec.source, filter_values) def set_filter(self, field_name: str, value, *, op: str | None = None): store = self._filter_store() - store[field_name] = {"op": op, "value": value} if op else value + store[field_name] = canonical_filter_clause( + {"op": op, "value": value} if op else value + ) self._refresh_filter_ui() return self def clear_filters(self): self._filter_store().clear() self._refresh_filter_ui() - if self.plugin is not None and hasattr(self.plugin, "filter_rows"): + if self.plugin is not None: return self.set_rows(self.filter_rows(self.normalized_filter_values())) return self @@ -281,6 +285,6 @@ def apply_filters(self, filter_values: dict[str, object] | None = None): if filter_values is not None: store = self._filter_store() store.clear() - store.update(filter_values) + store.update(canonical_filter_store(filter_values)) self._refresh_filter_ui() return self.set_rows(self.filter_rows(self.normalized_filter_values())) diff --git a/src/nicegui_builder/form.py b/src/nicegui_builder/form.py index 1cb6fdc..b134aef 100644 --- a/src/nicegui_builder/form.py +++ b/src/nicegui_builder/form.py @@ -4,9 +4,11 @@ import yaml from .builder import builder, register, builder_ctx +from .core.context import component_refs, ensure_builder_runtime from .core.form import FormHandle from .core.models import FormSpec from .plugins import plugin_registry +from .plugins.registry import build_form_layout, maybe_render_form, resolve_field_plugin def _resolve_layout_from_source(source, flavor: str): @@ -43,30 +45,24 @@ def _resolve_plugin_field(key: str, value: dict): def form(source, flavor: str = ""): - plugin = plugin_registry.resolve(source) - if not hasattr(plugin, "inspect_fields") or not hasattr(plugin, "resolve_field_node"): - raise TypeError(f"plugin '{plugin.name}' does not support form(...)") + plugin = resolve_field_plugin(source) source_class = source if isinstance(source, type) else source.__class__ source_instance = None if isinstance(source, type) else source - if hasattr(plugin, "render_form"): - rendered = plugin.render_form(source, flavor=flavor) - if rendered is not None: - return rendered + rendered = maybe_render_form(plugin, source, flavor=flavor) + if rendered is not None: + return rendered field_specs = plugin.inspect_fields(source) try: layout = _resolve_layout_from_source(source, flavor) except FileNotFoundError: - if hasattr(plugin, "build_layout"): - layout = plugin.build_layout(source, flavor=flavor) - else: - raise + layout = build_form_layout(plugin, source, flavor=flavor) ctx = dict(builder_ctx.get()) ctx_token = builder_ctx.set(ctx) + ensure_builder_runtime(ctx) ctx.update( - _component_refs={}, plugin=plugin, source=source, source_class=source_class, @@ -90,7 +86,7 @@ def form(source, flavor: str = ""): plugin=plugin, source_class=source_class, field_specs=field_specs, - component_refs=dict(ctx.get("_component_refs", {})), + component_refs=dict(component_refs(ctx)), source_instance=source_instance, form_spec=form_spec, ) diff --git a/src/nicegui_builder/plugins/pandas/plugin.py b/src/nicegui_builder/plugins/pandas/plugin.py index e58f199..7703f2f 100644 --- a/src/nicegui_builder/plugins/pandas/plugin.py +++ b/src/nicegui_builder/plugins/pandas/plugin.py @@ -8,7 +8,12 @@ normalize_datetime_input, render_split_datetime_inputs, ) -from nicegui_builder.core.filter_operators import FILTER_OPERATORS, normalize_filter_operator +from nicegui_builder.core.filter_operators import ( + FILTER_OPERATORS, + active_filter_clauses, + canonical_filter_clause, + normalize_filter_operator, +) from nicegui_builder.core.models import CollectionSpec, FieldSpec, TableSpec, WidgetSpec INTERNAL_ROW_ID = "nicegui_builder_row_id" @@ -112,20 +117,10 @@ def _build_filter_spec(column: FieldSpec) -> FieldSpec: def _normalize_filter_clause(value, column: FieldSpec): - if isinstance(value, dict): - return { - "op": normalize_filter_operator(value.get("op", "equals")), - "value": value.get("value"), - } - default_op = column.source_meta.get("filter_default_operator") if default_op is None: default_op = "contains" if column.source_meta.get("filter_kind") == "text" else "equals" - - return { - "op": default_op, - "value": value, - } + return canonical_filter_clause(value, default_op=default_op) def _parse_csv_values(raw_value): @@ -172,6 +167,27 @@ def _coerce_filter_values(column: FieldSpec, operator: str, raw_value): return raw_value +def _validated_coerced_clause(column: FieldSpec, clause: dict[str, object]) -> dict[str, object]: + operator = normalize_filter_operator(str(clause["op"])) + allowed = column.source_meta.get("filter_operators", []) + if operator not in allowed: + raise ValueError(f"unsupported filter operator for {column.name}: {operator}") + + raw_value = clause["value"] + if _is_empty_filter_value(operator, raw_value): + return { + "op": operator, + "value": raw_value, + "enabled": clause.get("enabled", True), + } + + return { + "op": operator, + "value": _coerce_filter_values(column, operator, raw_value), + "enabled": clause.get("enabled", True), + } + + def _apply_text_filter(filtered, column_name: str, operator: str, raw_value): operator = normalize_filter_operator(operator) series = filtered[column_name].astype(str) @@ -264,15 +280,6 @@ def _build_operator_options(field: FieldSpec) -> dict[str, str]: } -def _enabled_filter_values(filter_values: dict[str, object]) -> dict[str, object]: - enabled: dict[str, object] = {} - for field_name, value in filter_values.items(): - if isinstance(value, dict) and value.get("enabled") is False: - continue - enabled[field_name] = value - return enabled - - def _is_empty_filter_value(operator: str, value) -> bool: operator = normalize_filter_operator(operator) if operator == "between": @@ -559,22 +566,21 @@ def filter_rows(self, source, filter_values: dict[str, object]) -> list[dict]: if isinstance(value, dict) and value.get("enabled") is False: continue - clause = _normalize_filter_clause(value, column) + clause = _validated_coerced_clause(column, _normalize_filter_clause(value, column)) operator = clause["op"] raw_value = clause["value"] if _is_empty_filter_value(operator, raw_value): continue - coerced_value = _coerce_filter_values(column, operator, raw_value) if column.source_meta.get("filter_kind") in {"select", "boolean"}: - filtered = _apply_scalar_filter(filtered, column.name, operator, coerced_value) + filtered = _apply_scalar_filter(filtered, column.name, operator, raw_value) continue if column.python_type in {int, float} or column.python_type == "datetime": - filtered = _apply_scalar_filter(filtered, column.name, operator, coerced_value) + filtered = _apply_scalar_filter(filtered, column.name, operator, raw_value) continue - filtered = _apply_text_filter(filtered, column.name, operator, coerced_value) + filtered = _apply_text_filter(filtered, column.name, operator, raw_value) return _rows_from_dataframe(filtered) @@ -601,11 +607,8 @@ def render_collection( table_component = None def apply_filters(): - try: - table_component.rows = self.filter_rows(source, _enabled_filter_values(filter_values)) - table_component.update() - except ValueError: - return + table_component.rows = self.filter_rows(source, active_filter_clauses(filter_values)) + table_component.update() builder_state = _default_builder_state(spec) @@ -678,11 +681,10 @@ def _add_filter(): value = builder_state["value"] if _is_empty_filter_value(operator, value): return - filter_values[field_name] = { - "op": operator, - "value": value, - "enabled": True, - } + filter_values[field_name] = _validated_coerced_clause( + filter_fields[field_name], + {"op": operator, "value": value, "enabled": True}, + ) render_active_filters() apply_filters() @@ -733,17 +735,8 @@ def render_active_filters(): table_component.props(widget.props) if table_spec is not None: - try: - table_component.table_spec = table_spec - except Exception: - pass - try: - table_component.filter_values = filter_values - except Exception: - pass - try: - table_component.refresh_filters_ui = render_active_filters if spec.filters else (lambda: None) - except Exception: - pass + table_component.table_spec = table_spec + table_component.filter_values = filter_values + table_component.refresh_filters_ui = render_active_filters if spec.filters else (lambda: None) return table_component diff --git a/src/nicegui_builder/plugins/registry.py b/src/nicegui_builder/plugins/registry.py index 4e287ae..d7bcda5 100644 --- a/src/nicegui_builder/plugins/registry.py +++ b/src/nicegui_builder/plugins/registry.py @@ -1,4 +1,4 @@ -from nicegui_builder.plugins.base import PluginRegistration, SourcePlugin +from nicegui_builder.plugins.base import CollectionPlugin, FieldPlugin, PluginRegistration, SourcePlugin class PluginRegistry: @@ -27,3 +27,68 @@ def resolve(self, source) -> SourcePlugin: plugin_registry = PluginRegistry() + + +def _require_plugin_methods(plugin: SourcePlugin, *method_names: str) -> None: + missing = [ + method_name + for method_name in method_names + if not callable(getattr(plugin, method_name, None)) + ] + if missing: + joined = ", ".join(missing) + raise TypeError(f"plugin '{plugin.name}' is missing required method(s): {joined}") + + +def resolve_field_plugin(source) -> FieldPlugin: + plugin = plugin_registry.resolve(source) + _require_plugin_methods(plugin, "inspect_fields", "resolve_field_node") + return plugin + + +def resolve_collection_plugin(source) -> CollectionPlugin: + plugin = plugin_registry.resolve(source) + _require_plugin_methods(plugin, "inspect_collection", "resolve_collection_widget") + return plugin + + +def maybe_render_form(plugin: FieldPlugin, source, *, flavor: str = ""): + render_form = getattr(plugin, "render_form", None) + if callable(render_form): + return render_form(source, flavor=flavor) + return None + + +def build_form_layout(plugin: FieldPlugin, source, *, flavor: str = ""): + build_layout = getattr(plugin, "build_layout", None) + if not callable(build_layout): + raise FileNotFoundError("plugin does not provide build_layout(...)") + return build_layout(source, flavor=flavor) + + +def maybe_render_collection( + plugin: CollectionPlugin, + source, + spec, + *, + variant: str = "std", + table_spec=None, +): + render_collection = getattr(plugin, "render_collection", None) + if callable(render_collection): + return render_collection(source, spec, variant=variant, table_spec=table_spec) + return None + + +def prepare_collection_rows(plugin: CollectionPlugin, source) -> list[dict]: + prepare_rows = getattr(plugin, "prepare_rows", None) + if callable(prepare_rows): + return prepare_rows(source) + return source.to_dict(orient="records") + + +def filter_collection_rows(plugin: CollectionPlugin, source, filter_values: dict[str, object]) -> list[dict]: + filter_rows = getattr(plugin, "filter_rows", None) + if not callable(filter_rows): + raise TypeError("filter_rows() is not supported for this collection plugin") + return filter_rows(source, filter_values) diff --git a/src/nicegui_builder/table.py b/src/nicegui_builder/table.py index df6a48b..3e08812 100644 --- a/src/nicegui_builder/table.py +++ b/src/nicegui_builder/table.py @@ -2,6 +2,11 @@ from .core.table import TableHandle from .core.models import TableSpec from .plugins import plugin_registry +from .plugins.registry import ( + maybe_render_collection, + prepare_collection_rows, + resolve_collection_plugin, +) from .plugins import pandas as _pandas_plugin # ensure builtin plugin registration @@ -9,10 +14,7 @@ def _build_table_handle(component, table_spec: TableSpec, plugin=None): if component is None: return None - try: - setattr(component, "table_spec", table_spec) - except Exception: - pass + setattr(component, "table_spec", table_spec) handle = TableHandle(table_spec=table_spec, root_component=component, plugin=plugin) @@ -20,18 +22,13 @@ def _build_table_handle(component, table_spec: TableSpec, plugin=None): if isinstance(component_filters, dict): handle.filter_values = component_filters - try: - setattr(component, "table_handle", handle) - except Exception: - pass + setattr(component, "table_handle", handle) return handle def table(source, variant: str = "std"): - plugin = plugin_registry.resolve(source) - if not hasattr(plugin, "inspect_collection") or not hasattr(plugin, "resolve_collection_widget"): - raise TypeError(f"plugin '{plugin.name}' does not support table(...)") + plugin = resolve_collection_plugin(source) collection_spec = plugin.inspect_collection(source) source_class = source if isinstance(source, type) else source.__class__ @@ -45,12 +42,11 @@ def table(source, variant: str = "std"): plugin_name=plugin.name, ) - if hasattr(plugin, "render_collection"): - rendered = plugin.render_collection(source, collection_spec, variant=variant, table_spec=table_spec) - if rendered is not None: - return _build_table_handle(rendered, table_spec, plugin=plugin) + rendered = maybe_render_collection(plugin, source, collection_spec, variant=variant, table_spec=table_spec) + if rendered is not None: + return _build_table_handle(rendered, table_spec, plugin=plugin) - rows = plugin.prepare_rows(source) if hasattr(plugin, "prepare_rows") else source.to_dict(orient="records") + rows = prepare_collection_rows(plugin, source) layout = [ { diff --git a/tests/test_nicegui_builder/core/test_table.py b/tests/test_nicegui_builder/core/test_table.py index 700971f..f036e10 100644 --- a/tests/test_nicegui_builder/core/test_table.py +++ b/tests/test_nicegui_builder/core/test_table.py @@ -116,6 +116,7 @@ def update(self): assert fake_component.updated is True assert handle.describe()["handle_type"] == "TableHandle" assert handle.describe()["spec_type"] == "TableSpec" + assert handle.filter_values["name"] == {"op": "equals", "value": "ada", "enabled": True} def test_table_handle_can_store_and_clear_filter_state(monkeypatch): @@ -179,6 +180,23 @@ def update(self): assert handle.filter_values is fake_component.filter_values assert handle.normalized_filter_values() == {"name": {"op": "contains", "value": "ad"}} + assert handle.filter_values["name"] == {"op": "contains", "value": "ad", "enabled": True} + + +def test_table_handle_canonicalizes_partial_filter_values_on_apply(): + handle = _make_table_handle(component=type("Component", (), {"rows": [], "update": lambda self=None: None})(), plugin=type("Plugin", (), {"filter_rows": lambda self, source, values: []})()) + + handle.apply_filters( + { + "name": {"value": "Ada"}, + "score": 10, + } + ) + + assert handle.filter_values == { + "name": {"op": "equals", "value": "Ada", "enabled": True}, + "score": {"op": "equals", "value": 10, "enabled": True}, + } def test_table_handle_refreshes_filter_ui_when_filters_change(): @@ -227,6 +245,19 @@ def update(self): assert handle.get_rows() == [{"name": "Ada"}, {"name": "Grace"}] +def test_table_handle_apply_filters_propagates_plugin_validation_errors(): + component = type("Component", (), {"rows": [], "update": lambda self=None: None})() + plugin = type( + "Plugin", + (), + {"filter_rows": lambda self, source, values: (_ for _ in ()).throw(ValueError("invalid filter"))}, + )() + handle = _make_table_handle(component=component, plugin=plugin) + + with pytest.raises(ValueError, match="invalid filter"): + handle.apply_filters({"name": {"op": "equals", "value": "Ada"}}) + + def test_table_handle_supports_sort_pagination_selection_and_csv_export(monkeypatch): df = pandas.DataFrame( [ @@ -403,7 +434,7 @@ def test_table_handle_normalized_filter_values_skips_empty_entries(): ) assert handle.normalized_filter_values() == { - "name": "Ada", + "name": {"op": "equals", "value": "Ada"}, "score": {"op": "between", "value": [10, 20]}, "defaulted_dict": {"op": "equals", "value": "Grace"}, "legacy_dict": {"op": "gte", "value": 10}, diff --git a/tests/test_nicegui_builder/plugins/pandas/test_plugin.py b/tests/test_nicegui_builder/plugins/pandas/test_plugin.py index a229b21..2428d4c 100644 --- a/tests/test_nicegui_builder/plugins/pandas/test_plugin.py +++ b/tests/test_nicegui_builder/plugins/pandas/test_plugin.py @@ -1,6 +1,8 @@ import builtins from datetime import datetime +import pytest + from nicegui_builder.core.models import CollectionSpec, FieldSpec, TableSpec, WidgetSpec from nicegui_builder.plugins.pandas import pandas_plugin from nicegui_builder.plugins.pandas import plugin as pandas_module @@ -72,14 +74,25 @@ def test_normalize_and_apply_text_filters_cover_all_operators(): ) text_column = FieldSpec(name="name", python_type=str, source_meta={"filter_kind": "text"}) - assert pandas_module._normalize_filter_clause("Ada", text_column) == {"op": "contains", "value": "Ada"} + assert pandas_module._normalize_filter_clause("Ada", text_column) == { + "op": "contains", + "value": "Ada", + "enabled": True, + } assert pandas_module._normalize_filter_clause({"value": "Ada"}, text_column) == { - "op": "equals", + "op": "contains", "value": "Ada", + "enabled": True, } assert pandas_module._normalize_filter_clause({"op": "ge", "value": "Ada"}, text_column) == { "op": "gte", "value": "Ada", + "enabled": True, + } + assert pandas_module._normalize_filter_clause("Ada", text_column) == { + "op": "contains", + "value": "Ada", + "enabled": True, } assert [row["name"] for row in pandas_module._apply_text_filter(df, "name", "contains", "a").to_dict("records")] == [ @@ -158,6 +171,31 @@ def test_apply_scalar_filter_covers_all_operators_and_errors(): raise AssertionError("_apply_scalar_filter should reject unsupported operators") +def test_validated_coerced_clause_rejects_invalid_operator_and_coerces_values(): + datetime_column = FieldSpec( + name="starts_at", + python_type="datetime", + source_meta={ + "filter_kind": "datetime", + "filter_operators": ["equals", "between"], + }, + ) + + clause = pandas_module._validated_coerced_clause( + datetime_column, + {"op": "between", "value": ["2026-03-21T10:00", "2026-03-21T12:00"], "enabled": True}, + ) + + assert isinstance(clause["value"][0], pandas.Timestamp) + assert isinstance(clause["value"][1], pandas.Timestamp) + + with pytest.raises(ValueError, match="unsupported filter operator"): + pandas_module._validated_coerced_clause( + datetime_column, + {"op": "contains", "value": "2026-03-21T10:00", "enabled": True}, + ) + + def test_rows_from_dataframe_adds_internal_row_ids(): df = pandas.DataFrame([{"name": "Ada"}, {"name": "Grace"}]) @@ -467,7 +505,7 @@ def update(self): assert rendered.filter_values == {} -def test_pandas_plugin_render_collection_uses_select_controls_and_tolerates_attachment_failures(monkeypatch): +def test_pandas_plugin_render_collection_uses_select_controls_and_requires_attachment_support(monkeypatch): df = pandas.DataFrame( [ {"state": "queued", "active": True}, @@ -620,6 +658,26 @@ def update(self): monkeypatch.setattr(pandas_module.ui, "checkbox", lambda value=False, on_change=None, **kwargs: FakeCheckbox(value=value, on_change=on_change, **kwargs)) monkeypatch.setattr(pandas_module.ui, "table", lambda **kwargs: FragileTable(**kwargs)) + with pytest.raises(RuntimeError, match="read-only attachment"): + pandas_plugin.render_collection(df, spec, variant="filters", table_spec=table_spec) + + monkeypatch.setattr( + pandas_module.ui, + "table", + lambda **kwargs: type( + "WritableTable", + (), + { + "__init__": lambda self: None, + "rows": kwargs["rows"], + "updated": False, + "classes": lambda self, value: self, + "props": lambda self, value: self, + "update": lambda self: setattr(self, "updated", True), + }, + )(), + ) + rendered = pandas_plugin.render_collection(df, spec, variant="filters", table_spec=table_spec) assert rendered is not None diff --git a/tests/test_nicegui_builder/test_table.py b/tests/test_nicegui_builder/test_table.py index 30ee6d6..1255594 100644 --- a/tests/test_nicegui_builder/test_table.py +++ b/tests/test_nicegui_builder/test_table.py @@ -38,7 +38,7 @@ def test_build_table_handle_returns_none_for_missing_component(): assert table_module._build_table_handle(None, table_spec) is None -def test_build_table_handle_tolerates_failing_component_attribute_assignment(): +def test_build_table_handle_requires_component_metadata_attachment(): class FragileComponent: def __init__(self) -> None: object.__setattr__(self, "filter_values", {"name": "ada"}) @@ -52,11 +52,8 @@ def __setattr__(self, name, value): collection_spec = CollectionSpec(name="DemoRows") table_spec = table_module.TableSpec(source_class=list, collection_spec=collection_spec, plugin_name="fake") - handle = table_module._build_table_handle(component, table_spec, plugin="plugin") - - assert handle.component is component - assert handle.table_spec is table_spec - assert handle.filter_values == {"name": "ada"} + with pytest.raises(RuntimeError, match="read-only test component"): + table_module._build_table_handle(component, table_spec, plugin="plugin") def test_table_returns_plugin_rendered_collection_when_available(monkeypatch): From 59902f738a0067e78f5c430814726d9ce27a0704 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Fournier?= Date: Wed, 25 Mar 2026 22:28:37 -0400 Subject: [PATCH 4/9] build: make pydantic support optional --- README.md | 12 +++++++++++ docs/plugin.md | 3 +++ pyproject.toml | 3 ++- requirements-pydantic.txt | 1 + requirements.txt | 1 - src/nicegui_builder/plugins/__init__.py | 17 +++++++++++++-- .../plugins/test_registry.py | 21 +++++++++++++++++++ 7 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 requirements-pydantic.txt diff --git a/README.md b/README.md index ea4a893..1df0225 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,18 @@ Optional `pandas` support: pip install "nicegui-builder[pandas]" ``` +Optional `pydantic` support: + +```bash +pip install "nicegui-builder[pydantic]" +``` + +Everything included: + +```bash +pip install "nicegui-builder[all]" +``` + CLI entry point: ```bash diff --git a/docs/plugin.md b/docs/plugin.md index c1a2741..11f596c 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -20,6 +20,9 @@ Examples already present in the project: - `pydantic` for schema-driven forms - `pandas` for table-oriented collections +Both built-in source plugins are optional dependencies. +The base package stays importable without them, and each plugin registers itself only when its dependency is available. + ## Mental Model The core library should stay source-agnostic. diff --git a/pyproject.toml b/pyproject.toml index 7fe6f08..c936961 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,8 +53,9 @@ dependencies = { file = ["requirements.txt"] } [tool.setuptools.dynamic.optional-dependencies] dev = { file = ["requirements-dev.txt"] } docs = { file = ["requirements-docs.txt"] } +pydantic = { file = ["requirements-pydantic.txt"] } pandas = { file = ["requirements-pandas.txt"] } -all = { file = ["requirements-dev.txt", "requirements-docs.txt", "requirements-pandas.txt"] } +all = { file = ["requirements-dev.txt", "requirements-docs.txt", "requirements-pydantic.txt", "requirements-pandas.txt"] } [tool.pytest.ini_options] addopts = "--basetemp=.pytest_tmp" diff --git a/requirements-pydantic.txt b/requirements-pydantic.txt new file mode 100644 index 0000000..572b352 --- /dev/null +++ b/requirements-pydantic.txt @@ -0,0 +1 @@ +pydantic diff --git a/requirements.txt b/requirements.txt index 1352ed9..c567567 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,3 @@ flatten-dict nicegui -pydantic pyyaml diff --git a/src/nicegui_builder/plugins/__init__.py b/src/nicegui_builder/plugins/__init__.py index 270299c..35f7e11 100644 --- a/src/nicegui_builder/plugins/__init__.py +++ b/src/nicegui_builder/plugins/__init__.py @@ -1,6 +1,19 @@ +from importlib import import_module + from .base import CollectionPlugin, FieldPlugin, SourcePlugin from .registry import plugin_registry -from . import pydantic as _pydantic_plugin # ensure builtin plugin registration -from . import pandas as _pandas_plugin # ensure builtin plugin registration + + +def _load_optional_builtin(module_name: str, *, missing_dependencies: set[str]) -> None: + try: + import_module(f"{__name__}.{module_name}") + except ModuleNotFoundError as exc: + if exc.name in missing_dependencies: + return + raise + + +_load_optional_builtin("pydantic", missing_dependencies={"pydantic", "pydantic_core"}) +_load_optional_builtin("pandas", missing_dependencies={"pandas"}) __all__ = ["CollectionPlugin", "FieldPlugin", "SourcePlugin", "plugin_registry"] diff --git a/tests/test_nicegui_builder/plugins/test_registry.py b/tests/test_nicegui_builder/plugins/test_registry.py index 6df5b38..af976ac 100644 --- a/tests/test_nicegui_builder/plugins/test_registry.py +++ b/tests/test_nicegui_builder/plugins/test_registry.py @@ -1,6 +1,7 @@ import pytest from nicegui_builder.plugins import plugin_registry +import nicegui_builder.plugins as plugins_module from nicegui_builder.plugins.registry import PluginRegistry @@ -55,3 +56,23 @@ class UnknownSource: with pytest.raises(LookupError, match="UnknownSource"): registry.resolve(UnknownSource) + + +def test_plugins_module_can_skip_optional_builtin_imports(monkeypatch): + imported = [] + + def fake_import(name): + imported.append(name) + if name.endswith(".pydantic"): + raise ModuleNotFoundError("No module named 'pydantic'", name="pydantic") + return object() + + monkeypatch.setattr(plugins_module, "import_module", fake_import) + + plugins_module._load_optional_builtin("pydantic", missing_dependencies={"pydantic", "pydantic_core"}) + plugins_module._load_optional_builtin("pandas", missing_dependencies={"pandas"}) + + assert imported == [ + "nicegui_builder.plugins.pydantic", + "nicegui_builder.plugins.pandas", + ] From 849f7776cbd0e4f5ade9c9b32018cc157e8073bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Fournier?= Date: Wed, 25 Mar 2026 23:00:43 -0400 Subject: [PATCH 5/9] feat: add component refs support --- README.md | 46 +++++++++++++++++++ docs/architecture.md | 6 +++ docs/layout-schema.md | 17 ++++++- docs/plugin.md | 10 ++++ docs/public-api.md | 7 +++ src/nicegui_builder/builder.py | 7 ++- src/nicegui_builder/core/component_refs.py | 8 ++++ src/nicegui_builder/core/datetime_inputs.py | 8 +++- src/nicegui_builder/core/form.py | 5 +- src/nicegui_builder/core/view.py | 9 ++++ src/nicegui_builder/form.py | 23 +++++++++- .../plugins/pydantic/resolve.py | 2 + tests/test_nicegui_builder/test_builder.py | 23 ++++++++++ tests/test_nicegui_builder/test_form.py | 33 +++++++++++++ 14 files changed, 196 insertions(+), 8 deletions(-) create mode 100644 src/nicegui_builder/core/component_refs.py diff --git a/README.md b/README.md index 1df0225..9e5d286 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,27 @@ builder(layout) ui.run() ``` +`builder(...)` returns the root NiceGUI component. +If layout nodes declare `ref`, that root component also exposes a `component_refs` dictionary for later lookup. + +Example: + +```yaml +- card: + ref: profile_card + children: + - label: + ref: title_label + params: + text: Profile +``` + +```python +root = builder(layout) +root.component_refs["profile_card"] +root.component_refs["title_label"] +``` + For the declarative layout language itself, see: - [`docs/layout-schema.md`](docs/layout-schema.md) @@ -139,6 +160,20 @@ Example: That lets you keep the date/time pair together while placing it inside a `row`, `column`, `grid`, or another declarative container. +All `pydantic` fields are also exposed through `handle.component_refs`. +By default, field refs use the logical name `field:`. + +For split `datetime` fields, the logical ref resolves to a composite object with `.date` and `.time`. + +Example: + +```python +starts_at = handle.component_refs["field:starts_at"] +starts_at.container +starts_at.date +starts_at.time +``` + ### `table(source, variant="std")` Use `table(...)` when you want a plugin to inspect a collection-like source and render a table. @@ -282,6 +317,17 @@ handle.action_bar( ) ``` +Component refs: + +```python +handle.component_refs["field:email"] +handle.get_component("field:email") + +starts_at = handle.component_refs["field:starts_at"] +starts_at.date +starts_at.time +``` + ## Working With Table Handles `table(...)` returns a `TableHandle`. diff --git a/docs/architecture.md b/docs/architecture.md index 7aadbab..5e07188 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -93,6 +93,7 @@ Examples: - validate and submit a form - sort/filter/export a table - expose the spec and the root component +- expose component refs for later component lookup ## View 1: Core Specs @@ -251,10 +252,12 @@ classDiagram class ViewHandle { +root_component +plugin + +component_refs +spec +plugin_name +spec_type +describe() + +get_component(ref) +refresh() +show() +hide() @@ -321,6 +324,7 @@ They are lightweight view-oriented facades: - `ViewHandle` gives a common base - `FormHandle` adds form state, validation, submit, and live helpers - `TableHandle` adds rows, filters, selection, export, and table actions +- handles can also expose `component_refs` for named runtime lookup ## Builder Role @@ -332,6 +336,7 @@ What matters architecturally is this: - it resolves context values - it supports plugin-assisted node expansion such as `field__name` - it keeps a small explicit runtime context for refs and root-component tracking +- it collects named refs into `component_refs` In other words, the builder is the bridge between normalized layout decisions and actual NiceGUI components. @@ -352,6 +357,7 @@ Examples of notable behavior: - automatic form layouts - widget mapping from `pydantic-nicegui.yml` - split `datetime` handling as `date + time` +- automatic field refs in `component_refs`, including composite logical refs for split `datetime` ### `pandas` diff --git a/docs/layout-schema.md b/docs/layout-schema.md index 2475715..d9dceff 100644 --- a/docs/layout-schema.md +++ b/docs/layout-schema.md @@ -61,7 +61,7 @@ Supported configuration keys: - `params`: keyword arguments passed to the resolved method - `props`: props string passed to `.props(...)` - `classes`: classes string passed to `.classes(...)` -- `ref`: optional builder reference name +- `ref`: optional string reference name stored in `component_refs` - `children`: nested layout entries - `context`: reserved for future layout-time metadata @@ -69,6 +69,7 @@ Example: ```yaml - grid: + ref: contact_grid params: columns: 12 classes: w-full gap-3 @@ -79,6 +80,9 @@ Example: classes: text-h6 ``` +If `ref` is present, the rendered component is added to the runtime `component_refs` dictionary under that name. +Duplicate ref names are rejected. + ## Expansion Nodes An expansion node delegates resolution to a registered callback. @@ -107,6 +111,7 @@ Another example with the split `datetime` widget: ```yaml - field__starts_at: + ref: starts_at container: grid params: columns: 2 @@ -116,9 +121,19 @@ Another example with the split `datetime` widget: In that case: - `field__starts_at` selects the field expansion callback +- `ref` names the logical component ref exposed at runtime - `container` overrides the wrapper component used for the split `date_input + time_input` - `params` and `classes` apply to that wrapper container +For `pydantic` field expansions: + +- every field gets a logical ref even if you do not provide one explicitly +- the default logical name is `field:` +- split `datetime` fields also expose child refs with `:date` and `:time` suffixes + +So the example above produces a logical ref named `starts_at`, plus child refs `starts_at:date` and `starts_at:time`. +The form handle then exposes the logical ref as a composite object with `.date` and `.time`. + ## Null Values You can omit the node body entirely by using `null`. diff --git a/docs/plugin.md b/docs/plugin.md index 11f596c..d27c11b 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -181,6 +181,16 @@ Example: This means the plugin can provide a rich field expansion without forcing a single visual wrapper such as `row`. +The `pydantic` form flow also auto-registers field components in `component_refs`. +Default logical names use the form `field:`. +For split `datetime` fields, the logical ref resolves to a composite object with: + +- `.container` +- `.date` +- `.time` + +If the layout supplies `ref`, that explicit logical name is used instead. + ## Pandas Plugin Notes The `pandas` plugin is a good reference for collection-oriented plugins. diff --git a/docs/public-api.md b/docs/public-api.md index ec1aed3..66db64f 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -32,6 +32,13 @@ from nicegui_builder import ( These names are expected to remain importable across normal library evolution. +In addition, the observable behavior of the top-level entry points is part of the stable contract. +Examples: + +- `builder(...)` returns the root component and may attach `component_refs` when layout nodes declare `ref` +- `form(...)` returns a `FormHandle` with `component_refs` and `get_component(ref)` +- `table(...)` returns a `TableHandle` with the documented handle helpers + ## Advanced / Experimental The following namespaces remain available for advanced use, but are not yet treated as stable contracts: diff --git a/src/nicegui_builder/builder.py b/src/nicegui_builder/builder.py index a07bbc8..d17670b 100644 --- a/src/nicegui_builder/builder.py +++ b/src/nicegui_builder/builder.py @@ -93,7 +93,10 @@ def visit(components: list): ui_component.props(props) if ref: - component_refs(ctx)[ref] = ui_component + refs = component_refs(ctx) + if ref in refs: + raise ValueError(f"duplicate component ref: {ref}") + refs[ref] = ui_component # process children if any if children: @@ -139,5 +142,7 @@ def builder(layout) -> None: ctx_token = builder_ctx.set(ctx) visit(layout) root_component = get_root_component(ctx) + if root_component is not None and hasattr(root_component, "__dict__"): + root_component.component_refs = dict(component_refs(ctx)) builder_ctx.reset(ctx_token) return root_component diff --git a/src/nicegui_builder/core/component_refs.py b/src/nicegui_builder/core/component_refs.py new file mode 100644 index 0000000..1905b5d --- /dev/null +++ b/src/nicegui_builder/core/component_refs.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + + +@dataclass(slots=True, frozen=True) +class DateTimeComponentRef: + container: object | None + date: object | None + time: object | None diff --git a/src/nicegui_builder/core/datetime_inputs.py b/src/nicegui_builder/core/datetime_inputs.py index c4f429d..718d44c 100644 --- a/src/nicegui_builder/core/datetime_inputs.py +++ b/src/nicegui_builder/core/datetime_inputs.py @@ -118,6 +118,7 @@ def build_split_datetime_node( field_name: str, label: str, raw_value=None, + ref: str | None = None, container_methods: str = "row", container_params: dict | None = None, container_props: str = "", @@ -136,15 +137,18 @@ def build_split_datetime_node( if raw_value not in (None, ""): date_value, time_value = split_datetime_value(raw_value) + logical_ref = ref or f"field:{field_name}" + return { "methods": container_methods, + "ref": logical_ref, "params": dict(container_params or {}), "props": container_props, "classes": container_classes, "children": [ { "date_input": { - "ref": date_ref or f"field:{field_name}:date", + "ref": date_ref or f"{logical_ref}:date", "params": { "value": date_value, "label": date_label or f"{label} date", @@ -155,7 +159,7 @@ def build_split_datetime_node( }, { "time_input": { - "ref": time_ref or f"field:{field_name}:time", + "ref": time_ref or f"{logical_ref}:time", "params": { "value": time_value, "label": time_label or f"{label} time", diff --git a/src/nicegui_builder/core/form.py b/src/nicegui_builder/core/form.py index 2cbfa6f..41dcaa3 100644 --- a/src/nicegui_builder/core/form.py +++ b/src/nicegui_builder/core/form.py @@ -382,6 +382,7 @@ class FormHandle(ViewHandle): source_class: type field_specs: list[FieldSpec] component_refs: dict[str, object] + field_refs: dict[str, str] = field(default_factory=dict) source_instance: object | None = None form_spec: FormSpec | None = None _state: FormState = field(init=False, repr=False) @@ -467,13 +468,13 @@ def _setup_live_view(self, binding_type, refresh: Callable, *, event: str = "cha return binding_type(refresh=refresh, bound_fields=bound, **parts) def _field_ref(self, field: FieldSpec) -> str: - return f"field:{field.name}" + return self.field_refs.get(field.name, f"field:{field.name}") def _field_component(self, field: FieldSpec): return self.component(self._field_ref(field)) def _field_part_component(self, field_name: str, part: str): - return self.component(f"field:{field_name}:{part}") + return self.component(f"{self.field_refs.get(field_name, f'field:{field_name}')}:{part}") def _has_split_datetime_parts(self, field_name: str) -> bool: return ( diff --git a/src/nicegui_builder/core/view.py b/src/nicegui_builder/core/view.py index 0dd2dba..f0f08f3 100644 --- a/src/nicegui_builder/core/view.py +++ b/src/nicegui_builder/core/view.py @@ -35,6 +35,15 @@ def describe(self) -> dict[str, object]: ), } + def get_component(self, ref: str): + refs = getattr(self, "component_refs", None) + if isinstance(refs, dict): + return refs.get(ref) + refs = getattr(self.root_component, "component_refs", None) + if isinstance(refs, dict): + return refs.get(ref) + raise AttributeError("component refs are not available on this handle") + def refresh(self): if self.root_component is not None and hasattr(self.root_component, "update"): self.root_component.update() diff --git a/src/nicegui_builder/form.py b/src/nicegui_builder/form.py index b134aef..2d0f587 100644 --- a/src/nicegui_builder/form.py +++ b/src/nicegui_builder/form.py @@ -4,6 +4,7 @@ import yaml from .builder import builder, register, builder_ctx +from .core.component_refs import DateTimeComponentRef from .core.context import component_refs, ensure_builder_runtime from .core.form import FormHandle from .core.models import FormSpec @@ -37,7 +38,9 @@ def _resolve_plugin_field(key: str, value: dict): ) ctx.update(resolved["field_ctx"]) ctx["default_info"] = resolved.get("default_info") - resolved["node"]["ref"] = f"field:{fieldname}" + logical_ref = value.get("ref") or resolved["node"].get("ref") or f"field:{fieldname}" + resolved["node"]["ref"] = logical_ref + ctx.setdefault("_field_refs", {})[fieldname] = logical_ref return resolved["node"] @@ -69,6 +72,7 @@ def form(source, flavor: str = ""): source_instance=source_instance, field_specs=field_specs, layout=layout, + _field_refs={}, ) form_spec = FormSpec( @@ -81,12 +85,27 @@ def form(source, flavor: str = ""): ) root_component = builder(layout) + refs = dict(component_refs(ctx)) + field_refs = dict(ctx.get("_field_refs", {})) + for field in field_specs: + logical_ref = field_refs.get(field.name, f"field:{field.name}") + date_ref = f"{logical_ref}:date" + time_ref = f"{logical_ref}:time" + if date_ref in refs or time_ref in refs: + refs[logical_ref] = DateTimeComponentRef( + container=refs.get(logical_ref), + date=refs.get(date_ref), + time=refs.get(time_ref), + ) + if root_component is not None and hasattr(root_component, "__dict__"): + root_component.component_refs = refs handle = FormHandle( root_component=root_component, plugin=plugin, source_class=source_class, field_specs=field_specs, - component_refs=dict(component_refs(ctx)), + component_refs=refs, + field_refs=field_refs, source_instance=source_instance, form_spec=form_spec, ) diff --git a/src/nicegui_builder/plugins/pydantic/resolve.py b/src/nicegui_builder/plugins/pydantic/resolve.py index 4a41dad..4d48ecd 100644 --- a/src/nicegui_builder/plugins/pydantic/resolve.py +++ b/src/nicegui_builder/plugins/pydantic/resolve.py @@ -12,6 +12,7 @@ def _build_datetime_split_node(field_ctx: dict, default_info: dict, value: dict value = value or {} label = field_ctx.get("attributes_title") or field_ctx["fieldname"] raw_value = field_ctx.get("fieldvalue") + logical_ref = value.get("ref") or f"field:{field_ctx['fieldname']}" container_methods = value.get("container", default_info.get("methods")) container_params = dict(default_info.get("params", {})) container_params.update(value.get("params") or {}) @@ -36,6 +37,7 @@ def _build_datetime_split_node(field_ctx: dict, default_info: dict, value: dict field_name=field_ctx["fieldname"], label=label, raw_value=raw_value, + ref=logical_ref, container_methods=container_methods, container_params=container_params, container_props=container_props, diff --git a/tests/test_nicegui_builder/test_builder.py b/tests/test_nicegui_builder/test_builder.py index b76f3a5..e3955b1 100644 --- a/tests/test_nicegui_builder/test_builder.py +++ b/tests/test_nicegui_builder/test_builder.py @@ -1,4 +1,5 @@ import importlib +import pytest builder_module = importlib.import_module("nicegui_builder.builder") from nicegui_builder.builder import builder, register, resolve_context_value @@ -92,3 +93,25 @@ def resolve_field(field_name, value): assert root.params["text"] == "Resolved name" assert root.class_calls == ["resolved-field"] assert root.props_calls == ["outlined"] + assert root.component_refs["field:name"] is root + + +def test_builder_collects_explicit_component_refs_and_rejects_duplicates(monkeypatch): + fake_ui = FakeUI() + monkeypatch.setattr(builder_module, "ui", fake_ui) + + root = builder( + [ + {"card": {"ref": "panel"}}, + ] + ) + + assert root.component_refs["panel"] is root + + with pytest.raises(ValueError, match="duplicate component ref"): + builder( + [ + {"card": {"ref": "dup"}}, + {"label": {"ref": "dup", "params": {"text": "Hello"}}}, + ] + ) diff --git a/tests/test_nicegui_builder/test_form.py b/tests/test_nicegui_builder/test_form.py index 49fc49f..4908f85 100644 --- a/tests/test_nicegui_builder/test_form.py +++ b/tests/test_nicegui_builder/test_form.py @@ -177,3 +177,36 @@ def __init__(self, *args, **kwargs): assert handle.source_instance is instance assert calls["kwargs"]["source_instance"] is instance + + +def test_form_exposes_custom_component_refs_and_split_datetime_composites(monkeypatch): + class DateTimePlugin(FakePlugin): + def __init__(self): + self.field_specs = [FieldSpec(name="starts_at", python_type=object)] + + class DummyComponent: + def __init__(self): + self.component_refs = {} + + monkeypatch.setattr(form_module.plugin_registry, "resolve", lambda source: DateTimePlugin()) + monkeypatch.setattr( + form_module, + "_resolve_layout_from_source", + lambda source, flavor: [{"field__starts_at": {"ref": "starts_at"}}], + ) + + def fake_builder(layout): + ctx = form_module.builder_ctx.get() + refs = form_module.component_refs(ctx) + ctx["_field_refs"]["starts_at"] = "starts_at" + refs["starts_at"] = object() + refs["starts_at:date"] = object() + refs["starts_at:time"] = object() + return DummyComponent() + + monkeypatch.setattr(form_module, "builder", fake_builder) + + handle = form_module.form(type("Demo", (), {})) + + assert handle.component_refs["starts_at"].date is handle.component_refs["starts_at:date"] + assert handle.component_refs["starts_at"].time is handle.component_refs["starts_at:time"] From 6e878c56682ae16a95a544d72f6e49bfe01eaabd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Fournier?= Date: Thu, 26 Mar 2026 00:53:56 -0400 Subject: [PATCH 6/9] refactor: unify datetime input handling --- README.md | 22 +- docs/examples.md | 4 +- docs/layout-schema.md | 17 +- docs/plugin.md | 10 +- schemas/layout.schema.json | 25 +- src/nicegui_builder/core/datetime_inputs.py | 515 ++++++++++++------ src/nicegui_builder/core/form.py | 6 +- src/nicegui_builder/form.py | 21 +- src/nicegui_builder/plugins/pandas/plugin.py | 31 +- .../plugins/pydantic/resolve.py | 42 +- .../core/form/test_component_helpers.py | 48 +- .../plugins/pandas/test_plugin.py | 9 +- .../plugins/pydantic/test_resolve.py | 35 +- tests/test_nicegui_builder/test_form.py | 15 +- 14 files changed, 500 insertions(+), 300 deletions(-) diff --git a/README.md b/README.md index 9e5d286..2a94b0c 100644 --- a/README.md +++ b/README.md @@ -145,20 +145,23 @@ form(Contact, flavor="filters") handle = form(Contact, flavor="actionable") ``` -For `datetime` fields, the built-in `pydantic` plugin uses a split `date_input + time_input` widget by default. -That split stays grouped as one logical field, but the layout can choose its wrapper container. +For `datetime` fields, the built-in `pydantic` plugin uses a dedicated `datetime_input` component. +That component internally renders a coordinated `date_input + time_input` pair as one logical field, and the layout can choose its wrapper container. Example: ```yaml - field__starts_at: - container: grid params: - columns: 2 + container: + methods: grid + params: + columns: 2 + classes: gap-2 classes: col-span-12 gap-2 ``` -That lets you keep the date/time pair together while placing it inside a `row`, `column`, `grid`, or another declarative container. +That lets you keep the internal date/time pair together while placing it inside a `row`, `column`, `grid`, or another declarative container. All `pydantic` fields are also exposed through `handle.component_refs`. By default, field refs use the logical name `field:`. @@ -222,7 +225,7 @@ The UI lets you choose a field, an operator, and one or more values, then add th Active filters can be enabled or disabled with a checkbox and removed from the list without losing the builder state. For `in` and `notIn`, the current UI accepts comma-separated values. For numeric and datetime columns, `between` renders two inputs. -For `datetime` columns, the built-in filter UI uses the same split `date_input + time_input` pattern as the `pydantic` forms. +For `datetime` columns, the built-in filter UI reuses the same `datetime_input` component as the `pydantic` forms. ## Working With Form Handles @@ -446,9 +449,12 @@ Example: methods: email classes: w-full - field__starts_at: - container: grid params: - columns: 2 + container: + methods: grid + params: + columns: 2 + classes: gap-2 classes: col-span-12 gap-2 ``` diff --git a/docs/examples.md b/docs/examples.md index 2d0e073..2d9f82c 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -179,9 +179,9 @@ Goal: Covers: - default `datetime` resolution -- split widgets +- the shared `datetime_input` component - collected combined value -- choosing a wrapper container from the layout when desired +- choosing a wrapper container through `params.container` when desired Suggested file: diff --git a/docs/layout-schema.md b/docs/layout-schema.md index d9dceff..4d0ed24 100644 --- a/docs/layout-schema.md +++ b/docs/layout-schema.md @@ -97,7 +97,6 @@ Examples: Expansion nodes support the same common keys as standard nodes, plus plugin-specific overrides such as: - `methods` -- `container` Example: @@ -112,9 +111,12 @@ Another example with the split `datetime` widget: ```yaml - field__starts_at: ref: starts_at - container: grid params: - columns: 2 + container: + methods: grid + params: + columns: 2 + classes: gap-2 classes: col-span-12 gap-2 ``` @@ -122,8 +124,8 @@ In that case: - `field__starts_at` selects the field expansion callback - `ref` names the logical component ref exposed at runtime -- `container` overrides the wrapper component used for the split `date_input + time_input` -- `params` and `classes` apply to that wrapper container +- `params.container` configures the internal wrapper component used by `datetime_input` +- node-level `classes` still apply to the `datetime_input` component itself For `pydantic` field expansions: @@ -198,7 +200,7 @@ The shipped schema is strong at validating: - one-entry-per-node objects - standard vs expansion node shapes - the presence and type of `params`, `props`, `classes`, `ref`, `children`, and `context` -- plugin override keys currently used in layouts such as `methods` and `container` +- plugin override keys currently used in layouts such as `methods` ## What The Schema Does Not Fully Validate @@ -207,7 +209,8 @@ Some things are outside the reach of a practical static schema: - whether `card.tight` or another method chain exists in NiceGUI - whether a specific `field__name` actually exists on your model - whether a plugin accepts a given `methods` override such as `email` or `textarea` -- whether a `container` override is meaningful for a given field/plugin +- whether a `params.container` override is meaningful for a given field/plugin +- whether a specialized component such as `datetime_input` imposes additional runtime invariants on that container config - semantic correctness of runtime context expressions in `_...` or `$module:function` strings So the schema should be treated as a strong structural guardrail, not as a complete semantic type system. diff --git a/docs/plugin.md b/docs/plugin.md index d27c11b..91dd056 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -168,15 +168,17 @@ Its code is organized under: The YAML file is plugin-local on purpose, so the plugin stays self-contained. One useful pattern in the `pydantic` plugin is the `datetime` split field. -That split `date_input + time_input` behavior is now centralized in the core so multiple plugins can reuse the same widget pattern while still treating it as one logical value. -The `pydantic` resolver keeps those inputs grouped as one logical field while still allowing the layout node to override the wrapper container. +That behavior is now centralized in the core as a dedicated `datetime_input` component so multiple plugins can reuse the same widget while still treating it as one logical value. +The `pydantic` resolver keeps the internal date/time inputs grouped as one logical field while still allowing the layout node to override the wrapper container. Example: ```yaml - field__starts_at: - container: column - classes: gap-2 + params: + container: + methods: column + classes: gap-2 ``` This means the plugin can provide a rich field expansion without forcing a single visual wrapper such as `row`. diff --git a/schemas/layout.schema.json b/schemas/layout.schema.json index 7aed245..d3b0d0c 100644 --- a/schemas/layout.schema.json +++ b/schemas/layout.schema.json @@ -62,6 +62,27 @@ "type": "object", "description": "Keyword arguments passed to the resolved NiceGUI method.", "default": {}, + "properties": { + "container": { + "type": "object", + "description": "Structured container configuration used by specialized components such as datetime_input.", + "properties": { + "methods": { + "type": "string" + }, + "params": { + "$ref": "#/$defs/paramsObject" + }, + "classes": { + "type": "string" + }, + "props": { + "type": "string" + } + }, + "additionalProperties": false + } + }, "additionalProperties": { "$ref": "#/$defs/jsonValue" } @@ -130,10 +151,6 @@ "methods": { "type": "string", "description": "Plugin-specific method or variant override, for example on field__... nodes." - }, - "container": { - "type": "string", - "description": "Plugin-specific container override, currently used by split datetime field expansion." } }, "additionalProperties": false diff --git a/src/nicegui_builder/core/datetime_inputs.py b/src/nicegui_builder/core/datetime_inputs.py index 718d44c..fd7676e 100644 --- a/src/nicegui_builder/core/datetime_inputs.py +++ b/src/nicegui_builder/core/datetime_inputs.py @@ -3,115 +3,321 @@ from typing import Callable from nicegui import ui +from nicegui.elements.mixins.value_element import ValueElement +from .context import builder_ctx, component_refs, ensure_builder_runtime -def _coerce_datetime_like(value): - if hasattr(value, "to_pydatetime"): - return value.to_pydatetime() - return value +class DateTimeInput(ValueElement): + DEFAULT_CONTAINER = { + "methods": "row", + "params": {}, + "classes": "items-end gap-2", + "props": "", + } + DEFAULT_DATE_OPTIONS = { + "label": "Date", + "props": "clearable", + "classes": "", + } + DEFAULT_TIME_OPTIONS = { + "label": "Time", + "props": "clearable", + "classes": "", + } -def _parse_datetime_string(value: str): - normalized = value.replace("Z", "+00:00") - try: - return datetime.fromisoformat(normalized) - except ValueError: - return None - - -def _time_to_string(value) -> str: - if not isinstance(value, time_type): - return str(value) - - if value.microsecond: - return value.isoformat(timespec="microseconds") - if value.second: - return value.isoformat(timespec="seconds") - return value.isoformat(timespec="minutes") - - -def split_datetime_value(value) -> tuple[str | None, str | None]: - if value in (None, ""): - return (None, None) - - value = _coerce_datetime_like(value) - - if isinstance(value, datetime): - return (value.date().isoformat(), _time_to_string(value.time())) - - if isinstance(value, date_type) and not isinstance(value, datetime): - return (value.isoformat(), None) + # Value helpers + @staticmethod + def coerce_datetime_like(value): + if hasattr(value, "to_pydatetime"): + return value.to_pydatetime() + return value - if isinstance(value, str): - text = value.strip() - if not text: + @staticmethod + def parse_datetime_string(value: str): + normalized = value.replace("Z", "+00:00") + try: + return datetime.fromisoformat(normalized) + except ValueError: + return None + + @staticmethod + def time_to_string(value) -> str: + if not isinstance(value, time_type): + return str(value) + + if value.microsecond: + return value.isoformat(timespec="microseconds") + if value.second: + return value.isoformat(timespec="seconds") + return value.isoformat(timespec="minutes") + + @classmethod + def split_value(cls, value) -> tuple[str | None, str | None]: + if value in (None, ""): return (None, None) - parsed = _parse_datetime_string(text) - if parsed is None: - if "T" in text: - date_value, time_value = text.split("T", 1) - return (date_value or None, time_value or None) - if " " in text: - date_value, time_value = text.split(" ", 1) - return (date_value or None, time_value or None) - return (text, None) - - return (parsed.date().isoformat(), _time_to_string(parsed.time())) - - return (str(value), None) - - -def combine_datetime_value(date_value, time_value): - if date_value in (None, "") and time_value in (None, ""): - return None - if date_value in (None, ""): - return time_value - if time_value in (None, ""): - return date_value - return f"{date_value}T{time_value}" - - -def normalize_datetime_input(raw_value): - if raw_value in (None, ""): - return raw_value - raw_value = _coerce_datetime_like(raw_value) - if isinstance(raw_value, datetime): + value = cls.coerce_datetime_like(value) + + if isinstance(value, datetime): + return (value.date().isoformat(), cls.time_to_string(value.time())) + + if isinstance(value, date_type) and not isinstance(value, datetime): + return (value.isoformat(), None) + + if isinstance(value, str): + text = value.strip() + if not text: + return (None, None) + + parsed = cls.parse_datetime_string(text) + if parsed is None: + if "T" in text: + date_value, time_value = text.split("T", 1) + return (date_value or None, time_value or None) + if " " in text: + date_value, time_value = text.split(" ", 1) + return (date_value or None, time_value or None) + return (text, None) + + return (parsed.date().isoformat(), cls.time_to_string(parsed.time())) + + return (str(value), None) + + @staticmethod + def combine_value(date_value, time_value): + if date_value in (None, "") and time_value in (None, ""): + return None + if date_value in (None, ""): + return time_value + if time_value in (None, ""): + return date_value + return f"{date_value}T{time_value}" + + @classmethod + def normalize_value(cls, raw_value): + if raw_value in (None, ""): + return raw_value + raw_value = cls.coerce_datetime_like(raw_value) + if isinstance(raw_value, datetime): + return raw_value + if isinstance(raw_value, date_type): + return datetime.combine(raw_value, datetime.min.time()) + if isinstance(raw_value, str): + parsed = cls.parse_datetime_string(raw_value) + if parsed is not None: + return parsed + return raw_value return raw_value - if isinstance(raw_value, date_type): - return datetime.combine(raw_value, datetime.min.time()) - if isinstance(raw_value, str): - parsed = _parse_datetime_string(raw_value) - if parsed is not None: - return parsed - return raw_value - return raw_value - -def datetime_to_input_value(value) -> str: - date_value, time_value = split_datetime_value(value) - return combine_datetime_value(date_value, time_value) or "" + @classmethod + def format_for_display(cls, value) -> str: + normalized = cls.normalize_value(value) + if not isinstance(normalized, datetime): + return str(value) - -def format_datetime_for_display(value) -> str: - normalized = normalize_datetime_input(value) - if not isinstance(normalized, datetime): - return str(value) - - try: - current_locale = locale.setlocale(locale.LC_TIME) - locale.setlocale(locale.LC_TIME, "") try: - formatted = normalized.strftime("%x %X").strip() + current_locale = locale.setlocale(locale.LC_TIME) + locale.setlocale(locale.LC_TIME, "") + try: + formatted = normalized.strftime("%x %X").strip() + finally: + locale.setlocale(locale.LC_TIME, current_locale) + if formatted: + return formatted + except locale.Error: + pass + + return normalized.strftime("%Y-%m-%d %H:%M") + + # Config normalization + @staticmethod + def normalize_container( + container: dict | None, + ) -> dict: + if container and container.get("children"): + raise ValueError("datetime_input container does not support nested children") + + normalized = dict(DateTimeInput.DEFAULT_CONTAINER) + normalized["params"] = dict(DateTimeInput.DEFAULT_CONTAINER["params"]) + if container: + normalized.update({k: v for k, v in container.items() if k != "params"}) + if "params" in container: + normalized["params"] = dict(container.get("params") or {}) + return normalized + + @classmethod + def normalize_part_options( + cls, + options: dict | None, + *, + defaults: dict[str, str], + ) -> dict[str, str]: + normalized = dict(defaults) + normalized.update(dict(options or {})) + return normalized + + # Builder layout helpers + def _internal_ref(self, part: str) -> str: + return f"__datetime_input:{id(self)}:{part}" + + def _part_node(self, *, methods: str, ref: str, value, options: dict[str, str]) -> dict: + return { + methods: { + "ref": ref, + "params": { + "value": value, + "label": options["label"], + }, + "classes": options["classes"], + "props": options["props"], + } + } + + def _layout( + self, + *, + container_config: dict, + date_value, + time_value, + date_options: dict[str, str], + time_options: dict[str, str], + ) -> list[dict]: + container_ref = self._internal_ref("container") + date_ref = self.date_ref or self._internal_ref("date") + time_ref = self.time_ref or self._internal_ref("time") + + return [ + { + container_config["methods"]: { + "ref": container_ref, + "params": dict(container_config["params"]), + "classes": container_config["classes"], + "props": container_config["props"], + "children": [ + self._part_node( + methods="date_input", + ref=date_ref, + value=date_value, + options=date_options, + ), + self._part_node( + methods="time_input", + ref=time_ref, + value=time_value, + options=time_options, + ), + ], + } + } + ] + + def _assign_built_parts(self, refs: dict) -> None: + container_ref = self._internal_ref("container") + date_ref = self.date_ref or self._internal_ref("date") + time_ref = self.time_ref or self._internal_ref("time") + + self.container = refs.pop(container_ref) + self.date = refs[date_ref] + self.time = refs[time_ref] + + if self.date_ref is None: + refs.pop(date_ref, None) + if self.time_ref is None: + refs.pop(time_ref, None) + + # Runtime lifecycle + def _build_with_builder( + self, + *, + container_config: dict, + date_value, + time_value, + date_options: dict[str, str], + time_options: dict[str, str], + ) -> None: + from ..builder import visit + + ctx = dict(builder_ctx.get()) + runtime = ensure_builder_runtime(ctx) + previous_root = runtime.get("root_component") + if previous_root is None: + runtime["root_component"] = self + + token = builder_ctx.set(ctx) + try: + with self: + visit( + self._layout( + container_config=container_config, + date_value=date_value, + time_value=time_value, + date_options=date_options, + time_options=time_options, + ) + ) + self._assign_built_parts(component_refs(ctx)) finally: - locale.setlocale(locale.LC_TIME, current_locale) - if formatted: - return formatted - except locale.Error: - pass - - return normalized.strftime("%Y-%m-%d %H:%M") - + builder_ctx.reset(token) + + def _wire_events(self) -> None: + self.date.on_value_change(lambda _event: self._emit_change()) + self.time.on_value_change(lambda _event: self._emit_change()) + + def __init__( + self, + *, + value=None, + on_value_change: Callable | None = None, + container: dict | None = None, + date_ref: str | None = None, + time_ref: str | None = None, + date_options: dict | None = None, + time_options: dict | None = None, + ) -> None: + normalized = self.normalize_value(value) + self.container = None + self.date = None + self.time = None + self.date_ref = date_ref + self.time_ref = time_ref + super().__init__(tag="div", value=normalized, on_value_change=on_value_change) + + date_value, time_value = self.split_value(normalized) + container_config = self.normalize_container(container) + date_config = self.normalize_part_options( + date_options, + defaults=self.DEFAULT_DATE_OPTIONS, + ) + time_config = self.normalize_part_options( + time_options, + defaults=self.DEFAULT_TIME_OPTIONS, + ) + + self._build_with_builder( + container_config=container_config, + date_value=date_value, + time_value=time_value, + date_options=date_config, + time_options=time_config, + ) + + self._wire_events() + + def _emit_change(self) -> None: + combined = self.combine_value(self.date.value, self.time.value) + self.set_value(self.normalize_value(combined)) + + def set_value(self, value) -> None: + normalized = self.normalize_value(value) + super().set_value(normalized) + + if self.date is None or self.time is None: + return + + date_value, time_value = self.split_value(normalized) + self.date.value = date_value + self.time.value = time_value def build_split_datetime_node( *, @@ -119,92 +325,49 @@ def build_split_datetime_node( label: str, raw_value=None, ref: str | None = None, - container_methods: str = "row", - container_params: dict | None = None, - container_props: str = "", - container_classes: str = "w-full items-end gap-2", + container: dict | None = None, + component_props: str = "", + component_classes: str = "", date_ref: str | None = None, time_ref: str | None = None, - date_label: str | None = None, - time_label: str | None = None, - date_props: str = "clearable", - time_props: str = "clearable", - date_classes: str = "col", - time_classes: str = "col", + date_options: dict | None = None, + time_options: dict | None = None, ) -> dict: - date_value = None - time_value = None - if raw_value not in (None, ""): - date_value, time_value = split_datetime_value(raw_value) - logical_ref = ref or f"field:{field_name}" + normalized_container = DateTimeInput.normalize_container(container) + normalized_date_options = DateTimeInput.normalize_part_options( + date_options, + defaults={ + **DateTimeInput.DEFAULT_DATE_OPTIONS, + "label": f"{label} date", + "classes": "col", + }, + ) + normalized_time_options = DateTimeInput.normalize_part_options( + time_options, + defaults={ + **DateTimeInput.DEFAULT_TIME_OPTIONS, + "label": f"{label} time", + "classes": "col", + }, + ) return { - "methods": container_methods, + "methods": "datetime_input", "ref": logical_ref, - "params": dict(container_params or {}), - "props": container_props, - "classes": container_classes, - "children": [ - { - "date_input": { - "ref": date_ref or f"{logical_ref}:date", - "params": { - "value": date_value, - "label": date_label or f"{label} date", - }, - "props": date_props, - "classes": date_classes, - } - }, - { - "time_input": { - "ref": time_ref or f"{logical_ref}:time", - "params": { - "value": time_value, - "label": time_label or f"{label} time", - }, - "props": time_props, - "classes": time_classes, - } - }, - ], - } - - -def render_split_datetime_inputs( - *, - value, - on_change: Callable[[object], None], - date_label: str = "Date", - time_label: str = "Time", - row_classes: str = "items-end gap-2", - date_props: str = "clearable", - time_props: str = "clearable", -): - date_value, time_value = split_datetime_value(value) - state = { - "date": date_value, - "time": time_value, + "params": { + "value": raw_value, + "container": normalized_container, + "date_ref": date_ref or f"{logical_ref}:date", + "time_ref": time_ref or f"{logical_ref}:time", + "date_options": normalized_date_options, + "time_options": normalized_time_options, + }, + "props": component_props, + "classes": component_classes, + "children": [], } - with ui.row().classes(row_classes): - date_control = ui.date_input(value=date_value, label=date_label).props(date_props) - time_control = ui.time_input(value=time_value, label=time_label).props(time_props) - - def _emit(): - combined = combine_datetime_value(state["date"], state["time"]) - on_change(normalize_datetime_input(combined)) - - def _on_date_change(event): - state["date"] = event.value - _emit() - - def _on_time_change(event): - state["time"] = event.value - _emit() - - date_control.on_value_change(_on_date_change) - time_control.on_value_change(_on_time_change) - return date_control, time_control +if not hasattr(ui, "datetime_input"): + ui.datetime_input = DateTimeInput diff --git a/src/nicegui_builder/core/form.py b/src/nicegui_builder/core/form.py index 41dcaa3..c569ffb 100644 --- a/src/nicegui_builder/core/form.py +++ b/src/nicegui_builder/core/form.py @@ -9,7 +9,7 @@ from nicegui import ui from .actions import FORM_ACTION_SPECS, apply_action_intent, get_action_spec -from .datetime_inputs import _time_to_string, combine_datetime_value, split_datetime_value +from .datetime_inputs import DateTimeInput from .models import ActionSpec, FieldSpec, FormSpec from .view import ViewHandle @@ -486,14 +486,14 @@ def _field_value(self, field: FieldSpec): if self._has_split_datetime_parts(field.name): date_value = get_component_value(self._field_part_component(field.name, "date")) time_value = get_component_value(self._field_part_component(field.name, "time")) - return combine_datetime_value(date_value, time_value) + return DateTimeInput.combine_value(date_value, time_value) component = self._field_component(field) return get_component_value(component) if component else None def _set_field_value(self, field: FieldSpec, value): if self._has_split_datetime_parts(field.name): - date_value, time_value = split_datetime_value(value) + date_value, time_value = DateTimeInput.split_value(value) set_component_value(self._field_part_component(field.name, "date"), date_value) set_component_value(self._field_part_component(field.name, "time"), time_value) return value diff --git a/src/nicegui_builder/form.py b/src/nicegui_builder/form.py index 2d0f587..5c34786 100644 --- a/src/nicegui_builder/form.py +++ b/src/nicegui_builder/form.py @@ -4,7 +4,6 @@ import yaml from .builder import builder, register, builder_ctx -from .core.component_refs import DateTimeComponentRef from .core.context import component_refs, ensure_builder_runtime from .core.form import FormHandle from .core.models import FormSpec @@ -89,14 +88,18 @@ def form(source, flavor: str = ""): field_refs = dict(ctx.get("_field_refs", {})) for field in field_specs: logical_ref = field_refs.get(field.name, f"field:{field.name}") - date_ref = f"{logical_ref}:date" - time_ref = f"{logical_ref}:time" - if date_ref in refs or time_ref in refs: - refs[logical_ref] = DateTimeComponentRef( - container=refs.get(logical_ref), - date=refs.get(date_ref), - time=refs.get(time_ref), - ) + logical_component = refs.get(logical_ref) + if logical_component is None: + continue + + date_component = getattr(logical_component, "date", None) + time_component = getattr(logical_component, "time", None) + date_ref = getattr(logical_component, "date_ref", None) or f"{logical_ref}:date" + time_ref = getattr(logical_component, "time_ref", None) or f"{logical_ref}:time" + if date_component is not None: + refs[date_ref] = date_component + if time_component is not None: + refs[time_ref] = time_component if root_component is not None and hasattr(root_component, "__dict__"): root_component.component_refs = refs handle = FormHandle( diff --git a/src/nicegui_builder/plugins/pandas/plugin.py b/src/nicegui_builder/plugins/pandas/plugin.py index 7703f2f..64aa14d 100644 --- a/src/nicegui_builder/plugins/pandas/plugin.py +++ b/src/nicegui_builder/plugins/pandas/plugin.py @@ -3,10 +3,7 @@ from nicegui import ui from nicegui_builder.core.datetime_inputs import ( - datetime_to_input_value, - format_datetime_for_display, - normalize_datetime_input, - render_split_datetime_inputs, + DateTimeInput, ) from nicegui_builder.core.filter_operators import ( FILTER_OPERATORS, @@ -293,8 +290,8 @@ def _format_filter_value(field: FieldSpec, value) -> str: filter_kind = field.source_meta.get("filter_kind") if filter_kind == "datetime": if isinstance(value, (list, tuple)): - return " .. ".join(format_datetime_for_display(item) for item in value) - return format_datetime_for_display(value) + return " .. ".join(DateTimeInput.format_for_display(item) for item in value) + return DateTimeInput.format_for_display(value) if isinstance(value, (list, tuple)): return " .. ".join(str(item) for item in value) return str(value) @@ -367,18 +364,18 @@ def _render_between_value_controls(filter_kind: str, state: dict[str, object], s return with ui.column().classes("gap-2"): - render_split_datetime_inputs( + DateTimeInput( value=left_value, - on_change=lambda new_value: _set_range_item(state, state_key, 0, new_value), - date_label="From date", - time_label="From time", + on_value_change=lambda event: _set_range_item(state, state_key, 0, event.value), + date_options={"label": "From date"}, + time_options={"label": "From time"}, ) with ui.column().classes("gap-2"): - render_split_datetime_inputs( + DateTimeInput( value=right_value, - on_change=lambda new_value: _set_range_item(state, state_key, 1, new_value), - date_label="To date", - time_label="To time", + on_value_change=lambda event: _set_range_item(state, state_key, 1, event.value), + date_options={"label": "To date"}, + time_options={"label": "To time"}, ) @@ -412,11 +409,9 @@ def _render_single_value_control(field: FieldSpec, operator: str, state: dict[st return if filter_kind == "datetime": - render_split_datetime_inputs( + DateTimeInput( value=state.get(state_key, ""), - on_change=lambda new_value: state.__setitem__(state_key, new_value), - date_label="Date", - time_label="Time", + on_value_change=lambda event: state.__setitem__(state_key, event.value), ) return diff --git a/src/nicegui_builder/plugins/pydantic/resolve.py b/src/nicegui_builder/plugins/pydantic/resolve.py index 4d48ecd..6aa4887 100644 --- a/src/nicegui_builder/plugins/pydantic/resolve.py +++ b/src/nicegui_builder/plugins/pydantic/resolve.py @@ -1,5 +1,5 @@ from .inspect import build_field_context -from nicegui_builder.core.datetime_inputs import build_split_datetime_node +from nicegui_builder.core.datetime_inputs import DateTimeInput, build_split_datetime_node from .mapping import ( build_validation_props, get_defaults_from_map, @@ -13,35 +13,29 @@ def _build_datetime_split_node(field_ctx: dict, default_info: dict, value: dict label = field_ctx.get("attributes_title") or field_ctx["fieldname"] raw_value = field_ctx.get("fieldvalue") logical_ref = value.get("ref") or f"field:{field_ctx['fieldname']}" - container_methods = value.get("container", default_info.get("methods")) - container_params = dict(default_info.get("params", {})) - container_params.update(value.get("params") or {}) - container_classes = " ".join( - part - for part in [ - default_info.get("classes", ""), - value.get("classes", ""), - ] - if part - ) - container_props = " ".join( - part - for part in [ - default_info.get("props", ""), - value.get("props", ""), - ] - if part - ) + raw_params = dict(value.get("params") or {}) + raw_container = raw_params.pop("container", None) + container = DateTimeInput.normalize_container(raw_container) + if raw_container is None or "methods" not in raw_container: + container["methods"] = default_info.get("methods", container["methods"]) + default_params = dict(default_info.get("params", {})) + container["params"] = { + **default_params, + **dict(container.get("params") or {}), + } + if raw_container is None or "classes" not in raw_container: + container["classes"] = default_info.get("classes", container.get("classes", "")) + if raw_container is None or "props" not in raw_container: + container["props"] = default_info.get("props", container.get("props", "")) return build_split_datetime_node( field_name=field_ctx["fieldname"], label=label, raw_value=raw_value, ref=logical_ref, - container_methods=container_methods, - container_params=container_params, - container_props=container_props, - container_classes=container_classes, + container=container, + component_props=value.get("props", ""), + component_classes=value.get("classes", ""), ) diff --git a/tests/test_nicegui_builder/core/form/test_component_helpers.py b/tests/test_nicegui_builder/core/form/test_component_helpers.py index 6e67110..1cb1360 100644 --- a/tests/test_nicegui_builder/core/form/test_component_helpers.py +++ b/tests/test_nicegui_builder/core/form/test_component_helpers.py @@ -1,13 +1,11 @@ from datetime import date, datetime, time +from nicegui_builder.core.datetime_inputs import DateTimeInput from nicegui_builder.core.form import ( bind_component_event, - combine_datetime_value, clear_component_error, get_component_value, resolve_live_strategy, - split_datetime_value, - _time_to_string, set_component_color, set_component_enabled, set_component_error, @@ -250,25 +248,25 @@ def validate(self, as_model=False): def test_datetime_helpers_cover_string_parsing_and_formatting(): - assert _time_to_string("14:30") == "14:30" - assert _time_to_string(time(14, 30)) == "14:30" - assert _time_to_string(time(14, 30, 45)) == "14:30:45" - assert _time_to_string(time(14, 30, 45, 123456)) == "14:30:45.123456" - - assert split_datetime_value(None) == (None, None) - assert split_datetime_value("") == (None, None) - assert split_datetime_value(datetime(2026, 3, 21, 14, 30)) == ("2026-03-21", "14:30") - assert split_datetime_value(date(2026, 3, 21)) == ("2026-03-21", None) - assert split_datetime_value("2026-03-21T14:30") == ("2026-03-21", "14:30") - assert split_datetime_value("2026-03-21 14:30") == ("2026-03-21", "14:30") - assert split_datetime_value(" ") == (None, None) - assert split_datetime_value("2026-03-21Tparty-time") == ("2026-03-21", "party-time") - assert split_datetime_value("2026-03-21T14:30Z") == ("2026-03-21", "14:30") - assert split_datetime_value("2026-03-21 only-date") == ("2026-03-21", "only-date") - assert split_datetime_value("nonsense") == ("nonsense", None) - assert split_datetime_value(123) == ("123", None) - - assert combine_datetime_value(None, None) is None - assert combine_datetime_value(None, "14:30") == "14:30" - assert combine_datetime_value("2026-03-21", None) == "2026-03-21" - assert combine_datetime_value("2026-03-21", "14:30") == "2026-03-21T14:30" + assert DateTimeInput.time_to_string("14:30") == "14:30" + assert DateTimeInput.time_to_string(time(14, 30)) == "14:30" + assert DateTimeInput.time_to_string(time(14, 30, 45)) == "14:30:45" + assert DateTimeInput.time_to_string(time(14, 30, 45, 123456)) == "14:30:45.123456" + + assert DateTimeInput.split_value(None) == (None, None) + assert DateTimeInput.split_value("") == (None, None) + assert DateTimeInput.split_value(datetime(2026, 3, 21, 14, 30)) == ("2026-03-21", "14:30") + assert DateTimeInput.split_value(date(2026, 3, 21)) == ("2026-03-21", None) + assert DateTimeInput.split_value("2026-03-21T14:30") == ("2026-03-21", "14:30") + assert DateTimeInput.split_value("2026-03-21 14:30") == ("2026-03-21", "14:30") + assert DateTimeInput.split_value(" ") == (None, None) + assert DateTimeInput.split_value("2026-03-21Tparty-time") == ("2026-03-21", "party-time") + assert DateTimeInput.split_value("2026-03-21T14:30Z") == ("2026-03-21", "14:30") + assert DateTimeInput.split_value("2026-03-21 only-date") == ("2026-03-21", "only-date") + assert DateTimeInput.split_value("nonsense") == ("nonsense", None) + assert DateTimeInput.split_value(123) == ("123", None) + + assert DateTimeInput.combine_value(None, None) is None + assert DateTimeInput.combine_value(None, "14:30") == "14:30" + assert DateTimeInput.combine_value("2026-03-21", None) == "2026-03-21" + assert DateTimeInput.combine_value("2026-03-21", "14:30") == "2026-03-21T14:30" diff --git a/tests/test_nicegui_builder/plugins/pandas/test_plugin.py b/tests/test_nicegui_builder/plugins/pandas/test_plugin.py index 2428d4c..40e82f5 100644 --- a/tests/test_nicegui_builder/plugins/pandas/test_plugin.py +++ b/tests/test_nicegui_builder/plugins/pandas/test_plugin.py @@ -3,6 +3,7 @@ import pytest +from nicegui_builder.core.datetime_inputs import DateTimeInput from nicegui_builder.core.models import CollectionSpec, FieldSpec, TableSpec, WidgetSpec from nicegui_builder.plugins.pandas import pandas_plugin from nicegui_builder.plugins.pandas import plugin as pandas_module @@ -216,10 +217,11 @@ def test_rows_from_dataframe_serializes_datetime_values_for_ui(): def test_datetime_helpers_keep_internal_datetime_and_ui_friendly_values(): field = FieldSpec(name="starts_at", python_type="datetime", source_meta={"filter_kind": "datetime"}) - normalized = pandas_module.normalize_datetime_input("2026-03-21T10:15") + normalized = DateTimeInput.normalize_value("2026-03-21T10:15") assert isinstance(normalized, datetime) - assert pandas_module.datetime_to_input_value(normalized) == "2026-03-21T10:15" + date_value, time_value = DateTimeInput.split_value(normalized) + assert DateTimeInput.combine_value(date_value, time_value) == "2026-03-21T10:15" assert "T" not in pandas_module._format_filter_value(field, normalized) @@ -452,6 +454,7 @@ def update(self): monkeypatch.setattr(pandas_module.ui, "input", lambda **kwargs: FakeControl("input", **kwargs)) monkeypatch.setattr(pandas_module.ui, "number", lambda **kwargs: FakeControl("number", **kwargs)) monkeypatch.setattr(pandas_module.ui, "select", lambda **kwargs: FakeControl("select", **kwargs)) + monkeypatch.setattr(pandas_module, "DateTimeInput", lambda **kwargs: FakeControl("datetime_input", **kwargs)) monkeypatch.setattr(pandas_module.ui, "date_input", lambda **kwargs: FakeControl("date_input", **kwargs)) monkeypatch.setattr(pandas_module.ui, "time_input", lambda **kwargs: FakeControl("time_input", **kwargs)) monkeypatch.setattr(pandas_module.ui, "label", lambda text: FakeControl("label", text=text)) @@ -651,6 +654,7 @@ def update(self): monkeypatch.setattr(pandas_module.ui, "input", lambda **kwargs: FakeControl("input", **kwargs)) monkeypatch.setattr(pandas_module.ui, "number", lambda **kwargs: FakeControl("number", **kwargs)) monkeypatch.setattr(pandas_module.ui, "select", lambda **kwargs: FakeControl("select", **kwargs)) + monkeypatch.setattr(pandas_module, "DateTimeInput", lambda **kwargs: FakeControl("datetime_input", **kwargs)) monkeypatch.setattr(pandas_module.ui, "date_input", lambda **kwargs: FakeControl("date_input", **kwargs)) monkeypatch.setattr(pandas_module.ui, "time_input", lambda **kwargs: FakeControl("time_input", **kwargs)) monkeypatch.setattr(pandas_module.ui, "label", lambda text: FakeControl("label", text=text)) @@ -804,6 +808,7 @@ def update(self): monkeypatch.setattr(pandas_module.ui, "input", lambda **kwargs: ReactiveControl("input", **kwargs)) monkeypatch.setattr(pandas_module.ui, "number", lambda **kwargs: ReactiveControl("number", **kwargs)) monkeypatch.setattr(pandas_module.ui, "select", lambda **kwargs: ReactiveControl("select", **kwargs)) + monkeypatch.setattr(pandas_module, "DateTimeInput", lambda **kwargs: ReactiveControl("datetime_input", **kwargs)) monkeypatch.setattr(pandas_module.ui, "date_input", lambda **kwargs: ReactiveControl("date_input", **kwargs)) monkeypatch.setattr(pandas_module.ui, "time_input", lambda **kwargs: ReactiveControl("time_input", **kwargs)) monkeypatch.setattr(pandas_module.ui, "label", lambda text: ReactiveControl("label", text=text)) diff --git a/tests/test_nicegui_builder/plugins/pydantic/test_resolve.py b/tests/test_nicegui_builder/plugins/pydantic/test_resolve.py index c9c6535..ecbf12d 100644 --- a/tests/test_nicegui_builder/plugins/pydantic/test_resolve.py +++ b/tests/test_nicegui_builder/plugins/pydantic/test_resolve.py @@ -11,15 +11,15 @@ def test_pydantic_plugin_builds_split_datetime_field_node(): resolved = pydantic_plugin.resolve_field_node(DemoDateTimeModel, instance, "starts_at", {}) node = resolved["node"] - assert node["methods"] == "row" - assert len(node["children"]) == 2 - assert node["children"][0]["date_input"]["ref"] == "field:starts_at:date" - assert node["children"][1]["time_input"]["ref"] == "field:starts_at:time" - assert node["children"][0]["date_input"]["params"]["value"] == "2026-03-21" - assert node["children"][1]["time_input"]["params"]["value"] == "14:30" + assert node["methods"] == "datetime_input" + assert node["ref"] == "field:starts_at" + assert node["params"]["date_ref"] == "field:starts_at:date" + assert node["params"]["time_ref"] == "field:starts_at:time" + assert node["params"]["value"] == datetime(2026, 3, 21, 14, 30) + assert node["params"]["container"]["methods"] == "row" -def test_pydantic_plugin_allows_custom_container_for_split_datetime_field_node(): +def test_pydantic_plugin_allows_structured_container_config_for_split_datetime_field_node(): instance = DemoDateTimeModel(starts_at=datetime(2026, 3, 21, 14, 30)) resolved = pydantic_plugin.resolve_field_node( @@ -27,15 +27,24 @@ def test_pydantic_plugin_allows_custom_container_for_split_datetime_field_node() instance, "starts_at", { - "container": "grid", - "params": {"columns": 2}, + "params": { + "container": { + "methods": "grid", + "params": {"columns": 2}, + "classes": "gap-2", + "props": "bordered", + } + }, "classes": "col-span-12 gap-2", }, ) node = resolved["node"] - assert node["methods"] == "grid" - assert node["params"] == {"columns": 2} + assert node["methods"] == "datetime_input" + assert node["params"]["container"]["methods"] == "grid" + assert node["params"]["container"]["params"] == {"columns": 2} + assert node["params"]["container"]["classes"] == "gap-2" + assert node["params"]["container"]["props"] == "bordered" assert "col-span-12" in node["classes"] - assert node["children"][0]["date_input"]["ref"] == "field:starts_at:date" - assert node["children"][1]["time_input"]["ref"] == "field:starts_at:time" + assert node["params"]["date_ref"] == "field:starts_at:date" + assert node["params"]["time_ref"] == "field:starts_at:time" diff --git a/tests/test_nicegui_builder/test_form.py b/tests/test_nicegui_builder/test_form.py index 4908f85..2661431 100644 --- a/tests/test_nicegui_builder/test_form.py +++ b/tests/test_nicegui_builder/test_form.py @@ -188,6 +188,13 @@ class DummyComponent: def __init__(self): self.component_refs = {} + class FakeDateTimeInput: + def __init__(self): + self.date = object() + self.time = object() + self.date_ref = "starts_at:date" + self.time_ref = "starts_at:time" + monkeypatch.setattr(form_module.plugin_registry, "resolve", lambda source: DateTimePlugin()) monkeypatch.setattr( form_module, @@ -199,14 +206,12 @@ def fake_builder(layout): ctx = form_module.builder_ctx.get() refs = form_module.component_refs(ctx) ctx["_field_refs"]["starts_at"] = "starts_at" - refs["starts_at"] = object() - refs["starts_at:date"] = object() - refs["starts_at:time"] = object() + refs["starts_at"] = FakeDateTimeInput() return DummyComponent() monkeypatch.setattr(form_module, "builder", fake_builder) handle = form_module.form(type("Demo", (), {})) - assert handle.component_refs["starts_at"].date is handle.component_refs["starts_at:date"] - assert handle.component_refs["starts_at"].time is handle.component_refs["starts_at:time"] + assert handle.component_refs["starts_at:date"] is handle.component_refs["starts_at"].date + assert handle.component_refs["starts_at:time"] is handle.component_refs["starts_at"].time From 5de1f6910b80cdd60835bcf85d4a5aa087d35f36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Fournier?= Date: Thu, 26 Mar 2026 01:20:22 -0400 Subject: [PATCH 7/9] feat: extend nicegui ui with builder helpers --- README.md | 26 ++++ docs/architecture.md | 17 +- docs/public-api.md | 10 ++ pyproject.toml | 2 + src/nicegui_builder/__init__.py | 13 ++ src/nicegui_builder/__init__.pyi | 35 +++++ src/nicegui_builder/py.typed | 1 + .../plugins/test_registry.py | 15 ++ typings/nicegui/__init__.pyi | 1 + typings/nicegui/ui.pyi | 145 ++++++++++++++++++ 10 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 src/nicegui_builder/__init__.pyi create mode 100644 src/nicegui_builder/py.typed create mode 100644 typings/nicegui/__init__.pyi create mode 100644 typings/nicegui/ui.pyi diff --git a/README.md b/README.md index 2a94b0c..95a4941 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,32 @@ It currently provides three main entry points: - `form(source, flavor="")` for plugin-driven forms - `table(source, variant="std")` for plugin-driven tabular views +When `nicegui_builder` is imported, it also attaches those entry points to NiceGUI's `ui` object at runtime: + +- `ui.builder(...)` +- `ui.form(...)` +- `ui.table(...)` + +This is intentionally a runtime patch of the NiceGUI `ui` object rather than an officially supported NiceGUI extension point. +It works well in practice, but it should be understood as a convenience layer provided by `nicegui-builder`, not as a contract owned by NiceGUI itself. + +If you want editor completion for those extra methods without modifying NiceGUI itself, prefer importing `ui` from `nicegui_builder`: + +```python +from nicegui_builder import ui +``` + +That exported `ui` is the same runtime object as `nicegui.ui`, but `nicegui_builder` ships typing metadata for the added methods. + +For this workspace, VS Code/Pylance can also enrich `from nicegui import ui` directly through the local stub path configured in [`.vscode/settings.json`](.vscode/settings.json). +That setup uses [`typings/nicegui/ui.pyi`](typings/nicegui/ui.pyi) to expose `ui.builder(...)`, `ui.form(...)`, and `ui.table(...)` to code completion without modifying NiceGUI itself. + +Patch alert: + +- the runtime methods are attached dynamically by `nicegui_builder` +- the editor completion for `from nicegui import ui` comes from local workspace stubs, not from NiceGUI upstream +- outside a workspace that loads those stubs, code completion may fall back to whatever the editor infers from the installed NiceGUI package alone + ## Install Base install: diff --git a/docs/architecture.md b/docs/architecture.md index 5e07188..f096542 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -356,7 +356,7 @@ Examples of notable behavior: - automatic form layouts - widget mapping from `pydantic-nicegui.yml` -- split `datetime` handling as `date + time` +- split `datetime` handling through the shared `datetime_input` component - automatic field refs in `component_refs`, including composite logical refs for split `datetime` ### `pandas` @@ -384,6 +384,21 @@ Examples of notable behavior: - Separate pure state from UI-bound behavior when possible. - Keep the top-level API stable even while internals evolve. +## Patchy Integrations + +Some conveniences in the project are intentionally implemented as thin patches rather than as first-class upstream extension points. + +Current examples: + +- `nicegui_builder` attaches `builder`, `form`, and `table` to NiceGUI's runtime `ui` object +- the workspace can provide editor completion for those added methods through local stubs in [`typings/nicegui/ui.pyi`](../typings/nicegui/ui.pyi) + +These integrations are useful and deliberate, but they should still be understood as package-owned glue: + +- runtime behavior is provided by `nicegui-builder` +- editor behavior depends on the local typing setup +- neither mechanism implies that NiceGUI itself natively declares those methods + ## Practical Reading Order If someone is new to the project, the best order is: diff --git a/docs/public-api.md b/docs/public-api.md index 66db64f..6b7efcf 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -39,6 +39,15 @@ Examples: - `form(...)` returns a `FormHandle` with `component_refs` and `get_component(ref)` - `table(...)` returns a `TableHandle` with the documented handle helpers +The package also re-exports `ui` and patches the imported NiceGUI `ui` object at runtime with: + +- `ui.builder(...)` +- `ui.form(...)` +- `ui.table(...)` + +That behavior is supported by `nicegui-builder`, but it is still a patch-style integration layered on top of NiceGUI's own `ui` module. +It should be treated as a convenience contract of this package, not as an upstream NiceGUI guarantee. + ## Advanced / Experimental The following namespaces remain available for advanced use, but are not yet treated as stable contracts: @@ -57,3 +66,4 @@ Examples: - `nicegui_builder.plugins.pydantic.inspect` - `nicegui_builder.plugins.pydantic.mapping` - `nicegui_builder.plugins.pydantic.resolve` +- workspace editor stub wiring such as [`typings/nicegui/ui.pyi`](../typings/nicegui/ui.pyi) diff --git a/pyproject.toml b/pyproject.toml index c936961..15a858b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,8 @@ include-package-data = true [tool.setuptools.package-data] nicegui_builder = [ + "py.typed", + "*.pyi", "examples/*.yml", "examples/*.yaml", "plugins/pydantic/*.yml", diff --git a/src/nicegui_builder/__init__.py b/src/nicegui_builder/__init__.py index cf9c513..e217952 100644 --- a/src/nicegui_builder/__init__.py +++ b/src/nicegui_builder/__init__.py @@ -1,3 +1,5 @@ +from nicegui import ui + from .builder import builder from .core import ( ActionSpec, @@ -16,7 +18,17 @@ from .form import form from .table import table + +def _attach_to_ui() -> None: + ui.builder = builder + ui.form = form + ui.table = table + + +_attach_to_ui() + STABLE_API = ( + "ui", "builder", "form", "table", @@ -40,6 +52,7 @@ ) __all__ = [ + "ui", "builder", "ActionSpec", "form", diff --git a/src/nicegui_builder/__init__.pyi b/src/nicegui_builder/__init__.pyi new file mode 100644 index 0000000..73c0ca1 --- /dev/null +++ b/src/nicegui_builder/__init__.pyi @@ -0,0 +1,35 @@ +from typing import Any, Protocol + +from .core import ( + ActionSpec, + FormHandle, + FormSpec, + FormState, + FormValidationResult, + LiveBadgeBinding, + LiveBinding, + LiveButtonBinding, + LivePanelBinding, + TableHandle, + TableSpec, + ViewHandle, +) + + +class _ExtendedUI(Protocol): + def builder(self, layout: Any) -> Any: ... + def form(self, source: Any, flavor: str = "") -> Any: ... + def table(self, source: Any, variant: str = "std") -> Any: ... + def __getattr__(self, name: str) -> Any: ... + + +ui: _ExtendedUI + +def builder(layout: Any) -> Any: ... +def form(source: Any, flavor: str = "") -> Any: ... +def table(source: Any, variant: str = "std") -> Any: ... + +STABLE_API: tuple[str, ...] +EXPERIMENTAL_NAMESPACES: tuple[str, ...] + +__all__: list[str] diff --git a/src/nicegui_builder/py.typed b/src/nicegui_builder/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/nicegui_builder/py.typed @@ -0,0 +1 @@ + diff --git a/tests/test_nicegui_builder/plugins/test_registry.py b/tests/test_nicegui_builder/plugins/test_registry.py index af976ac..ee840dc 100644 --- a/tests/test_nicegui_builder/plugins/test_registry.py +++ b/tests/test_nicegui_builder/plugins/test_registry.py @@ -1,5 +1,7 @@ import pytest +from nicegui import ui +import nicegui_builder from nicegui_builder.plugins import plugin_registry import nicegui_builder.plugins as plugins_module from nicegui_builder.plugins.registry import PluginRegistry @@ -76,3 +78,16 @@ def fake_import(name): "nicegui_builder.plugins.pydantic", "nicegui_builder.plugins.pandas", ] + + +def test_package_attaches_builder_form_and_table_to_ui(): + assert ui.builder is nicegui_builder.builder + assert ui.form is nicegui_builder.form + assert ui.table is nicegui_builder.table + + +def test_package_exports_nicegui_ui_with_builder_extensions(): + assert nicegui_builder.ui is ui + assert nicegui_builder.ui.builder is nicegui_builder.builder + assert nicegui_builder.ui.form is nicegui_builder.form + assert nicegui_builder.ui.table is nicegui_builder.table diff --git a/typings/nicegui/__init__.pyi b/typings/nicegui/__init__.pyi new file mode 100644 index 0000000..d2a6768 --- /dev/null +++ b/typings/nicegui/__init__.pyi @@ -0,0 +1 @@ +from . import ui as ui diff --git a/typings/nicegui/ui.pyi b/typings/nicegui/ui.pyi new file mode 100644 index 0000000..4c4670f --- /dev/null +++ b/typings/nicegui/ui.pyi @@ -0,0 +1,145 @@ +from typing import Any + +add_body_html: Any +add_css: Any +add_head_html: Any +add_sass: Any +add_scss: Any +aggrid: Any +altair: Any +anywidget: Any +audio: Any +avatar: Any +badge: Any +button: Any +button_group: Any +card: Any +card_actions: Any +card_section: Any +carousel: Any +carousel_slide: Any +chat_message: Any +checkbox: Any +chip: Any +circular_progress: Any +clipboard: Any +code: Any +codemirror: Any +color_input: Any +color_picker: Any +colors: Any +column: Any +context: Any +context_menu: Any +dark_mode: Any +date: Any +date_input: Any +dialog: Any +download: Any +drawer: Any +dropdown_button: Any +echart: Any +editor: Any +element: Any +expansion: Any +fab: Any +fab_action: Any +footer: Any +fullscreen: Any +grid: Any +header: Any +highchart: Any +html: Any +icon: Any +image: Any +input: Any +input_chips: Any +interactive_image: Any +item: Any +item_label: Any +item_section: Any +joystick: Any +json_editor: Any +keyboard: Any +knob: Any +label: Any +leaflet: Any +left_drawer: Any +line_plot: Any +linear_progress: Any +link: Any +link_target: Any +list: Any +log: Any +markdown: Any +matplotlib: Any +menu: Any +menu_item: Any +mermaid: Any +navigate: Any +notification: Any +notify: Any +number: Any +on: Any +on_exception: Any +page: Any +page_scroller: Any +page_sticky: Any +page_title: Any +pagination: Any +plotly: Any +pyplot: Any +query: Any +radio: Any +range: Any +rating: Any +refreshable: Any +refreshable_method: Any +restructured_text: Any +right_drawer: Any +row: Any +run: Any +run_javascript: Any +run_with: Any +scene: Any +scene_view: Any +scroll_area: Any +select: Any +separator: Any +skeleton: Any +slide_item: Any +slider: Any +space: Any +spinner: Any +splitter: Any +state: Any +step: Any +stepper: Any +stepper_navigation: Any +sub_pages: Any +switch: Any +tab: Any +tab_panel: Any +tab_panels: Any +table: Any +tabs: Any +teleport: Any +textarea: Any +time: Any +time_input: Any +timeline: Any +timeline_entry: Any +timer: Any +toggle: Any +tooltip: Any +tree: Any +update: Any +upload: Any +video: Any +xterm: Any + +def builder(layout: Any) -> Any: ... +def form(source: Any, flavor: str = "") -> Any: ... +def table(source: Any, variant: str = "std") -> Any: ... + +__all__: list[str] From 021a29fef6270a7012b60db10bdd256225205cc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Fournier?= Date: Thu, 26 Mar 2026 21:46:33 -0400 Subject: [PATCH 8/9] fix: avoid ui table name collisions --- README.md | 6 +++--- docs/public-api.md | 4 ++-- src/nicegui_builder/__init__.py | 4 ++-- src/nicegui_builder/__init__.pyi | 4 ++-- tests/test_nicegui_builder/plugins/test_registry.py | 10 +++++----- typings/nicegui/ui.pyi | 4 ++-- 6 files changed, 16 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 95a4941..534df2a 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,8 @@ It currently provides three main entry points: When `nicegui_builder` is imported, it also attaches those entry points to NiceGUI's `ui` object at runtime: - `ui.builder(...)` -- `ui.form(...)` -- `ui.table(...)` +- `ui.form_builder(...)` +- `ui.table_builder(...)` This is intentionally a runtime patch of the NiceGUI `ui` object rather than an officially supported NiceGUI extension point. It works well in practice, but it should be understood as a convenience layer provided by `nicegui-builder`, not as a contract owned by NiceGUI itself. @@ -39,7 +39,7 @@ from nicegui_builder import ui That exported `ui` is the same runtime object as `nicegui.ui`, but `nicegui_builder` ships typing metadata for the added methods. For this workspace, VS Code/Pylance can also enrich `from nicegui import ui` directly through the local stub path configured in [`.vscode/settings.json`](.vscode/settings.json). -That setup uses [`typings/nicegui/ui.pyi`](typings/nicegui/ui.pyi) to expose `ui.builder(...)`, `ui.form(...)`, and `ui.table(...)` to code completion without modifying NiceGUI itself. +That setup uses [`typings/nicegui/ui.pyi`](typings/nicegui/ui.pyi) to expose `ui.builder(...)`, `ui.form_builder(...)`, and `ui.table_builder(...)` to code completion without modifying NiceGUI itself. Patch alert: diff --git a/docs/public-api.md b/docs/public-api.md index 6b7efcf..9ba975a 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -42,8 +42,8 @@ Examples: The package also re-exports `ui` and patches the imported NiceGUI `ui` object at runtime with: - `ui.builder(...)` -- `ui.form(...)` -- `ui.table(...)` +- `ui.form_builder(...)` +- `ui.table_builder(...)` That behavior is supported by `nicegui-builder`, but it is still a patch-style integration layered on top of NiceGUI's own `ui` module. It should be treated as a convenience contract of this package, not as an upstream NiceGUI guarantee. diff --git a/src/nicegui_builder/__init__.py b/src/nicegui_builder/__init__.py index e217952..66dcd1f 100644 --- a/src/nicegui_builder/__init__.py +++ b/src/nicegui_builder/__init__.py @@ -21,8 +21,8 @@ def _attach_to_ui() -> None: ui.builder = builder - ui.form = form - ui.table = table + ui.form_builder = form + ui.table_builder = table _attach_to_ui() diff --git a/src/nicegui_builder/__init__.pyi b/src/nicegui_builder/__init__.pyi index 73c0ca1..9ef5e1c 100644 --- a/src/nicegui_builder/__init__.pyi +++ b/src/nicegui_builder/__init__.pyi @@ -18,8 +18,8 @@ from .core import ( class _ExtendedUI(Protocol): def builder(self, layout: Any) -> Any: ... - def form(self, source: Any, flavor: str = "") -> Any: ... - def table(self, source: Any, variant: str = "std") -> Any: ... + def form_builder(self, source: Any, flavor: str = "") -> Any: ... + def table_builder(self, source: Any, variant: str = "std") -> Any: ... def __getattr__(self, name: str) -> Any: ... diff --git a/tests/test_nicegui_builder/plugins/test_registry.py b/tests/test_nicegui_builder/plugins/test_registry.py index ee840dc..7564c3b 100644 --- a/tests/test_nicegui_builder/plugins/test_registry.py +++ b/tests/test_nicegui_builder/plugins/test_registry.py @@ -80,14 +80,14 @@ def fake_import(name): ] -def test_package_attaches_builder_form_and_table_to_ui(): +def test_package_attaches_builder_form_and_table_builders_to_ui(): assert ui.builder is nicegui_builder.builder - assert ui.form is nicegui_builder.form - assert ui.table is nicegui_builder.table + assert ui.form_builder is nicegui_builder.form + assert ui.table_builder is nicegui_builder.table def test_package_exports_nicegui_ui_with_builder_extensions(): assert nicegui_builder.ui is ui assert nicegui_builder.ui.builder is nicegui_builder.builder - assert nicegui_builder.ui.form is nicegui_builder.form - assert nicegui_builder.ui.table is nicegui_builder.table + assert nicegui_builder.ui.form_builder is nicegui_builder.form + assert nicegui_builder.ui.table_builder is nicegui_builder.table diff --git a/typings/nicegui/ui.pyi b/typings/nicegui/ui.pyi index 4c4670f..43bece2 100644 --- a/typings/nicegui/ui.pyi +++ b/typings/nicegui/ui.pyi @@ -139,7 +139,7 @@ video: Any xterm: Any def builder(layout: Any) -> Any: ... -def form(source: Any, flavor: str = "") -> Any: ... -def table(source: Any, variant: str = "std") -> Any: ... +def form_builder(source: Any, flavor: str = "") -> Any: ... +def table_builder(source: Any, variant: str = "std") -> Any: ... __all__: list[str] From 84d440f0aa038d22f64f73e00716dcb15083eb5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Fournier?= Date: Thu, 26 Mar 2026 21:54:24 -0400 Subject: [PATCH 9/9] build: remove docs dependency extra --- .github/workflows/ci.yml | 2 +- MANIFEST.in | 1 - pyproject.toml | 3 +-- requirements-docs.txt | 1 - 4 files changed, 2 insertions(+), 5 deletions(-) delete mode 100644 requirements-docs.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb9a366..4fc72e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install -e ".[dev,pandas]" + python -m pip install -e ".[all]" - name: Run tests run: python -m pytest tests diff --git a/MANIFEST.in b/MANIFEST.in index e7ad736..5d9af66 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,6 +2,5 @@ include README.md include LICENSE include requirements.txt include requirements-dev.txt -include requirements-docs.txt include requirements-pandas.txt recursive-include src *.yml *.yaml diff --git a/pyproject.toml b/pyproject.toml index 15a858b..0557795 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,10 +54,9 @@ dependencies = { file = ["requirements.txt"] } [tool.setuptools.dynamic.optional-dependencies] dev = { file = ["requirements-dev.txt"] } -docs = { file = ["requirements-docs.txt"] } pydantic = { file = ["requirements-pydantic.txt"] } pandas = { file = ["requirements-pandas.txt"] } -all = { file = ["requirements-dev.txt", "requirements-docs.txt", "requirements-pydantic.txt", "requirements-pandas.txt"] } +all = { file = ["requirements-dev.txt", "requirements-pydantic.txt", "requirements-pandas.txt"] } [tool.pytest.ini_options] addopts = "--basetemp=.pytest_tmp" diff --git a/requirements-docs.txt b/requirements-docs.txt deleted file mode 100644 index 8b13789..0000000 --- a/requirements-docs.txt +++ /dev/null @@ -1 +0,0 @@ -