diff --git a/.github/workflows/ap-ui-fixture-drift.yml b/.github/workflows/ap-ui-fixture-drift.yml new file mode 100644 index 00000000..61144c71 --- /dev/null +++ b/.github/workflows/ap-ui-fixture-drift.yml @@ -0,0 +1,51 @@ +name: ap-ui Fixture Drift + +on: + pull_request: + paths: + - "packages/ap-ui/tests/fixtures/ui_html_*_parity.json" + workflow_dispatch: + +permissions: + contents: read + +jobs: + fixture-drift: + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false }} + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout alliance-platform-py + uses: actions/checkout@v4 + + - name: Checkout alliance-platform-js + uses: actions/checkout@v4 + with: + repository: AllianceSoftware/alliance-platform-js + path: alliance-platform-js + ref: main + ssh-key: ${{ secrets.ALLIANCE_PLATFORM_JS_DEPLOY_KEY }} + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: yarn + cache-dependency-path: alliance-platform-js/yarn.lock + + - name: Install JS dependencies + working-directory: alliance-platform-js + run: yarn install --frozen-lockfile + + - name: Regenerate ap-ui parity fixtures + run: | + ./packages/ap-ui/scripts/syncHtmlUiParityFixtures.sh \ + --js-repo "$GITHUB_WORKSPACE/alliance-platform-js" + + - name: Check fixture drift + run: | + git diff --exit-code -- \ + packages/ap-ui/tests/fixtures/ui_html_button_parity.json \ + packages/ap-ui/tests/fixtures/ui_html_button_group_parity.json diff --git a/Justfile b/Justfile index ac66195f..b19b6363 100644 --- a/Justfile +++ b/Justfile @@ -190,6 +190,28 @@ format-check: manage package *args: cd packages/{{package}} && uv run ./manage.py {{args}} +# Regenerate ap-ui HTML parity fixtures using the alliance-platform-js runtime. +# Usage: +# just sync-html-ui-parity-fixtures +# just sync-html-ui-parity-fixtures ../alliance-platform-js button_group +sync-html-ui-parity-fixtures js_repo="../alliance-platform-js" component="": + #!/usr/bin/env bash + set -euo pipefail + if [[ -n "{{component}}" ]]; then + ./packages/ap-ui/scripts/syncHtmlUiParityFixtures.sh --js-repo "{{js_repo}}" --component "{{component}}" + else + ./packages/ap-ui/scripts/syncHtmlUiParityFixtures.sh --js-repo "{{js_repo}}" + fi + +# Check ap-ui HTML parity fixtures for drift. +check-html-ui-parity-fixtures js_repo="../alliance-platform-js": + #!/usr/bin/env bash + set -euo pipefail + ./packages/ap-ui/scripts/syncHtmlUiParityFixtures.sh --js-repo "{{js_repo}}" + git diff --exit-code -- \ + packages/ap-ui/tests/fixtures/ui_html_button_parity.json \ + packages/ap-ui/tests/fixtures/ui_html_button_group_parity.json + # Build docs and watch for changes docs-watch: ./scripts/build-docs.sh diff --git a/packages/ap-ui/DEVELOPMENT.md b/packages/ap-ui/DEVELOPMENT.md new file mode 100644 index 00000000..16558aff --- /dev/null +++ b/packages/ap-ui/DEVELOPMENT.md @@ -0,0 +1,57 @@ +# ap-ui Development Notes + +This file contains maintainer-focused workflow notes for developing `alliance-platform-py/packages/ap-ui`. + +## Adding a new HTML dispatcher component + +1. Add a renderer class under: + - `alliance_platform/ui/templatetags/alliance_platform/html_components/components/` +2. Register it in: + - `alliance_platform/ui/templatetags/alliance_platform/html_components/registry.py` +3. Add parity cases in: + - `scripts/parity_cases/` + - include `class_prefixes` in each parity case module so fixture generation can keep relevant VE class tokens without hardcoding full class maps. +4. Regenerate fixtures: + - `just sync-html-ui-parity-fixtures` +5. Add or extend parity tests in: + - `tests/` + +## HTML parity fixture workflow + +The fixture generator depends on `@alliancesoftware/ui` TypeScript sources, so it must run through the `alliance-platform-js` runtime context. + +From `alliance-platform-py`: + +```bash +just sync-html-ui-parity-fixtures +``` + +Use a non-default JS checkout path: + +```bash +just sync-html-ui-parity-fixtures /path/to/alliance-platform-js +``` + +Generate a single component fixture: + +```bash +just sync-html-ui-parity-fixtures ../alliance-platform-js button_group +``` + +Check fixture drift (for CI or pre-commit checks): + +```bash +just check-html-ui-parity-fixtures /path/to/alliance-platform-js +``` + +## CI fixture drift check + +The cross-repo fixture drift workflow is defined in: + +- `.github/workflows/ap-ui-fixture-drift.yml` + +It checks out both `alliance-platform-py` and `alliance-platform-js`, regenerates the fixtures, and fails if fixture files changed. + +For private `alliance-platform-js` access in GitHub Actions, configure: + +- `ALLIANCE_PLATFORM_JS_DEPLOY_KEY`: private SSH key matching a read-only deploy key on `AllianceSoftware/alliance-platform-js`. diff --git a/packages/ap-ui/README.md b/packages/ap-ui/README.md index d85dfd34..51f81575 100644 --- a/packages/ap-ui/README.md +++ b/packages/ap-ui/README.md @@ -1 +1,3 @@ -The documentation for this package is hosted on [readthedocs.io](https://alliance-platform.readthedocs.io/projects/ui/latest/) +The documentation for this package is hosted on [readthedocs.io](https://alliance-platform.readthedocs.io/projects/ui/latest/). + +Maintainer/development workflow notes are in [DEVELOPMENT.md](./DEVELOPMENT.md). diff --git a/packages/ap-ui/alliance_platform/ui/templatetags/alliance_platform/html_components/__init__.py b/packages/ap-ui/alliance_platform/ui/templatetags/alliance_platform/html_components/__init__.py new file mode 100644 index 00000000..0b914265 --- /dev/null +++ b/packages/ap-ui/alliance_platform/ui/templatetags/alliance_platform/html_components/__init__.py @@ -0,0 +1,7 @@ +from .dispatcher import parse_ui_tag +from .registry import built_in_registry + +__all__ = [ + "built_in_registry", + "parse_ui_tag", +] diff --git a/packages/ap-ui/alliance_platform/ui/templatetags/alliance_platform/html_components/base.py b/packages/ap-ui/alliance_platform/ui/templatetags/alliance_platform/html_components/base.py new file mode 100644 index 00000000..39d31746 --- /dev/null +++ b/packages/ap-ui/alliance_platform/ui/templatetags/alliance_platform/html_components/base.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +from pathlib import Path +import re +from typing import Any +import warnings + +from allianceutils.util import underscore_to_camel +from django import template +from django.template import Context +from django.template import Origin +from django.template.base import UNKNOWN_SOURCE +from django.template.base import FilterExpression +from django.template.base import NodeList +from django.utils.html import conditional_escape +from django.utils.safestring import mark_safe + +from alliance_platform.frontend.bundler import get_bundler +from alliance_platform.frontend.bundler.base import ResolveContext +from alliance_platform.frontend.bundler.context import BundlerAsset +from alliance_platform.frontend.bundler.frontend_resource import FrontendResource +from alliance_platform.frontend.bundler.vanilla_extract import resolve_vanilla_extract_class_mapping +from alliance_platform.frontend.templatetags.react import DeferredProp + +from .slots import get_slot_context +from .slots import merge_slot_props +from .slots import push_slot_scope + +_REACT_ATTR_TO_HTML_ATTR = { + "className": "class", + "htmlFor": "for", + "formAction": "formaction", + "formMethod": "formmethod", + "formEncType": "formenctype", + "formNoValidate": "formnovalidate", + "formTarget": "formtarget", + "tabIndex": "tabindex", +} + +_CAMEL_CASE_SPLIT_RE = re.compile(r"([a-z0-9])([A-Z])") + + +class BaseHtmlUIComponentRenderer(template.Node, BundlerAsset): + """Base node for HTML-only UI components dispatched by ``{% ui %}``.""" + + slot_name: str | None = None + + def __init__( + self, + *, + props: dict[str, Any], + nodelist: NodeList, + origin: Origin | None, + target_var: str | None, + register_asset: bool = True, + ): + self.props = props + self.nodelist = nodelist + self.target_var = target_var + self._register_asset = register_asset + resolved_origin = origin or Origin(UNKNOWN_SOURCE) + if register_asset: + super().__init__(resolved_origin) + else: + self.origin = resolved_origin + self.bundler = get_bundler() + + def resolve_component_resources(self) -> list[FrontendResource]: + return [] + + def get_resources_for_bundling(self) -> list[FrontendResource]: + return self.resolve_component_resources() + + def get_slot_name(self) -> str | None: + return self.slot_name + + def render(self, context: Context) -> str: + if not self._register_asset: + raise RuntimeError( + "Cannot render a renderer initialised with register_asset=False. " + "This mode is for resource introspection only." + ) + self._queue_resources() + props = self.resolve_props(context) + props = self._merge_slot_props(context, props) + children_html = self.render_children(context) + rendered = self.render_component(context, props, children_html) + if self.target_var: + context[self.target_var] = rendered + return "" + return rendered + + def resolve_props(self, context: Context) -> dict[str, Any]: + resolved_props: dict[str, Any] = {} + for key, value in self.props.items(): + normalized_key = self._normalize_prop_key(key) + resolved_value = self.resolve_prop_value(context, value) + if normalized_key == "className" and normalized_key in resolved_props: + existing = resolved_props.get(normalized_key) + resolved_props[normalized_key] = self.join_classes( + str(existing) if existing else None, + str(resolved_value) if resolved_value else None, + ) + continue + resolved_props[normalized_key] = resolved_value + return resolved_props + + def resolve_prop_value(self, context: Context, value: Any) -> Any: + if isinstance(value, FilterExpression): + return self.resolve_prop_value(context, value.resolve(context)) + if isinstance(value, DeferredProp): + return value.resolve(context) + if isinstance(value, NodeList): + return value.render(context) + return value + + def render_children( + self, + context: Context, + slot_overrides: dict[str, dict[str, Any]] | None = None, + ) -> str: + if slot_overrides: + with push_slot_scope(context, slot_overrides): + return self.nodelist.render(context) + return self.nodelist.render(context) + + def render_component(self, context: Context, props: dict[str, Any], children_html: str) -> str: + raise NotImplementedError + + def resolve_resource_path(self, path: str, resolve_extensions: list[str] | None = None) -> Path: + resolver_context = ResolveContext(self.bundler.root_dir, self.origin.name if self.origin else None) + return self.bundler.resolve_path(path, resolver_context, resolve_extensions=resolve_extensions) + + def resolve_optional_resource_path( + self, + path: str, + resolve_extensions: list[str] | None = None, + ) -> Path | None: + try: + return self.resolve_resource_path(path, resolve_extensions=resolve_extensions) + except template.TemplateSyntaxError: + return None + + def resolve_frontend_resource( + self, + path: str, + resolve_extensions: list[str] | None = None, + ) -> FrontendResource: + return FrontendResource.from_path( + self.resolve_resource_path(path, resolve_extensions=resolve_extensions) + ) + + def resolve_vanilla_extract_mapping( + self, + path: str, + resolve_extensions: list[str] | None = None, + ): + style_path = self.resolve_resource_path(path, resolve_extensions=resolve_extensions) + return resolve_vanilla_extract_class_mapping(self.bundler, style_path) + + def get_style_class(self, mapping: Any, key: str) -> str: + value = getattr(mapping, key, "") + return value if isinstance(value, str) else "" + + def get_nested_style_class(self, mapping: Any, key: str, nested_key: str) -> str: + mapping_value = getattr(mapping, key, {}) + if isinstance(mapping_value, dict) or hasattr(mapping_value, "get"): + nested_value = mapping_value.get(nested_key, "") + return nested_value if isinstance(nested_value, str) else "" + return "" + + def validate_enum_prop( + self, + props: dict[str, Any], + *, + prop_name: str, + valid_values: tuple[str, ...], + default_value: str, + ) -> str: + value = props.get(prop_name, default_value) + if value in valid_values: + return str(value) + warnings.warn(f"Invalid '{prop_name}' prop passed: {value}") + return default_value + + def build_attrs_string(self, attrs: dict[str, Any]) -> str: + rendered_attrs: list[str] = [] + for name, value in attrs.items(): + if value is None or value is False: + continue + attr_name = self._to_html_attr_name(name) + if name == "style" and isinstance(value, dict): + value = self._style_dict_to_string(value) + if value is True: + rendered_attrs.append(f" {conditional_escape(attr_name)}") + else: + rendered_attrs.append(f' {conditional_escape(attr_name)}="{conditional_escape(value)}"') + return "".join(rendered_attrs) + + def join_classes(self, *class_names: str | None) -> str: + return " ".join(class_name for class_name in class_names if class_name) + + def _normalize_prop_key(self, key: str) -> str: + if key in {"class", "class_name"}: + return "className" + if key.startswith("data_"): + return f"data-{key[5:].replace('_', '-')}" + if key.startswith("aria_"): + return f"aria-{key[5:].replace('_', '-')}" + return underscore_to_camel(key) + + def _to_html_attr_name(self, key: str) -> str: + if key in _REACT_ATTR_TO_HTML_ATTR: + return _REACT_ATTR_TO_HTML_ATTR[key] + if "-" in key: + return key + if key.startswith("aria") and len(key) > 4 and key[4].isupper(): + return "aria-" + self._camel_to_kebab(key[4:]) + if key.startswith("data") and len(key) > 4 and key[4].isupper(): + return "data-" + self._camel_to_kebab(key[4:]) + return key.lower() + + def _camel_to_kebab(self, value: str) -> str: + return _CAMEL_CASE_SPLIT_RE.sub(r"\1-\2", value).replace("_", "-").lower() + + def _style_dict_to_string(self, style: dict[str, Any]) -> str: + declarations = [] + for key, value in style.items(): + css_key = key if key.startswith("--") else self._camel_to_kebab(str(key)) + declarations.append(f"{css_key}: {value}") + return "; ".join(declarations) + + def _merge_slot_props(self, context: Context, child_props: dict[str, Any]) -> dict[str, Any]: + slot_name = self.get_slot_name() + if not slot_name: + return child_props + slot_context = get_slot_context(context) + return merge_slot_props(slot_context.get(slot_name), child_props) + + def _queue_resources(self): + for item in self.bundler.get_embed_items(self.get_resources_for_bundling()): + self.bundler_asset_context.queue_embed_file(item) + + def _render_tag(self, tag_name: str, attrs: dict[str, Any], children_html: str = "") -> str: + attrs_html = self.build_attrs_string(attrs) + return mark_safe(f"<{tag_name}{attrs_html}>{children_html}") diff --git a/packages/ap-ui/alliance_platform/ui/templatetags/alliance_platform/html_components/components/__init__.py b/packages/ap-ui/alliance_platform/ui/templatetags/alliance_platform/html_components/components/__init__.py new file mode 100644 index 00000000..0934ce63 --- /dev/null +++ b/packages/ap-ui/alliance_platform/ui/templatetags/alliance_platform/html_components/components/__init__.py @@ -0,0 +1,7 @@ +from .button import UIButtonRenderer +from .button_group import UIButtonGroupRenderer + +__all__ = [ + "UIButtonRenderer", + "UIButtonGroupRenderer", +] diff --git a/packages/ap-ui/alliance_platform/ui/templatetags/alliance_platform/html_components/components/button.py b/packages/ap-ui/alliance_platform/ui/templatetags/alliance_platform/html_components/components/button.py new file mode 100644 index 00000000..a8cb0269 --- /dev/null +++ b/packages/ap-ui/alliance_platform/ui/templatetags/alliance_platform/html_components/components/button.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import re +from typing import Any +import warnings + +from django.template import Context +from django.utils.html import conditional_escape +from django.utils.safestring import mark_safe + +from alliance_platform.frontend.bundler.frontend_resource import FrontendResource + +from ..base import BaseHtmlUIComponentRenderer + +VALID_VARIANTS = ("solid", "outlined", "plain", "light", "link") +VALID_COLORS = ("primary", "secondary", "destructive", "gray") +VALID_SIZES = ("sm", "md", "lg", "xl", "2xl") +VALID_SHAPES = ("default", "circle") + +_BUTTON_STYLE_PATH = "@alliancesoftware/ui/components/button/Button.css.ts" +_FOCUS_RING_STYLE_PATH = "@alliancesoftware/ui/styles/base/focusRing.css.ts" + +_ICON_ONLY_RE = re.compile(r"^\s*<[^>]+data-apui-slot=([\"'])icon\1[^>]*>.*]+>\s*$", re.DOTALL) + + +class UIButtonRenderer(BaseHtmlUIComponentRenderer): + slot_name = "button" + + def resolve_component_resources(self) -> list[FrontendResource]: + return [ + self.resolve_frontend_resource(_BUTTON_STYLE_PATH), + self.resolve_frontend_resource(_FOCUS_RING_STYLE_PATH), + ] + + def render_component(self, context: Context, props: dict[str, Any], children_html: str) -> str: + variant = self.validate_enum_prop( + props, + prop_name="variant", + valid_values=VALID_VARIANTS, + default_value="solid", + ) + color = self.validate_enum_prop( + props, + prop_name="color", + valid_values=VALID_COLORS, + default_value="primary", + ) + size = self.validate_enum_prop( + props, + prop_name="size", + valid_values=VALID_SIZES, + default_value="md", + ) + shape = self.validate_enum_prop( + props, + prop_name="shape", + valid_values=VALID_SHAPES, + default_value="default", + ) + + if "disabled" in props and "isDisabled" not in props: + warnings.warn("You passed 'disabled' - use 'isDisabled' instead") + + is_disabled = bool(props.get("isDisabled") or props.get("disabled")) + + button_styles = self.resolve_vanilla_extract_mapping(_BUTTON_STYLE_PATH) + focus_ring_styles = self.resolve_vanilla_extract_mapping(_FOCUS_RING_STYLE_PATH) + + size_class_name = self.get_nested_style_class(button_styles, "sizes", size) + class_name = self.join_classes( + self.get_style_class(focus_ring_styles, "base"), + self.get_style_class(button_styles, "baseButton"), + size_class_name, + props.get("className"), + ) + + attrs: dict[str, Any] = { + "className": class_name, + "data-apui": "button", + "data-variant": variant, + "data-color": color, + "data-size": size, + "data-shape": shape, + "data-disabled": "true" if is_disabled else None, + "style": props.get("style"), + } + + href = props.get("href") + element_type = props.get("elementType") + if not isinstance(element_type, str): + element_type = "a" if href else "button" + tag_name = element_type + + if href is not None: + attrs["href"] = href + if is_disabled and tag_name == "button": + attrs["disabled"] = True + + pass_through_keys = { + "id", + "name", + "type", + "value", + "title", + "role", + "target", + "rel", + "tabIndex", + "form", + "formAction", + "formMethod", + "formEncType", + "formNoValidate", + "formTarget", + "aria-label", + "aria-describedby", + "aria-controls", + "aria-expanded", + "aria-current", + "data-testid", + } + handled_props = { + "variant", + "color", + "size", + "shape", + "className", + "style", + "isDisabled", + "disabled", + "href", + "elementType", + "children", + "slot", + "autoFocus", + } + + for key, value in props.items(): + if key in handled_props: + continue + if key in pass_through_keys or key.startswith("data-") or key.startswith("aria-"): + attrs[key] = value + + normalized_children = self._normalize_children(children_html) + if self._is_icon_only(normalized_children): + attrs["data-icon-only"] = "true" + + return self._render_tag(tag_name, attrs, normalized_children) + + def _normalize_children(self, children_html: str) -> str: + stripped = children_html.strip() + if not stripped: + return "" + if "<" not in stripped and ">" not in stripped: + return str(mark_safe(f"{conditional_escape(stripped)}")) + return children_html + + def _is_icon_only(self, children_html: str) -> bool: + return bool(_ICON_ONLY_RE.match(children_html.strip())) diff --git a/packages/ap-ui/alliance_platform/ui/templatetags/alliance_platform/html_components/components/button_group.py b/packages/ap-ui/alliance_platform/ui/templatetags/alliance_platform/html_components/components/button_group.py new file mode 100644 index 00000000..52202229 --- /dev/null +++ b/packages/ap-ui/alliance_platform/ui/templatetags/alliance_platform/html_components/components/button_group.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from typing import Any + +from django.template import Context + +from alliance_platform.frontend.bundler.frontend_resource import FrontendResource + +from ..base import BaseHtmlUIComponentRenderer +from ..runtime import attach_module_script + +VALID_ORIENTATIONS = ("horizontal", "vertical") +VALID_ALIGNS = ("start", "center", "end") +VALID_DENSITIES = ("compact", "xxs", "xs", "sm", "md", "lg", "xl", "xxl", "xxxl") + +_BUTTON_GROUP_STYLE_PATH = "@alliancesoftware/ui/components/button/ButtonGroup.css.ts" +_SMART_ORIENTATION_STYLE_PATH = "@alliancesoftware/ui/components/layout/SmartOrientation.css.ts" +# The runtime module is optional for now; if unresolved at parse time we degrade gracefully to static HTML. +_RUNTIME_MODULE_PATH = "@alliancesoftware/ui/components/layout/SmartOrientation.attach.ts" + + +class UIButtonGroupRenderer(BaseHtmlUIComponentRenderer): + def resolve_component_resources(self) -> list[FrontendResource]: + resources: list[FrontendResource] = [ + self.resolve_frontend_resource(_BUTTON_GROUP_STYLE_PATH), + self.resolve_frontend_resource(_SMART_ORIENTATION_STYLE_PATH), + ] + runtime_resource = self._resolve_runtime_resource() + if runtime_resource: + resources.append(runtime_resource) + return resources + + def render_component(self, context: Context, props: dict[str, Any], children_html: str) -> str: + if not children_html.strip(): + return "" + + orientation = self.validate_enum_prop( + props, + prop_name="orientation", + valid_values=VALID_ORIENTATIONS, + default_value="horizontal", + ) + align = self.validate_enum_prop( + props, + prop_name="align", + valid_values=VALID_ALIGNS, + default_value="start", + ) + + density_prop_present = "density" in props and props.get("density") is not None + density = self.validate_enum_prop( + props, + prop_name="density", + valid_values=VALID_DENSITIES, + default_value="md", + ) + + group_styles = self.resolve_vanilla_extract_mapping(_BUTTON_GROUP_STYLE_PATH) + smart_orientation_styles = self.resolve_vanilla_extract_mapping(_SMART_ORIENTATION_STYLE_PATH) + + container_class_name = self.get_nested_style_class(smart_orientation_styles, "container", orientation) + align_class_name = self.get_nested_style_class(smart_orientation_styles, "align", align) + density_class_name = self.get_nested_style_class(smart_orientation_styles, "density", density) + button_group_class_name = self.get_style_class(group_styles, "buttonGroup") + button_slot_class_name = self.get_style_class(group_styles, "button") + + class_name = self.join_classes( + container_class_name, + align_class_name, + density_class_name, + button_group_class_name, + props.get("className"), + ) + + slot_defaults: dict[str, Any] = {} + for key in ("isDisabled", "color", "variant", "size"): + if key in props and props[key] is not None: + slot_defaults[key] = props[key] + if button_slot_class_name: + slot_defaults["className"] = button_slot_class_name + + children_html = self.render_children(context, slot_overrides={"button": slot_defaults}) + + attrs: dict[str, Any] = { + "className": class_name, + "data-apui": "button-group", + "data-orientation": orientation, + "data-density": density if density_prop_present else None, + "data-align": props.get("align") if props.get("align") is not None else None, + "id": props.get("id"), + "style": props.get("style"), + } + + runtime_resource = self._resolve_runtime_resource() + script_html = "" + if runtime_resource is not None: + script_html = attach_module_script(runtime_resource, attrs) + + return f"{self._render_tag('div', attrs, children_html)}{script_html}" + + def _resolve_runtime_resource(self) -> FrontendResource | None: + runtime_path = self.resolve_optional_resource_path( + _RUNTIME_MODULE_PATH, + resolve_extensions=[".ts", ".tsx", ".js", ".mjs"], + ) + if runtime_path is None: + return None + return FrontendResource.from_path(runtime_path) diff --git a/packages/ap-ui/alliance_platform/ui/templatetags/alliance_platform/html_components/constants.py b/packages/ap-ui/alliance_platform/ui/templatetags/alliance_platform/html_components/constants.py new file mode 100644 index 00000000..0baea5c5 --- /dev/null +++ b/packages/ap-ui/alliance_platform/ui/templatetags/alliance_platform/html_components/constants.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +UI_SLOT_CONTEXT_KEY = "__alliance_platform_ui_slots" + +ALLOWED_COMPONENTS_KWARG = "allowed_components" + +RESERVED_DISPATCHER_KWARGS = frozenset({ALLOWED_COMPONENTS_KWARG}) diff --git a/packages/ap-ui/alliance_platform/ui/templatetags/alliance_platform/html_components/dispatcher.py b/packages/ap-ui/alliance_platform/ui/templatetags/alliance_platform/html_components/dispatcher.py new file mode 100644 index 00000000..07677bcf --- /dev/null +++ b/packages/ap-ui/alliance_platform/ui/templatetags/alliance_platform/html_components/dispatcher.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any +import warnings + +from allianceutils.template import is_static_expression +from allianceutils.template import parse_tag_arguments +from django import template +from django.conf import settings +from django.template import Context +from django.template import Origin +from django.template import TemplateSyntaxError +from django.template.base import UNKNOWN_SOURCE +from django.template.base import FilterExpression +from django.template.base import NodeList + +from alliance_platform.frontend.bundler.context import BundlerAsset +from alliance_platform.frontend.bundler.frontend_resource import FrontendResource + +from .constants import ALLOWED_COMPONENTS_KWARG +from .registry import HtmlUIComponentRegistry +from .registry import built_in_registry + +_DISPATCHER_WARNING_KEYS: set[tuple[str, str]] = set() + + +@dataclass(frozen=True) +class ComponentSelector: + expression: FilterExpression + is_static: bool + static_value: str | None + + +class UIComponentDispatcherNode(template.Node, BundlerAsset): + def __init__( + self, + *, + selector: ComponentSelector, + props: dict[str, Any], + nodelist: NodeList, + allowed_components: list[str] | None, + target_var: str | None, + origin: Origin | None, + registry: HtmlUIComponentRegistry, + ): + self.selector = selector + self.props = props + self.nodelist = nodelist + self.allowed_components = allowed_components or [] + self.target_var = target_var + self.registry = registry + super().__init__(origin or Origin(UNKNOWN_SOURCE)) + + def get_resources_for_bundling(self) -> list[FrontendResource]: + if self.selector.is_static and self.selector.static_value is not None: + spec = self.registry.get(self.selector.static_value) + if spec is None: + return [] + renderer = spec.renderer_cls( + props=self.props, + nodelist=self.nodelist, + origin=self.origin, + target_var=self.target_var, + register_asset=False, + ) + return renderer.get_resources_for_bundling() + + resources: list[FrontendResource] = [] + seen_keys: set[tuple[type[FrontendResource], str]] = set() + for component_name in self.allowed_components: + spec = self.registry.get(component_name) + if spec is None: + continue + renderer = spec.renderer_cls( + props=self.props, + nodelist=self.nodelist, + origin=self.origin, + target_var=self.target_var, + register_asset=False, + ) + for resource in renderer.get_resources_for_bundling(): + key = (type(resource), str(resource.path)) + if key in seen_keys: + continue + seen_keys.add(key) + resources.append(resource) + return resources + + def render(self, context: Context) -> str: + component_name = self._resolve_component_name(context) + + if not component_name: + self._warn_dispatcher( + warning_type="ui_dispatcher_empty_component", + component_identifier="", + message="Resolved ui component name was empty; rendering nothing.", + ) + return "" + + if not self.selector.is_static: + if component_name not in self.allowed_components: + self._warn_dispatcher( + warning_type="ui_dispatcher_disallowed_dynamic_component", + component_identifier=component_name, + message=( + f"Resolved ui component '{component_name}' is not allowed by " + f"{ALLOWED_COMPONENTS_KWARG}." + ), + ) + return "" + + spec = self.registry.get(component_name) + if spec is None: + if settings.DEBUG: + raise TemplateSyntaxError(f"Unknown ui component '{component_name}'") + self._warn_dispatcher( + warning_type="ui_dispatcher_unknown_component", + component_identifier=component_name, + message=f"Unknown ui component '{component_name}'", + ) + return "" + + renderer = spec.renderer_cls( + props=self.props, + nodelist=self.nodelist, + origin=self.origin, + target_var=self.target_var, + ) + return renderer.render(context) + + def _resolve_component_name(self, context: Context) -> str: + if self.selector.is_static: + return self.selector.static_value or "" + + value = self.selector.expression.resolve(context) + return "" if value is None else str(value).strip() + + def _warn_dispatcher(self, warning_type: str, component_identifier: str, message: str): + key = (warning_type, component_identifier) + if not settings.DEBUG and key in _DISPATCHER_WARNING_KEYS: + return + if not settings.DEBUG: + _DISPATCHER_WARNING_KEYS.add(key) + warnings.warn(message) + + +def parse_ui_tag( + parser, + token, + *, + registry: HtmlUIComponentRegistry = built_in_registry, +): + tag_name = token.split_contents()[0] + args, kwargs, target_var = parse_tag_arguments(parser, token, supports_as=True) + + if len(args) == 0: + raise TemplateSyntaxError( + f"'{tag_name}' requires a component selector as the first positional argument" + ) + if len(args) > 1: + raise TemplateSyntaxError( + f"'{tag_name}' accepts exactly one positional argument (component selector), received {len(args)}" + ) + + selector_expr = args[0] + selector_is_static = is_static_expression(selector_expr) + static_selector_value: str | None = None + + if selector_is_static: + resolved_selector = selector_expr.resolve(Context()) + if not isinstance(resolved_selector, str): + raise TemplateSyntaxError( + f"'{tag_name}' static selector must resolve to a string, received {type(resolved_selector).__name__}" + ) + static_selector_value = resolved_selector + if settings.DEBUG and not registry.exists(static_selector_value): + raise TemplateSyntaxError(f"Unknown ui component '{static_selector_value}'") + + allowed_components = _parse_allowed_components_literal( + tag_name=tag_name, + raw_allowed_components=kwargs.pop(ALLOWED_COMPONENTS_KWARG, None), + registry=registry, + ) + + if not selector_is_static and not allowed_components: + raise TemplateSyntaxError( + f"'{tag_name}' requires {ALLOWED_COMPONENTS_KWARG} when using a dynamic component selector" + ) + + nodelist = parser.parse((f"end{tag_name}",)) + parser.delete_first_token() + + return UIComponentDispatcherNode( + selector=ComponentSelector( + expression=selector_expr, + is_static=selector_is_static, + static_value=static_selector_value, + ), + props=kwargs, + nodelist=nodelist, + allowed_components=allowed_components, + target_var=target_var, + origin=parser.origin, + registry=registry, + ) + + +def _parse_allowed_components_literal( + *, + tag_name: str, + raw_allowed_components: FilterExpression | None, + registry: HtmlUIComponentRegistry, +) -> list[str]: + if raw_allowed_components is None: + return [] + + if not is_static_expression(raw_allowed_components): + raise TemplateSyntaxError( + f"'{tag_name}' expects {ALLOWED_COMPONENTS_KWARG} as a static string literal list" + ) + + resolved_value = raw_allowed_components.resolve(Context()) + if not isinstance(resolved_value, str): + raise TemplateSyntaxError( + f"'{tag_name}' expects {ALLOWED_COMPONENTS_KWARG} as a string, received {type(resolved_value).__name__}" + ) + + normalized: list[str] = [] + seen: set[str] = set() + for item in resolved_value.split(","): + component_name = item.strip() + if not component_name: + raise TemplateSyntaxError( + f"'{tag_name}' has malformed {ALLOWED_COMPONENTS_KWARG}; empty entries are not allowed" + ) + if component_name in seen: + continue + seen.add(component_name) + normalized.append(component_name) + + unknown = [component_name for component_name in normalized if not registry.exists(component_name)] + if unknown: + raise TemplateSyntaxError( + f"'{tag_name}' has invalid {ALLOWED_COMPONENTS_KWARG} entries: {', '.join(unknown)}" + ) + + return normalized diff --git a/packages/ap-ui/alliance_platform/ui/templatetags/alliance_platform/html_components/registry.py b/packages/ap-ui/alliance_platform/ui/templatetags/alliance_platform/html_components/registry.py new file mode 100644 index 00000000..6f66f6f9 --- /dev/null +++ b/packages/ap-ui/alliance_platform/ui/templatetags/alliance_platform/html_components/registry.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from collections import OrderedDict +from dataclasses import dataclass +from typing import TYPE_CHECKING +from typing import Iterable +from typing import TypeVar + +if TYPE_CHECKING: + from .base import BaseHtmlUIComponentRenderer + +RendererType = TypeVar("RendererType", bound="BaseHtmlUIComponentRenderer") + + +@dataclass(frozen=True) +class HtmlUIComponentSpec: + name: str + renderer_cls: type["BaseHtmlUIComponentRenderer"] + + +class HtmlUIComponentRegistry: + def __init__(self): + self._specs: OrderedDict[str, HtmlUIComponentSpec] = OrderedDict() + + def register(self, spec: HtmlUIComponentSpec): + self._specs[spec.name] = spec + + def register_renderer(self, name: str, renderer_cls: type["BaseHtmlUIComponentRenderer"]): + self.register(HtmlUIComponentSpec(name=name, renderer_cls=renderer_cls)) + + def get(self, name: str) -> HtmlUIComponentSpec | None: + return self._specs.get(name) + + def exists(self, name: str) -> bool: + return name in self._specs + + def list_names(self) -> list[str]: + return list(self._specs.keys()) + + def list_specs(self) -> Iterable[HtmlUIComponentSpec]: + return self._specs.values() + + +built_in_registry = HtmlUIComponentRegistry() + + +# Keep the default built-ins close to registry construction so parsing validation can rely on them. +from .components.button import UIButtonRenderer # noqa: E402 +from .components.button_group import UIButtonGroupRenderer # noqa: E402 + +built_in_registry.register_renderer("button", UIButtonRenderer) +built_in_registry.register_renderer("button_group", UIButtonGroupRenderer) diff --git a/packages/ap-ui/alliance_platform/ui/templatetags/alliance_platform/html_components/runtime.py b/packages/ap-ui/alliance_platform/ui/templatetags/alliance_platform/html_components/runtime.py new file mode 100644 index 00000000..ae6caf2b --- /dev/null +++ b/packages/ap-ui/alliance_platform/ui/templatetags/alliance_platform/html_components/runtime.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import json +from typing import Any + +from django.utils.html import format_html +from django.utils.safestring import mark_safe + +from alliance_platform.frontend.bundler import get_bundler +from alliance_platform.frontend.bundler.context import BundlerAssetContext +from alliance_platform.frontend.bundler.frontend_resource import FrontendResource + + +def attach_module_script(module_resource: FrontendResource, root_attrs: dict[str, Any]) -> str: + """Attach a runtime module to a rendered root element using a generated ``data-djid`` selector.""" + + asset_context = BundlerAssetContext.get_current() + component_id = asset_context.generate_id() + root_attrs["data-djid"] = component_id + + bundler = get_bundler() + import_url = bundler.get_url(module_resource.path) + selector = f"[data-djid='{component_id}']" + code = ( + f"import attach from {json.dumps(str(import_url))};\n" + f"const el = document.querySelector({json.dumps(selector)});\n" + "if (el) { attach(el); }" + ) + return str(format_html('', mark_safe(code))) diff --git a/packages/ap-ui/alliance_platform/ui/templatetags/alliance_platform/html_components/slots.py b/packages/ap-ui/alliance_platform/ui/templatetags/alliance_platform/html_components/slots.py new file mode 100644 index 00000000..ca104d07 --- /dev/null +++ b/packages/ap-ui/alliance_platform/ui/templatetags/alliance_platform/html_components/slots.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from contextlib import contextmanager +from typing import Any +from typing import Iterator +from typing import cast + +from django.template import Context + +from .constants import UI_SLOT_CONTEXT_KEY + +SlotProps = dict[str, Any] +SlotContext = dict[str, SlotProps] + + +def get_slot_context(context: Context) -> SlotContext: + value = context.get(UI_SLOT_CONTEXT_KEY, {}) + if not isinstance(value, dict): + return {} + return cast(SlotContext, value) + + +def merge_slot_props(slot_props: SlotProps | None, child_props: SlotProps) -> SlotProps: + slot_props = slot_props or {} + merged = {**slot_props, **child_props} + + slot_class_name = slot_props.get("className") + child_class_name = child_props.get("className") + if slot_class_name and child_class_name: + merged["className"] = f"{slot_class_name} {child_class_name}" + + return merged + + +@contextmanager +def push_slot_scope(context: Context, slots: SlotContext) -> Iterator[None]: + existing = get_slot_context(context) + next_slots = {**existing, **slots} + with context.push(**{UI_SLOT_CONTEXT_KEY: next_slots}): + yield diff --git a/packages/ap-ui/alliance_platform/ui/templatetags/alliance_platform/ui.py b/packages/ap-ui/alliance_platform/ui/templatetags/alliance_platform/ui.py index 65f0c56c..8ff26504 100644 --- a/packages/ap-ui/alliance_platform/ui/templatetags/alliance_platform/ui.py +++ b/packages/ap-ui/alliance_platform/ui/templatetags/alliance_platform/ui.py @@ -17,6 +17,7 @@ from .button import register_button from .date_picker import register_date_picker +from .html_components import parse_ui_tag from .icon import register_icon from .inline_alert import register_inline_alert from .labeled_input import register_labeled_input @@ -38,6 +39,7 @@ register_date_picker(register) register_time_input(register) register_labeled_input(register) +register.tag("ui")(parse_ui_tag) QueryParams = dict[str, Any] | QueryDict | str diff --git a/packages/ap-ui/docs/templatetags.rst b/packages/ap-ui/docs/templatetags.rst index 1dff4249..871c5725 100644 --- a/packages/ap-ui/docs/templatetags.rst +++ b/packages/ap-ui/docs/templatetags.rst @@ -22,6 +22,35 @@ The Alliance UI template tags serve as a convenient alternative to the :ttag:`co template tag, for easily embedding components from the Alliance UI library into Django templates. See the documentation for the component tag for instructions on passing arguments and filters. +.. templatetag:: ui + +``ui`` +------ + +Render built-in HTML-only Alliance UI components via a single dispatcher tag. + +Usage: + +.. code-block:: html+django + + {% ui "button" variant="solid" color="primary" %}Save{% endui %} + +Dynamic component names are supported when you provide a compile-time literal +whitelist via ``allowed_components``: + +.. code-block:: html+django + + {% ui component_name allowed_components="button,button_group" %} + {{ label }} + {% endui %} + +The dispatcher also supports ``as ``: + +.. code-block:: html+django + + {% ui "button" as save_button_html %}Save{% endui %} + {{ save_button_html }} + .. templatetag:: Button ``Button`` diff --git a/packages/ap-ui/scripts/generateHtmlUiParityFixtures.mjs b/packages/ap-ui/scripts/generateHtmlUiParityFixtures.mjs new file mode 100644 index 00000000..b3dda666 --- /dev/null +++ b/packages/ap-ui/scripts/generateHtmlUiParityFixtures.mjs @@ -0,0 +1,412 @@ +#!/usr/bin/env node + +import fs from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { pathToFileURL } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const CASE_MODULES = [ + './parity_cases/button.mjs', + './parity_cases/button_group.mjs', +]; + +const require = createRequire(import.meta.url); +const GENERATED_AT_ENV_VAR = 'AP_UI_PARITY_GENERATED_AT_UTC'; +const RUNTIME_ATTACH_IMPORT_URL = + 'http://localhost:5273/static/@alliancesoftware/ui/components/layout/SmartOrientation.attach.ts'; +let prettierFormatPromise; +const parityComponentRuntimeCache = new Map(); +let uiRequirePromise; + +async function fileExists(filePath) { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +async function resolveUiPackageJsonPath() { + const explicitUiPackageDir = process.env.AP_UI_UI_PACKAGE_DIR; + const explicitJsRepo = process.env.AP_UI_JS_REPO; + + const directCandidates = []; + if (explicitUiPackageDir) { + directCandidates.push(path.resolve(explicitUiPackageDir, 'package.json')); + } + if (explicitJsRepo) { + directCandidates.push(path.resolve(explicitJsRepo, 'packages/ui/package.json')); + } + directCandidates.push(path.resolve(process.cwd(), 'package.json')); + + for (const packageJsonPath of directCandidates) { + if (await fileExists(packageJsonPath)) { + return packageJsonPath; + } + } + + const searchPaths = []; + if (explicitUiPackageDir) { + searchPaths.push(explicitUiPackageDir); + } + if (explicitJsRepo) { + searchPaths.push(explicitJsRepo); + } + searchPaths.push(process.cwd()); + + for (const searchPath of searchPaths) { + try { + return require.resolve('@alliancesoftware/ui/package.json', { paths: [searchPath] }); + } catch { + // Keep trying subsequent resolution roots. + } + } + + return require.resolve('@alliancesoftware/ui/package.json'); +} + +async function loadRendererRuntime() { + let reactModule; + let reactDomServerModule; + try { + // Prefer bare imports so vite-node resolves the same React singleton for both + // the component module graph and server renderer. + reactModule = await import('react'); + reactDomServerModule = await import('react-dom/server'); + } catch { + // Fallback for non-vite execution contexts. + const uiPackageJsonPath = await resolveUiPackageJsonPath(); + const uiRequire = createRequire(uiPackageJsonPath); + const reactPath = uiRequire.resolve('react'); + const reactDomServerPath = uiRequire.resolve('react-dom/server'); + reactModule = await import(pathToFileURL(reactPath).href); + reactDomServerModule = await import(pathToFileURL(reactDomServerPath).href); + } + + const React = reactModule.default ?? reactModule; + const renderToStaticMarkup = + reactDomServerModule.renderToStaticMarkup ?? reactDomServerModule.default?.renderToStaticMarkup; + + if (!React?.createElement || typeof renderToStaticMarkup !== 'function') { + throw new Error( + 'Failed to load React rendering runtime. Ensure this script is run under a TS-aware runtime (for example vite-node).' + ); + } + + return { React, renderToStaticMarkup }; +} + +async function resolveUiPackageDir() { + const uiPackageJsonPath = await resolveUiPackageJsonPath(); + return path.dirname(uiPackageJsonPath); +} + +async function getUiRequire() { + if (!uiRequirePromise) { + uiRequirePromise = resolveUiPackageJsonPath().then(uiPackageJsonPath => createRequire(uiPackageJsonPath)); + } + return uiRequirePromise; +} + +async function importDefault(modulePath) { + const module = await import(pathToFileURL(modulePath).href); + return module.default ?? module; +} + +async function loadParityComponents(component) { + if (parityComponentRuntimeCache.has(component)) { + return parityComponentRuntimeCache.get(component); + } + + const uiPackageDir = await resolveUiPackageDir(); + let components; + if (component === 'button') { + components = { + Button: await importDefault(path.join(uiPackageDir, 'components/button/Button.tsx')), + }; + } else if (component === 'button_group') { + components = { + Button: await importDefault(path.join(uiPackageDir, 'components/button/Button.tsx')), + ButtonGroup: await importDefault(path.join(uiPackageDir, 'components/button/ButtonGroup.tsx')), + }; + } else { + throw new Error(`Unsupported parity component runtime: ${component}`); + } + + parityComponentRuntimeCache.set(component, components); + return components; +} + +async function formatFixtureJson(content) { + if (!prettierFormatPromise) { + prettierFormatPromise = (async () => { + try { + const uiRequire = await getUiRequire(); + const prettierPath = uiRequire.resolve('prettier'); + const prettierModule = await import(pathToFileURL(prettierPath).href); + return prettierModule.format ?? prettierModule.default?.format ?? null; + } catch { + try { + const prettierPath = require.resolve('prettier'); + const prettierModule = await import(pathToFileURL(prettierPath).href); + return prettierModule.format ?? prettierModule.default?.format ?? null; + } catch { + return null; + } + } + })(); + } + const prettierFormat = await prettierFormatPromise; + if (!prettierFormat) { + return content; + } + return prettierFormat(content, { parser: 'json' }); +} + +async function loadExistingGeneratedAtUtc(fixturePath) { + try { + const existingRaw = await fs.readFile(fixturePath, 'utf8'); + const existingFixture = JSON.parse(existingRaw); + if (typeof existingFixture.generated_at_utc === 'string' && existingFixture.generated_at_utc.length > 0) { + return existingFixture.generated_at_utc; + } + } catch { + // Ignore missing/invalid fixture and fall back to a generated timestamp. + } + return null; +} + +function captureWarnings(run) { + const warnings = []; + const originalWarn = console.warn; + console.warn = (...args) => { + warnings.push(args.map(String).join(' ')); + }; + try { + const html = run(); + return { html, warnings }; + } finally { + console.warn = originalWarn; + } +} + +function dedupeTokens(tokens) { + const seen = new Set(); + return tokens.filter(token => { + if (!token || seen.has(token)) { + return false; + } + seen.add(token); + return true; + }); +} + +function tokenizeClasses(value) { + if (!value) { + return []; + } + return String(value) + .trim() + .split(/\s+/) + .filter(Boolean); +} + +function parseAttributes(attrString) { + const attrs = new Map(); + const attrPattern = /([^\s=]+)(?:="([^"]*)")?/g; + let match; + while ((match = attrPattern.exec(attrString)) !== null) { + attrs.set(match[1], match[2] ?? true); + } + return attrs; +} + +function escapeAttribute(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('"', '"'); +} + +function buildAttributesString(attrs, orderedKeys = []) { + const parts = []; + const consumed = new Set(); + + const appendAttr = (name, value) => { + if (value === null || value === undefined || value === false) { + return; + } + if (value === true) { + parts.push(` ${name}`); + } else { + parts.push(` ${name}="${escapeAttribute(value)}"`); + } + }; + + for (const key of orderedKeys) { + if (!attrs.has(key)) { + continue; + } + consumed.add(key); + appendAttr(key, attrs.get(key)); + } + + for (const [key, value] of attrs.entries()) { + if (consumed.has(key)) { + continue; + } + appendAttr(key, value); + } + + return parts.join(''); +} + +function normalizeClassTokens(classValue, allowedPrefixes) { + const normalized = []; + for (const originalToken of tokenizeClasses(classValue)) { + const hashIndex = originalToken.lastIndexOf('__'); + const hadHash = hashIndex !== -1; + const token = hadHash ? originalToken.slice(0, hashIndex) : originalToken; + if (!token) { + continue; + } + + if (token.includes('_')) { + const prefix = token.split('_', 1)[0]; + if (hadHash && !allowedPrefixes.has(prefix)) { + continue; + } + } else if (hadHash && !allowedPrefixes.has(token)) { + continue; + } + normalized.push(token); + } + + const deduped = dedupeTokens(normalized); + return deduped.filter(token => { + const hasChildToken = deduped.some(other => other !== token && other.startsWith(`${token}_`)); + if (hasChildToken) { + return false; + } + if (token.endsWith('Base')) { + const root = token.slice(0, -4); + const hasRootChild = deduped.some(other => other !== token && other.startsWith(`${root}_`)); + if (hasRootChild) { + return false; + } + } + return true; + }); +} + +function normalizeDomAttributes(html, testCase, allowedPrefixes) { + if (!html.trim()) { + return ''; + } + + let normalized = html; + normalized = normalized.replace(/\sdata-react-aria-pressable="true"/g, ''); + normalized = normalized.replace(/\stabindex="0"/g, ''); + normalized = normalized.replace(/\stype="button"/g, ''); + if (!testCase.template.includes('data-apui-slot="icon"')) { + normalized = normalized.replace(/\sdata-icon-only="true"/g, ''); + } + normalized = normalized.replace(/\sclass="([^"]*)"/g, (_match, classValue) => { + const classTokens = normalizeClassTokens(classValue, allowedPrefixes); + return classTokens.length ? ` class="${classTokens.join(' ')}"` : ''; + }); + return normalized; +} + +function injectButtonGroupRuntime(html) { + const rootMatch = html.match(/^]*)>([\s\S]*)<\/div>$/); + if (!rootMatch) { + return html; + } + + const [, attrsString, childrenHtml] = rootMatch; + const attrs = parseAttributes(attrsString); + attrs.set('data-djid', '__DJID__'); + const rootHtml = `${childrenHtml}`; + const scriptHtml = + `'; + return `${rootHtml}${scriptHtml}`; +} + +function normalizeRenderedHtml(component, testCase, html, allowedPrefixes) { + const normalized = normalizeDomAttributes(html, testCase, allowedPrefixes); + if (component === 'button_group' && normalized) { + return injectButtonGroupRuntime(normalized); + } + return normalized; +} + +async function generateFixtureFromModule(modulePath, runtime) { + const caseModule = await import(new URL(modulePath, import.meta.url)); + const { component, cases, class_prefixes: classPrefixes = [] } = caseModule; + const allowedPrefixes = new Set(classPrefixes); + const parityComponents = await loadParityComponents(component); + + if (!component || !Array.isArray(cases)) { + throw new Error(`Invalid parity case module at ${modulePath}`); + } + + const serializedCases = []; + for (const testCase of cases) { + const { html, warnings } = captureWarnings(() => + runtime.renderToStaticMarkup( + testCase.buildElement({ + React: runtime.React, + components: parityComponents, + }) + ) + ); + const normalizedHtml = normalizeRenderedHtml(component, testCase, html, allowedPrefixes); + serializedCases.push({ + name: testCase.name, + template: testCase.template, + expected_html: normalizedHtml, + expected_warnings: warnings, + meta: testCase.meta ?? {}, + }); + } + + const fixturePath = path.resolve(__dirname, '../tests/fixtures', `ui_html_${component}_parity.json`); + const generatedAtUtc = + process.env[GENERATED_AT_ENV_VAR] ?? (await loadExistingGeneratedAtUtc(fixturePath)) ?? new Date().toISOString(); + + const fixture = { + generated_by: 'scripts/generateHtmlUiParityFixtures.mjs', + generated_at_utc: generatedAtUtc, + component, + styles: {}, + cases: serializedCases, + }; + + const serializedFixture = `${JSON.stringify(fixture, null, 2)}\n`; + const formattedFixture = await formatFixtureJson(serializedFixture); + await fs.writeFile(fixturePath, formattedFixture, 'utf8'); + return fixturePath; +} + +async function main() { + const runtime = await loadRendererRuntime(); + const onlyComponent = process.argv[2]; + const modulePaths = CASE_MODULES; + for (const modulePath of modulePaths) { + const caseModule = await import(new URL(modulePath, import.meta.url)); + if (onlyComponent && caseModule.component !== onlyComponent) { + continue; + } + const fixturePath = await generateFixtureFromModule(modulePath, runtime); + process.stdout.write(`Wrote ${fixturePath}\n`); + } +} + +await main(); diff --git a/packages/ap-ui/scripts/parity_cases/button.mjs b/packages/ap-ui/scripts/parity_cases/button.mjs new file mode 100644 index 00000000..6000eda1 --- /dev/null +++ b/packages/ap-ui/scripts/parity_cases/button.mjs @@ -0,0 +1,41 @@ +export const component = 'button'; +export const class_prefixes = ['focusRing', 'Button']; + +export const cases = [ + { + name: 'default', + template: '{% ui "button" %}Save{% endui %}', + buildElement({ React, components }) { + const { Button } = components; + return React.createElement(Button, null, 'Save'); + }, + meta: {}, + }, + { + name: 'invalid_variant_warns_and_falls_back', + template: '{% ui "button" variant="invalid" %}Save{% endui %}', + buildElement({ React, components }) { + const { Button } = components; + return React.createElement(Button, { variant: 'invalid' }, 'Save'); + }, + meta: {}, + }, + { + name: 'anchor_variant', + template: '{% ui "button" href="/next" color="secondary" size="lg" %}Go{% endui %}', + buildElement({ React, components }) { + const { Button } = components; + return React.createElement(Button, { href: '/next', color: 'secondary', size: 'lg' }, 'Go'); + }, + meta: {}, + }, + { + name: 'icon_only', + template: '{% ui "button" %}{% endui %}', + buildElement({ React, components }) { + const { Button } = components; + return React.createElement(Button, null, React.createElement('span', { 'data-apui-slot': 'icon' })); + }, + meta: {}, + }, +]; diff --git a/packages/ap-ui/scripts/parity_cases/button_group.mjs b/packages/ap-ui/scripts/parity_cases/button_group.mjs new file mode 100644 index 00000000..c6f3d7ba --- /dev/null +++ b/packages/ap-ui/scripts/parity_cases/button_group.mjs @@ -0,0 +1,41 @@ +export const component = 'button_group'; +export const class_prefixes = ['SmartOrientation', 'ButtonGroup', 'focusRing', 'Button']; + +export const cases = [ + { + name: 'default', + template: '{% ui "button_group" %}{% ui "button" %}One{% endui %}{% endui %}', + buildElement({ React, components }) { + const { Button, ButtonGroup } = components; + return React.createElement( + ButtonGroup, + null, + React.createElement(Button, null, 'One') + ); + }, + meta: {}, + }, + { + name: 'slot_defaults_and_child_class_merge', + template: + '{% ui "button_group" variant="outlined" color="gray" size="lg" density="compact" align="end" %}{% ui "button" className="custom" %}Two{% endui %}{% endui %}', + buildElement({ React, components }) { + const { Button, ButtonGroup } = components; + return React.createElement( + ButtonGroup, + { variant: 'outlined', color: 'gray', size: 'lg', density: 'compact', align: 'end' }, + React.createElement(Button, { className: 'custom' }, 'Two') + ); + }, + meta: {}, + }, + { + name: 'empty_children', + template: '{% ui "button_group" %}{% endui %}', + buildElement({ React, components }) { + const { ButtonGroup } = components; + return React.createElement(ButtonGroup, null); + }, + meta: {}, + }, +]; diff --git a/packages/ap-ui/scripts/syncHtmlUiParityFixtures.sh b/packages/ap-ui/scripts/syncHtmlUiParityFixtures.sh new file mode 100755 index 00000000..1e7771fd --- /dev/null +++ b/packages/ap-ui/scripts/syncHtmlUiParityFixtures.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" +AP_UI_DIR="$(cd -- "${SCRIPT_DIR}/.." >/dev/null 2>&1 && pwd)" +PY_REPO_ROOT="$(cd -- "${AP_UI_DIR}/../.." >/dev/null 2>&1 && pwd)" + +DEFAULT_JS_REPO="${PY_REPO_ROOT}/../alliance-platform-js" +JS_REPO="${ALLIANCE_PLATFORM_JS_DIR:-${DEFAULT_JS_REPO}}" +COMPONENT="" + +usage() { + cat <<'EOF' +Usage: syncHtmlUiParityFixtures.sh [--js-repo ] [--component ] + +Regenerates ap-ui HTML parity fixtures using the JS workspace runtime. + +Options: + --js-repo Path to alliance-platform-js (default: ../alliance-platform-js) + --component Generate only one component fixture (for example "button") + -h, --help Show this help +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --js-repo) + if [[ $# -lt 2 ]]; then + echo "Missing value for --js-repo" >&2 + usage >&2 + exit 1 + fi + JS_REPO="$2" + shift 2 + ;; + --component) + if [[ $# -lt 2 ]]; then + echo "Missing value for --component" >&2 + usage >&2 + exit 1 + fi + COMPONENT="$2" + shift 2 + ;; + -h | --help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ ! -d "${JS_REPO}" ]]; then + echo "alliance-platform-js directory not found: ${JS_REPO}" >&2 + exit 1 +fi +JS_REPO="$(cd -- "${JS_REPO}" >/dev/null 2>&1 && pwd)" + +UI_PACKAGE_DIR="${JS_REPO}/packages/ui" +if [[ ! -d "${UI_PACKAGE_DIR}" ]]; then + echo "Expected UI package directory not found: ${UI_PACKAGE_DIR}" >&2 + exit 1 +fi + +VITE_NODE_BIN="${JS_REPO}/node_modules/.bin/vite-node" +if [[ ! -x "${VITE_NODE_BIN}" ]]; then + cat >&2 <", + "expected_warnings": [], + "meta": {} + }, + { + "name": "slot_defaults_and_child_class_merge", + "template": "{% ui \"button_group\" variant=\"outlined\" color=\"gray\" size=\"lg\" density=\"compact\" align=\"end\" %}{% ui \"button\" className=\"custom\" %}Two{% endui %}{% endui %}", + "expected_html": "
", + "expected_warnings": [], + "meta": {} + }, + { + "name": "empty_children", + "template": "{% ui \"button_group\" %}{% endui %}", + "expected_html": "", + "expected_warnings": [], + "meta": {} + } + ] +} diff --git a/packages/ap-ui/tests/fixtures/ui_html_button_parity.json b/packages/ap-ui/tests/fixtures/ui_html_button_parity.json new file mode 100644 index 00000000..c55b18c5 --- /dev/null +++ b/packages/ap-ui/tests/fixtures/ui_html_button_parity.json @@ -0,0 +1,36 @@ +{ + "generated_by": "scripts/generateHtmlUiParityFixtures.mjs", + "generated_at_utc": "2026-04-08T00:00:00.000Z", + "component": "button", + "styles": {}, + "cases": [ + { + "name": "default", + "template": "{% ui \"button\" %}Save{% endui %}", + "expected_html": "", + "expected_warnings": [], + "meta": {} + }, + { + "name": "invalid_variant_warns_and_falls_back", + "template": "{% ui \"button\" variant=\"invalid\" %}Save{% endui %}", + "expected_html": "", + "expected_warnings": ["Invalid 'variant' prop passed: invalid"], + "meta": {} + }, + { + "name": "anchor_variant", + "template": "{% ui \"button\" href=\"/next\" color=\"secondary\" size=\"lg\" %}Go{% endui %}", + "expected_html": "Go", + "expected_warnings": [], + "meta": {} + }, + { + "name": "icon_only", + "template": "{% ui \"button\" %}{% endui %}", + "expected_html": "", + "expected_warnings": [], + "meta": {} + } + ] +} diff --git a/packages/ap-ui/tests/parity/__init__.py b/packages/ap-ui/tests/parity/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/ap-ui/tests/parity/base.py b/packages/ap-ui/tests/parity/base.py new file mode 100644 index 00000000..7a32521b --- /dev/null +++ b/packages/ap-ui/tests/parity/base.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from contextlib import contextmanager +import json +from pathlib import Path +from typing import Any +from unittest import mock +import warnings + +from alliance_platform.frontend.bundler.context import BundlerAssetContext +from django.template import Context +from django.template import Template +from django.test import SimpleTestCase + +from tests.test_utils import override_ap_frontend_settings +from tests.test_utils.bundler import TestViteBundler +from tests.test_utils.bundler import bundler_kwargs +from tests.test_utils.bundler import bypass_frontend_resource_registry + +from .normalizers import normalize_html_fragment +from .style_mocks import make_style_mapping_resolver + +test_development_bundler = TestViteBundler( + **bundler_kwargs, # type: ignore[arg-type] + mode="development", +) + + +class HtmlUIParityTestCase(SimpleTestCase): + fixture_component: str + + @contextmanager + def setup_render_context(self): + with override_ap_frontend_settings(BUNDLER=test_development_bundler): + with BundlerAssetContext( + skip_checks=True, + frontend_resource_registry=bypass_frontend_resource_registry, + ) as asset_context: + with mock.patch( + "alliance_platform.ui.templatetags.alliance_platform.html_components.base.resolve_vanilla_extract_class_mapping", + side_effect=make_style_mapping_resolver(), + ): + yield asset_context + + def load_fixture(self): + fixture_path = ( + Path(__file__).resolve().parent.parent + / "fixtures" + / f"ui_html_{self.fixture_component}_parity.json" + ) + return json.loads(fixture_path.read_text()) + + def render_ui_template(self, template_body: str, context_kwargs: dict[str, Any] | None = None) -> str: + template_obj = Template("{% load alliance_platform.ui %}" + template_body) + context_obj = Context(context_kwargs or {}) + context_obj.template = template_obj + return template_obj.render(context_obj) + + def assert_parity_case(self, case: dict[str, Any], context_kwargs: dict[str, Any] | None = None): + with self.setup_render_context() as _asset_context: + with warnings.catch_warnings(record=True) as caught_warnings: + warnings.simplefilter("always") + output = self.render_ui_template(case["template"], context_kwargs) + + actual_html = normalize_html_fragment(output) + expected_html = normalize_html_fragment(case["expected_html"]) + self.assertEqual(actual_html, expected_html) + + expected_warnings = case.get("expected_warnings", []) + actual_warnings = [str(item.message) for item in caught_warnings] + self.assertEqual(actual_warnings, expected_warnings) diff --git a/packages/ap-ui/tests/parity/normalizers.py b/packages/ap-ui/tests/parity/normalizers.py new file mode 100644 index 00000000..bf46c0ba --- /dev/null +++ b/packages/ap-ui/tests/parity/normalizers.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import re + +_DJID_HTML_RE = re.compile(r'data-djid="[^"]+"') +_DJID_SELECTOR_RE = re.compile(r"\[data-djid='[^']+'\]") +_OPENING_TAG_RE = re.compile(r"<([a-zA-Z][\w:-]*)(\s[^<>]*?)?>") +_ATTR_RE = re.compile(r'([^\s=]+)(?:="([^"]*)")?') +_TAG_GAP_RE = re.compile(r">\s+<") +_WHITESPACE_RE = re.compile(r"\s+") + + +def _normalize_tag_attributes(value: str) -> str: + def _replace(match: re.Match[str]) -> str: + tag_name = match.group(1) + attrs_part = (match.group(2) or "").strip() + if not attrs_part: + return f"<{tag_name}>" + + attrs: list[tuple[str, str | None]] = [] + for attr_match in _ATTR_RE.finditer(attrs_part): + attrs.append((attr_match.group(1), attr_match.group(2))) + attrs.sort(key=lambda item: item[0]) + + rendered_attrs = [] + for name, attr_value in attrs: + if attr_value is None: + rendered_attrs.append(f" {name}") + else: + rendered_attrs.append(f' {name}="{attr_value}"') + return f"<{tag_name}{''.join(rendered_attrs)}>" + + return _OPENING_TAG_RE.sub(_replace, value) + + +def normalize_html_fragment(value: str) -> str: + normalized = value.strip() + normalized = _DJID_HTML_RE.sub('data-djid="__DJID__"', normalized) + normalized = _DJID_SELECTOR_RE.sub("[data-djid='__DJID__']", normalized) + normalized = _normalize_tag_attributes(normalized) + normalized = _TAG_GAP_RE.sub("><", normalized) + normalized = _WHITESPACE_RE.sub(" ", normalized) + return normalized.strip() diff --git a/packages/ap-ui/tests/parity/style_mocks.py b/packages/ap-ui/tests/parity/style_mocks.py new file mode 100644 index 00000000..6a4aaf6f --- /dev/null +++ b/packages/ap-ui/tests/parity/style_mocks.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + + +class MockStyleToken(str): + """String token that can also synthesise nested class keys via `.get(...)`.""" + + def __new__(cls, token: str): + return super().__new__(cls, token) + + def get(self, key: str, default: str = "") -> str: + if not key: + return default + return f"{self}_{key}" + + +class MockVanillaExtractMapping: + def __init__(self, scope: str, mapping: dict[str, Any]): + self.scope = scope + self.mapping = mapping + + def __getattr__(self, name: str): + if name in self.mapping: + value = self.mapping[name] + if isinstance(value, dict): + return value + if isinstance(value, str): + return value + return MockStyleToken(f"{self.scope}_{name}") + + +def _mapping_scope_from_filename(filename: str) -> str: + if filename.endswith(".css.ts"): + return filename[: -len(".css.ts")] + return Path(filename).stem + + +DEFAULT_STYLE_MAPPINGS: dict[str, dict[str, Any]] = { + "SmartOrientation.css.ts": { + "container": { + "horizontal": "SmartOrientation_containerBase", + "vertical": "SmartOrientation_containerBase", + }, + }, +} + + +def make_style_mapping_resolver(overrides: dict[str, dict[str, Any]] | None = None): + mappings = {**DEFAULT_STYLE_MAPPINGS, **(overrides or {})} + + def _resolve_mapping(_bundler, filename): + key = Path(filename).name + scope = _mapping_scope_from_filename(key) + return MockVanillaExtractMapping(scope, mappings.get(key, {})) + + return _resolve_mapping diff --git a/packages/ap-ui/tests/test_html_ui_button_group_parity.py b/packages/ap-ui/tests/test_html_ui_button_group_parity.py new file mode 100644 index 00000000..ef016353 --- /dev/null +++ b/packages/ap-ui/tests/test_html_ui_button_group_parity.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from tests.parity.base import HtmlUIParityTestCase +from tests.parity.normalizers import normalize_html_fragment + + +class UIButtonGroupParityTestCase(HtmlUIParityTestCase): + fixture_component = "button_group" + + def test_fixture_cases(self): + fixture = self.load_fixture() + for case in fixture["cases"]: + with self.subTest(case=case["name"]): + self.assert_parity_case(case) + + def test_runtime_bootstrap_script_is_appended(self): + with self.setup_render_context() as _asset_context: + output = self.render_ui_template( + '{% ui "button_group" %}{% ui "button" %}One{% endui %}{% endui %}' + ) + normalized = normalize_html_fragment(output) + self.assertIn('