Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .github/workflows/ap-ui-fixture-drift.yml
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions packages/ap-ui/DEVELOPMENT.md
Original file line number Diff line number Diff line change
@@ -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`.
4 changes: 3 additions & 1 deletion packages/ap-ui/README.md
Original file line number Diff line number Diff line change
@@ -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).
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from .dispatcher import parse_ui_tag
from .registry import built_in_registry

__all__ = [
"built_in_registry",
"parse_ui_tag",
]
Original file line number Diff line number Diff line change
@@ -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}</{tag_name}>")
Loading
Loading