diff --git a/chatkit/actions.py b/chatkit/actions.py index e00b3ad..b5c04d0 100644 --- a/chatkit/actions.py +++ b/chatkit/actions.py @@ -3,6 +3,7 @@ from typing import Any, Generic, Literal, TypeVar, get_args, get_origin from pydantic import BaseModel, Field +from typing_extensions import deprecated Handler = Literal["client", "server"] LoadingBehavior = Literal["auto", "none", "self", "container"] @@ -11,6 +12,14 @@ DEFAULT_LOADING_BEHAVIOR: LoadingBehavior = "auto" +_direct_usage_of_action_classes_deprecated = deprecated( + "Direct usage of named action classes is deprecated. " + "Use WidgetTemplate to build widgets from .widget files instead. " + "Visit https://widgets.chatkit.studio/ to author widget files." +) + + +@_direct_usage_of_action_classes_deprecated class ActionConfig(BaseModel): type: str payload: Any = None @@ -22,6 +31,7 @@ class ActionConfig(BaseModel): TPayload = TypeVar("TPayload") +@_direct_usage_of_action_classes_deprecated class Action(BaseModel, Generic[TType, TPayload]): type: TType = Field(default=TType, frozen=True) # pyright: ignore payload: TPayload = None # pyright: ignore - default to None to allow no-payload actions diff --git a/chatkit/server.py b/chatkit/server.py index 66d7b58..970c973 100644 --- a/chatkit/server.py +++ b/chatkit/server.py @@ -69,7 +69,7 @@ is_streaming_req, ) from .version import __version__ -from .widgets import Markdown, Text, WidgetComponent, WidgetComponentBase, WidgetRoot +from .widgets import WidgetComponent, WidgetComponentBase, WidgetRoot DEFAULT_PAGE_SIZE = 20 DEFAULT_ERROR_MESSAGE = "An error occurred when generating a response." @@ -82,6 +82,11 @@ def diff_widget( Compare two WidgetRoots and return a list of deltas. """ + def is_streaming_text(component: WidgetComponentBase) -> bool: + return getattr(component, "type", None) in {"Markdown", "Text"} and isinstance( + getattr(component, "value", None), str + ) + def full_replace(before: WidgetComponentBase, after: WidgetComponentBase) -> bool: if ( before.type != after.type @@ -108,10 +113,10 @@ def full_replace_value(before_value: Any, after_value: Any) -> bool: for field in before.model_fields_set.union(after.model_fields_set): if ( - isinstance(before, (Markdown, Text)) - and isinstance(after, (Markdown, Text)) + is_streaming_text(before) + and is_streaming_text(after) and field == "value" - and after.value.startswith(before.value) + and getattr(after, "value", "").startswith(getattr(before, "value", "")) ): # Appends to the value prop of Markdown or Text do not trigger a full replace continue @@ -129,11 +134,11 @@ def full_replace_value(before_value: Any, after_value: Any) -> bool: def find_all_streaming_text_components( component: WidgetComponent | WidgetRoot, - ) -> dict[str, Markdown | Text]: + ) -> dict[str, WidgetComponentBase]: components = {} def recurse(component: WidgetComponent | WidgetRoot): - if isinstance(component, (Markdown, Text)) and component.id: + if is_streaming_text(component) and component.id: components[component.id] = component if hasattr(component, "children"): @@ -154,16 +159,19 @@ def recurse(component: WidgetComponent | WidgetRoot): f"Node {id} was not present when the widget was initially rendered. All nodes with ID must persist across all widget updates." ) - if before_node.value != after_node.value: - if not after_node.value.startswith(before_node.value): + before_value = str(getattr(before_node, "value", None)) + after_value = str(getattr(after_node, "value", None)) + + if before_value != after_value: + if not after_value.startswith(before_value): raise ValueError( f"Node {id} was updated with a new value that is not a prefix of the initial value. All widget updates must be cumulative." ) - done = not after_node.streaming + done = not getattr(after_node, "streaming", False) deltas.append( WidgetStreamingTextValueDelta( component_id=id, - delta=after_node.value[len(before_node.value) :], + delta=after_value[len(before_value) :], done=done, ) ) diff --git a/chatkit/widgets.py b/chatkit/widgets.py index eb64341..a0adecc 100644 --- a/chatkit/widgets.py +++ b/chatkit/widgets.py @@ -1,23 +1,38 @@ from __future__ import annotations +import inspect +import json from datetime import datetime +from pathlib import Path from typing import ( Annotated, + Any, Literal, ) +from jinja2 import Environment, StrictUndefined, Template from pydantic import ( BaseModel, ConfigDict, Field, + TypeAdapter, model_serializer, ) -from typing_extensions import NotRequired, TypedDict +from typing_extensions import NotRequired, TypedDict, deprecated from .actions import ActionConfig from .icons import IconName +_jinja_env = Environment(undefined=StrictUndefined) +_direct_usage_of_named_widget_types_deprecated = deprecated( + "Direct usage of named widget classes is deprecated. " + "Use WidgetTemplate to build widgets from .widget files instead. " + "Visit https://widgets.chatkit.studio/ to author widget files." +) + + +@_direct_usage_of_named_widget_types_deprecated class ThemeColor(TypedDict): """Color values for light and dark themes.""" @@ -27,6 +42,7 @@ class ThemeColor(TypedDict): """Color to use when the theme is light.""" +@_direct_usage_of_named_widget_types_deprecated class Spacing(TypedDict): """Shorthand spacing values applied to a widget.""" @@ -44,6 +60,7 @@ class Spacing(TypedDict): """Vertical spacing; accepts a spacing unit or CSS string.""" +@_direct_usage_of_named_widget_types_deprecated class Border(TypedDict): """Border style definition for an edge.""" @@ -64,6 +81,7 @@ class Border(TypedDict): """Border line style.""" +@_direct_usage_of_named_widget_types_deprecated class Borders(TypedDict): """Composite border configuration applied across edges.""" @@ -81,6 +99,7 @@ class Borders(TypedDict): """Vertical borders or thickness in px.""" +@_direct_usage_of_named_widget_types_deprecated class MinMax(TypedDict): """Integer minimum/maximum bounds.""" @@ -90,6 +109,7 @@ class MinMax(TypedDict): """Maximum value (inclusive).""" +@_direct_usage_of_named_widget_types_deprecated class EditableProps(TypedDict): """Editable field options for text widgets.""" @@ -179,6 +199,7 @@ def serialize(self, next_): return dumped +@_direct_usage_of_named_widget_types_deprecated class WidgetStatusWithFavicon(TypedDict): """Widget status representation using a favicon.""" @@ -190,6 +211,7 @@ class WidgetStatusWithFavicon(TypedDict): """Show a frame around the favicon for contrast.""" +@_direct_usage_of_named_widget_types_deprecated class WidgetStatusWithIcon(TypedDict): """Widget status representation using an icon.""" @@ -203,6 +225,7 @@ class WidgetStatusWithIcon(TypedDict): """Union for representing widget status messaging.""" +@_direct_usage_of_named_widget_types_deprecated class ListViewItem(WidgetComponentBase): """Single row inside a ``ListView`` component.""" @@ -217,6 +240,7 @@ class ListViewItem(WidgetComponentBase): """Y-axis alignment for content within the list item.""" +@_direct_usage_of_named_widget_types_deprecated class ListView(WidgetComponentBase): """Container component for rendering collections of list items.""" @@ -231,6 +255,7 @@ class ListView(WidgetComponentBase): """Force light or dark theme for this subtree.""" +@_direct_usage_of_named_widget_types_deprecated class CardAction(TypedDict): """Configuration for confirm/cancel actions within a card.""" @@ -240,6 +265,7 @@ class CardAction(TypedDict): """Declarative action dispatched to the host application.""" +@_direct_usage_of_named_widget_types_deprecated class Card(WidgetComponentBase): """Versatile container used for structuring widget content.""" @@ -271,6 +297,7 @@ class Card(WidgetComponentBase): """Force light or dark theme for this subtree.""" +@_direct_usage_of_named_widget_types_deprecated class Markdown(WidgetComponentBase): """Widget rendering Markdown content, optionally streamed.""" @@ -281,6 +308,7 @@ class Markdown(WidgetComponentBase): """Applies streaming-friendly transitions for incremental updates.""" +@_direct_usage_of_named_widget_types_deprecated class Text(WidgetComponentBase): """Widget rendering plain text with typography controls.""" @@ -319,6 +347,7 @@ class Text(WidgetComponentBase): """Enable inline editing for this text node.""" +@_direct_usage_of_named_widget_types_deprecated class Title(WidgetComponentBase): """Widget rendering prominent headline text.""" @@ -345,6 +374,7 @@ class Title(WidgetComponentBase): """Limit text to a maximum number of lines (line clamp).""" +@_direct_usage_of_named_widget_types_deprecated class Caption(WidgetComponentBase): """Widget rendering supporting caption text.""" @@ -371,6 +401,7 @@ class Caption(WidgetComponentBase): """Limit text to a maximum number of lines (line clamp).""" +@_direct_usage_of_named_widget_types_deprecated class Badge(WidgetComponentBase): """Small badge indicating status or categorization.""" @@ -389,6 +420,7 @@ class Badge(WidgetComponentBase): """Determines if the badge should be fully rounded (pill).""" +@_direct_usage_of_named_widget_types_deprecated class BoxBase(BaseModel): """Shared layout props for flexible container widgets.""" @@ -441,6 +473,7 @@ class BoxBase(BaseModel): """Aspect ratio of the box (e.g., 16/9); number or CSS string.""" +@_direct_usage_of_named_widget_types_deprecated class Box(WidgetComponentBase, BoxBase): """Generic flex container with direction control.""" @@ -449,18 +482,21 @@ class Box(WidgetComponentBase, BoxBase): """Flex direction for content within this container.""" +@_direct_usage_of_named_widget_types_deprecated class Row(WidgetComponentBase, BoxBase): """Horizontal flex container.""" type: Literal["Row"] = Field(default="Row", frozen=True) # pyright: ignore +@_direct_usage_of_named_widget_types_deprecated class Col(WidgetComponentBase, BoxBase): """Vertical flex container.""" type: Literal["Col"] = Field(default="Col", frozen=True) # pyright: ignore +@_direct_usage_of_named_widget_types_deprecated class Form(WidgetComponentBase, BoxBase): """Form wrapper capable of submitting ``onSubmitAction``.""" @@ -471,6 +507,7 @@ class Form(WidgetComponentBase, BoxBase): """Flex direction for laying out form children.""" +@_direct_usage_of_named_widget_types_deprecated class Divider(WidgetComponentBase): """Visual divider separating content sections.""" @@ -490,6 +527,7 @@ class Divider(WidgetComponentBase): """Flush the divider to the container edge, removing surrounding padding.""" +@_direct_usage_of_named_widget_types_deprecated class Icon(WidgetComponentBase): """Icon component referencing a built-in icon name.""" @@ -508,6 +546,7 @@ class Icon(WidgetComponentBase): """Size of the icon; accepts an icon size token.""" +@_direct_usage_of_named_widget_types_deprecated class Image(WidgetComponentBase): """Image component with sizing and fitting controls.""" @@ -572,6 +611,7 @@ class Image(WidgetComponentBase): """Flex growth/shrink factor.""" +@_direct_usage_of_named_widget_types_deprecated class Button(WidgetComponentBase): """Button component optionally wired to an action.""" @@ -618,6 +658,7 @@ class Button(WidgetComponentBase): """Disable interactions and apply disabled styles.""" +@_direct_usage_of_named_widget_types_deprecated class Spacer(WidgetComponentBase): """Flexible spacer used to push content apart.""" @@ -626,6 +667,7 @@ class Spacer(WidgetComponentBase): """Minimum size the spacer should occupy along the flex direction.""" +@_direct_usage_of_named_widget_types_deprecated class SelectOption(TypedDict): """Selectable option used by the ``Select`` widget.""" @@ -639,6 +681,7 @@ class SelectOption(TypedDict): """Displayed as secondary text below the option `label`.""" +@_direct_usage_of_named_widget_types_deprecated class Select(WidgetComponentBase): """Select dropdown component.""" @@ -667,6 +710,7 @@ class Select(WidgetComponentBase): """Disable interactions and apply disabled styles.""" +@_direct_usage_of_named_widget_types_deprecated class DatePicker(WidgetComponentBase): """Date picker input component.""" @@ -701,6 +745,7 @@ class DatePicker(WidgetComponentBase): """Disable interactions and apply disabled styles.""" +@_direct_usage_of_named_widget_types_deprecated class Checkbox(WidgetComponentBase): """Checkbox input component.""" @@ -719,6 +764,7 @@ class Checkbox(WidgetComponentBase): """Mark the checkbox as required for form submission.""" +@_direct_usage_of_named_widget_types_deprecated class Input(WidgetComponentBase): """Single-line text input component.""" @@ -755,6 +801,7 @@ class Input(WidgetComponentBase): """Determines if the input should be fully rounded (pill).""" +@_direct_usage_of_named_widget_types_deprecated class Label(WidgetComponentBase): """Form label associated with a field.""" @@ -779,6 +826,7 @@ class Label(WidgetComponentBase): """ +@_direct_usage_of_named_widget_types_deprecated class RadioOption(TypedDict): """Option inside a ``RadioGroup`` widget.""" @@ -790,6 +838,7 @@ class RadioOption(TypedDict): """Disables a specific radio option.""" +@_direct_usage_of_named_widget_types_deprecated class RadioGroup(WidgetComponentBase): """Grouped radio input control.""" @@ -812,6 +861,7 @@ class RadioGroup(WidgetComponentBase): """Mark the group as required for form submission.""" +@_direct_usage_of_named_widget_types_deprecated class Textarea(WidgetComponentBase): """Multiline text input component.""" @@ -848,6 +898,7 @@ class Textarea(WidgetComponentBase): """Allow password managers / autofill extensions to appear.""" +@_direct_usage_of_named_widget_types_deprecated class Transition(WidgetComponentBase): """Wrapper enabling transitions for a child component.""" @@ -856,6 +907,7 @@ class Transition(WidgetComponentBase): """The child component to animate layout changes for.""" +@_direct_usage_of_named_widget_types_deprecated class Chart(WidgetComponentBase): """Data visualization component for simple bar/line/area charts.""" @@ -900,6 +952,7 @@ class Chart(WidgetComponentBase): """Aspect ratio of the chart area (e.g., 16/9); number or CSS string.""" +@_direct_usage_of_named_widget_types_deprecated class XAxisConfig(TypedDict): """Configuration object for the X axis.""" @@ -931,6 +984,7 @@ class XAxisConfig(TypedDict): """Interpolation curve types for `area` and `line` series.""" +@_direct_usage_of_named_widget_types_deprecated class BarSeries(BaseModel): """A bar series plotted from a numeric `dataKey`. Supports stacking.""" @@ -953,6 +1007,7 @@ class BarSeries(BaseModel): """ +@_direct_usage_of_named_widget_types_deprecated class AreaSeries(BaseModel): """An area series plotted from a numeric `dataKey`. Supports stacking and curves.""" @@ -977,6 +1032,7 @@ class AreaSeries(BaseModel): """Interpolation curve type used to connect points.""" +@_direct_usage_of_named_widget_types_deprecated class LineSeries(BaseModel): """A line series plotted from a numeric `dataKey`. Supports curves.""" @@ -1006,32 +1062,17 @@ class LineSeries(BaseModel): """Union of all supported chart series types.""" -class BasicRoot(WidgetComponentBase): - """Layout root capable of nesting components or other roots.""" - - type: Literal["Basic"] = Field(default="Basic", frozen=True) # pyright: ignore - children: list[WidgetComponent | WidgetRoot] - """Children to render inside this root. Can include widget components or nested roots.""" - theme: Literal["light", "dark"] | None = None - """Force light or dark theme for this subtree.""" - direction: Literal["row", "col"] | None = None - """Flex direction for laying out direct children.""" - gap: int | str | None = None - """Gap between direct children; spacing unit or CSS string.""" - padding: float | str | Spacing | None = None - """Inner padding; spacing unit, CSS string, or padding object.""" - align: Alignment | None = None - """Cross-axis alignment of children.""" - justify: Justification | None = None - """Main-axis distribution of children.""" +class DynamicWidgetComponent(WidgetComponentBase): + """ + A widget component with a statically defined base shape but dynamically + defined additional fields loaded from a widget template or JSON schema. + """ + model_config = ConfigDict(extra="allow") + children: DynamicWidgetComponent | list[DynamicWidgetComponent] | None = None -WidgetRoot = Annotated[ - Card | ListView, - Field(discriminator="type"), -] -WidgetComponent = Annotated[ +StrictWidgetComponent = Annotated[ Text | Title | Caption @@ -1058,8 +1099,89 @@ class BasicRoot(WidgetComponentBase): | Transition, Field(discriminator="type"), ] + + +StrictWidgetRoot = Annotated[ + Card | ListView, + Field(discriminator="type"), +] + + +class DynamicWidgetRoot(DynamicWidgetComponent): + """Dynamic root widget restricted to root types.""" + + type: Literal["Card", "ListView"] # pyright: ignore + + +class BasicRoot(DynamicWidgetComponent): + """Layout root capable of nesting components or other roots.""" + + type: Literal["Basic"] = Field(default="Basic", frozen=True) # pyright: ignore + + +WidgetComponent = StrictWidgetComponent | DynamicWidgetComponent """Union of all renderable widget components.""" +WidgetRoot = StrictWidgetRoot | DynamicWidgetRoot +"""Union of all renderable top-level widgets.""" + WidgetIcon = IconName """Icon names accepted by widgets that render icons.""" + + +class WidgetTemplate: + """ + Utility for loading and building widgets from a .widget file. + + Example using .widget file on disc: + ```python + template = WidgetTemplate.from_file("path/to/my_widget.widget") + widget = template.build({"name": "Harry Potter"}) + ``` + + Example using already parsed widget definition: + ```python + template = WidgetTemplate(definition={"version": "1.0", "name": "...", "template": Template(...), "jsonSchema": {...}}) + widget = template.build({"name": "Harry Potter"}) + ``` + """ + + adapter: TypeAdapter[DynamicWidgetRoot] = TypeAdapter(DynamicWidgetRoot) + + def __init__(self, definition: dict[str, Any]): + self.version = definition["version"] + if self.version != "1.0": + raise ValueError(f"Unsupported widget spec version: {self.version}") + + self.name = definition["name"] + template = definition["template"] + if isinstance(template, Template): + self.template = template + else: + self.template = _jinja_env.from_string(template) + self.data_schema = definition.get("jsonSchema", {}) + + @classmethod + def from_file(cls, file_path: str) -> "WidgetTemplate": + path = Path(file_path) + if not path.is_absolute(): + caller_frame = inspect.stack()[1] + caller_path = Path(caller_frame.filename).resolve() + path = caller_path.parent / path + + with path.open("r", encoding="utf-8") as file: + payload = json.load(file) + + return cls(payload) + + def build( + self, data: dict[str, Any] | BaseModel | None = None + ) -> DynamicWidgetRoot: + if data is None: + data = {} + if isinstance(data, BaseModel): + data = data.model_dump() + rendered = self.template.render(**data) + widget_dict = json.loads(rendered) + return self.adapter.validate_python(widget_dict) diff --git a/pyproject.toml b/pyproject.toml index 10a7100..a80fb01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ version = "1.3.1" description = "A ChatKit backend SDK." readme = "README.md" requires-python = ">=3.10" -dependencies = ["pydantic", "uvicorn", "openai", "openai-agents>=0.3.2"] +dependencies = ["pydantic", "uvicorn", "openai", "openai-agents>=0.3.2", "jinja2"] [dependency-groups] dev = [ diff --git a/tests/assets/widgets/card_no_data.json b/tests/assets/widgets/card_no_data.json new file mode 100644 index 0000000..cfb801f --- /dev/null +++ b/tests/assets/widgets/card_no_data.json @@ -0,0 +1,71 @@ +{ + "type": "Card", + "children": [ + { + "type": "Col", + "align": "center", + "gap": 4, + "padding": 4, + "children": [ + { + "type": "Box", + "background": "green-400", + "radius": "full", + "padding": 3, + "children": [ + { + "type": "Icon", + "name": "check", + "size": "3xl", + "color": "white" + } + ] + }, + { + "type": "Col", + "align": "center", + "gap": 1, + "children": [ + { + "type": "Title", + "value": "Enable notification" + }, + { + "type": "Text", + "value": "Notify me when this item ships", + "color": "secondary" + } + ] + } + ] + }, + { + "type": "Row", + "children": [ + { + "type": "Button", + "label": "Yes", + "block": true, + "onClickAction": { + "type": "notification.settings", + "payload": { + "enable": true + } + } + }, + { + "type": "Button", + "label": "No", + "block": true, + "variant": "outline", + "onClickAction": { + "type": "notification.settings", + "payload": { + "enable": true + } + } + } + ] + } + ] +} diff --git a/tests/assets/widgets/card_no_data.widget b/tests/assets/widgets/card_no_data.widget new file mode 100644 index 0000000..3d6353a --- /dev/null +++ b/tests/assets/widgets/card_no_data.widget @@ -0,0 +1,83 @@ +{ + "version": "1.0", + "name": "Enable Notification", + "template": "{\"type\":\"Card\",\"children\":[{\"type\":\"Col\",\"align\":\"center\",\"gap\":4,\"padding\":4,\"children\":[{\"type\":\"Box\",\"background\":\"green-400\",\"radius\":\"full\",\"padding\":3,\"children\":[{\"type\":\"Icon\",\"name\":\"check\",\"size\":\"3xl\",\"color\":\"white\"}]},{\"type\":\"Col\",\"align\":\"center\",\"gap\":1,\"children\":[{\"type\":\"Title\",\"value\":\"Enable notification\"},{\"type\":\"Text\",\"value\":\"Notify me when this item ships\",\"color\":\"secondary\"}]}]},{\"type\":\"Row\",\"children\":[{\"type\":\"Button\",\"label\":\"Yes\",\"block\":true,\"onClickAction\":{\"type\":\"notification.settings\",\"payload\":{\"enable\":true}}},{\"type\":\"Button\",\"label\":\"No\",\"block\":true,\"variant\":\"outline\",\"onClickAction\":{\"type\":\"notification.settings\",\"payload\":{\"enable\":true}}}]}]}", + "jsonSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "outputJsonPreview": { + "type": "Card", + "children": [ + { + "type": "Col", + "align": "center", + "gap": 4, + "padding": 4, + "children": [ + { + "type": "Box", + "background": "green-400", + "radius": "full", + "padding": 3, + "children": [ + { + "type": "Icon", + "name": "check", + "size": "3xl", + "color": "white" + } + ] + }, + { + "type": "Col", + "align": "center", + "gap": 1, + "children": [ + { + "type": "Title", + "value": "Enable notification" + }, + { + "type": "Text", + "value": "Notify me when this item ships", + "color": "secondary" + } + ] + } + ] + }, + { + "type": "Row", + "children": [ + { + "type": "Button", + "label": "Yes", + "block": true, + "onClickAction": { + "type": "notification.settings", + "payload": { + "enable": true + } + } + }, + { + "type": "Button", + "label": "No", + "block": true, + "variant": "outline", + "onClickAction": { + "type": "notification.settings", + "payload": { + "enable": true + } + } + } + ] + } + ] + }, + "encodedWidget": "eyJpZCI6IndpZ180YWtnYWg0YSIsIm5hbWUiOiJFbmFibGUgTm90aWZpY2F0aW9uIiwidmlldyI6IjxDYXJkPlxuICA8Q29sIGFsaWduPVwiY2VudGVyXCIgZ2FwPXs0fSBwYWRkaW5nPXs0fT5cbiAgICA8Qm94IGJhY2tncm91bmQ9XCJncmVlbi00MDBcIiByYWRpdXM9XCJmdWxsXCIgcGFkZGluZz17M30-XG4gICAgICA8SWNvbiBuYW1lPVwiY2hlY2tcIiBzaXplPVwiM3hsXCIgY29sb3I9XCJ3aGl0ZVwiIC8-XG4gICAgPC9Cb3g-XG4gICAgPENvbCBhbGlnbj1cImNlbnRlclwiIGdhcD17MX0-XG4gICAgICA8VGl0bGUgdmFsdWU9XCJFbmFibGUgbm90aWZpY2F0aW9uXCIgLz5cbiAgICAgIDxUZXh0IHZhbHVlPVwiTm90aWZ5IG1lIHdoZW4gdGhpcyBpdGVtIHNoaXBzXCIgY29sb3I9XCJzZWNvbmRhcnlcIiAvPlxuICAgIDwvQ29sPlxuICA8L0NvbD5cblxuICA8Um93PlxuICAgIDxCdXR0b25cbiAgICAgIGxhYmVsPVwiWWVzXCJcbiAgICAgIGJsb2NrXG4gICAgICBvbkNsaWNrQWN0aW9uPXt7XG4gICAgICAgIHR5cGU6IFwibm90aWZpY2F0aW9uLnNldHRpbmdzXCIsXG4gICAgICAgIHBheWxvYWQ6IHsgZW5hYmxlOiB0cnVlIH0sXG4gICAgICB9fVxuICAgIC8-XG4gICAgPEJ1dHRvblxuICAgICAgbGFiZWw9XCJOb1wiXG4gICAgICBibG9ja1xuICAgICAgdmFyaWFudD1cIm91dGxpbmVcIlxuICAgICAgb25DbGlja0FjdGlvbj17e1xuICAgICAgICB0eXBlOiBcIm5vdGlmaWNhdGlvbi5zZXR0aW5nc1wiLFxuICAgICAgICBwYXlsb2FkOiB7IGVuYWJsZTogdHJ1ZSB9LFxuICAgICAgfX1cbiAgICAvPlxuICA8L1Jvdz5cbjwvQ2FyZD4iLCJkZWZhdWx0U3RhdGUiOnt9LCJzY2hlbWFNb2RlIjoiem9kIiwianNvblNjaGVtYSI6e30sInNjaGVtYSI6ImltcG9ydCB7IHogfSBmcm9tIFwiem9kXCJcblxuY29uc3QgV2lkZ2V0U3RhdGUgPSB6Lm9iamVjdCh7fSlcblxuZXhwb3J0IGRlZmF1bHQgV2lkZ2V0U3RhdGUiLCJzdGF0ZXMiOltdLCJzY2hlbWFWYWxpZGl0eSI6InZhbGlkIiwidmlld1ZhbGlkaXR5IjoidmFsaWQiLCJkZWZhdWx0U3RhdGVWYWxpZGl0eSI6InZhbGlkIn0" +} diff --git a/tests/assets/widgets/card_with_data.json b/tests/assets/widgets/card_with_data.json new file mode 100644 index 0000000..5602b2b --- /dev/null +++ b/tests/assets/widgets/card_with_data.json @@ -0,0 +1,56 @@ +{ + "type": "Card", + "size": "md", + "children": [ + { + "type": "Row", + "children": [ + { + "type": "Text", + "value": "#proj-chatkit" + }, + { + "type": "Spacer" + }, + { + "type": "Text", + "value": "4:48 PM", + "color": "tertiary" + } + ] + }, + { + "type": "Divider", + "flush": true + }, + { + "type": "Row", + "align": "start", + "gap": 4, + "children": [ + { + "type": "Image", + "src": "/pam.png", + "size": 44 + }, + { + "type": "Col", + "children": [ + { + "type": "Text", + "value": "Pam Beesly", + "weight": "semibold" + }, + { + "type": "Markdown", + "value": "End of week update for ChatKit:\n\n1. Designed **new header system** with more flexibility for custom menu actions.\n2. Made progress on **DevDay training material**.\n3. Coordinated with partners to **prioritize remaining feature requirements**.\n\n**Next week** I plan to focus on building out our Figma library and updating to new icons." + } + ] + } + ] + }, + { + "type": "Spacer" + } + ] +} diff --git a/tests/assets/widgets/card_with_data.widget b/tests/assets/widgets/card_with_data.widget new file mode 100644 index 0000000..922fa85 --- /dev/null +++ b/tests/assets/widgets/card_with_data.widget @@ -0,0 +1,96 @@ +{ + "version": "1.0", + "name": "Channel message", + "template": "{\"type\":\"Card\",\"size\":\"md\",\"children\":[{\"type\":\"Row\",\"children\":[{\"type\":\"Text\",\"value\":{{ (channel) | tojson }}},{\"type\":\"Spacer\"},{\"type\":\"Text\",\"value\":{{ (time) | tojson }},\"color\":\"tertiary\"}]},{\"type\":\"Divider\",\"flush\":true},{\"type\":\"Row\",\"align\":\"start\",\"gap\":4,\"children\":[{\"type\":\"Image\",\"src\":{{ (user.image) | tojson }},\"size\":44},{\"type\":\"Col\",\"children\":[{\"type\":\"Text\",\"value\":{{ (user.name) | tojson }},\"weight\":\"semibold\"},{\"type\":\"Markdown\",\"value\":{{ ((\"End of week update for ChatKit:\\n\\n1. Designed **new header system** with more flexibility for custom menu actions.\\n2. Made progress on **DevDay training material**.\\n3. Coordinated with partners to **prioritize remaining feature requirements**.\\n\\n**Next week** I plan to focus on building out our Figma library and updating to new icons.\")) | tojson }}}]}]},{\"type\":\"Spacer\"}]}", + "jsonSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "channel": { + "type": "string" + }, + "time": { + "type": "string" + }, + "user": { + "type": "object", + "properties": { + "image": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "image", + "name" + ], + "additionalProperties": false + } + }, + "required": [ + "channel", + "time", + "user" + ], + "additionalProperties": false + }, + "outputJsonPreview": { + "type": "Card", + "size": "md", + "children": [ + { + "type": "Row", + "children": [ + { + "type": "Text", + "value": "#proj-chatkit" + }, + { + "type": "Spacer" + }, + { + "type": "Text", + "value": "4:48 PM", + "color": "tertiary" + } + ] + }, + { + "type": "Divider", + "flush": true + }, + { + "type": "Row", + "align": "start", + "gap": 4, + "children": [ + { + "type": "Image", + "src": "/zj.png", + "size": 44 + }, + { + "type": "Col", + "children": [ + { + "type": "Text", + "value": "Zach Johnston", + "weight": "semibold" + }, + { + "type": "Markdown", + "value": "End of week update for ChatKit:\n\n1. Designed **new header system** with more flexibility for custom menu actions.\n2. Made progress on **DevDay training material**.\n3. Coordinated with partners to **prioritize remaining feature requirements**.\n\n**Next week** I plan to focus on building out our Figma library and updating to new icons." + } + ] + } + ] + }, + { + "type": "Spacer" + } + ] + }, + "encodedWidget": "eyJpZCI6IndpZ183N3AzNzIxeiIsIm5hbWUiOiJDaGFubmVsIG1lc3NhZ2UiLCJ2aWV3IjoiPENhcmQgc2l6ZT1cIm1kXCI-XG4gIDxSb3c-XG4gICAgPFRleHQgdmFsdWU9e2NoYW5uZWx9IC8-XG4gICAgPFNwYWNlciAvPlxuICAgIDxUZXh0IHZhbHVlPXt0aW1lfSBjb2xvcj1cInRlcnRpYXJ5XCIgLz5cbiAgPC9Sb3c-XG4gIDxEaXZpZGVyIGZsdXNoIC8-XG4gIDxSb3cgYWxpZ249XCJzdGFydFwiIGdhcD17NH0-XG4gICAgPEltYWdlIHNyYz17dXNlci5pbWFnZX0gc2l6ZT17NDR9IC8-XG4gICAgPENvbD5cbiAgICAgIDxUZXh0IHZhbHVlPXt1c2VyLm5hbWV9IHdlaWdodD1cInNlbWlib2xkXCIgLz5cbiAgICAgIDxNYXJrZG93blxuICAgICAgICB2YWx1ZT17YEVuZCBvZiB3ZWVrIHVwZGF0ZSBmb3IgQ2hhdEtpdDpcblxuMS4gRGVzaWduZWQgKipuZXcgaGVhZGVyIHN5c3RlbSoqIHdpdGggbW9yZSBmbGV4aWJpbGl0eSBmb3IgY3VzdG9tIG1lbnUgYWN0aW9ucy5cbjIuIE1hZGUgcHJvZ3Jlc3Mgb24gKipEZXZEYXkgdHJhaW5pbmcgbWF0ZXJpYWwqKi5cbjMuIENvb3JkaW5hdGVkIHdpdGggcGFydG5lcnMgdG8gKipwcmlvcml0aXplIHJlbWFpbmluZyBmZWF0dXJlIHJlcXVpcmVtZW50cyoqLlxuXG4qKk5leHQgd2VlayoqIEkgcGxhbiB0byBmb2N1cyBvbiBidWlsZGluZyBvdXQgb3VyIEZpZ21hIGxpYnJhcnkgYW5kIHVwZGF0aW5nIHRvIG5ldyBpY29ucy5gfVxuICAgICAgLz5cbiAgICA8L0NvbD5cbiAgPC9Sb3c-XG4gIDxTcGFjZXIgLz5cbjwvQ2FyZD4iLCJkZWZhdWx0U3RhdGUiOnsiY2hhbm5lbCI6IiNwcm9qLWNoYXRraXQiLCJ0aW1lIjoiNDo0OCBQTSIsInVzZXIiOnsiaW1hZ2UiOiIvemoucG5nIiwibmFtZSI6IlphY2ggSm9obnN0b24ifX0sInNjaGVtYU1vZGUiOiJ6b2QiLCJqc29uU2NoZW1hIjp7fSwic2NoZW1hIjoiaW1wb3J0IHsgeiB9IGZyb20gXCJ6b2RcIlxuXG5jb25zdCBVc2VyID0gei5vYmplY3Qoe1xuICBpbWFnZTogei5zdHJpbmcoKSxcbiAgbmFtZTogei5zdHJpbmcoKSxcbn0pXG5cbmNvbnN0IFdpZGdldFN0YXRlID0gei5vYmplY3Qoe1xuICBjaGFubmVsOiB6LnN0cmluZygpLFxuICB0aW1lOiB6LnN0cmluZygpLFxuICB1c2VyOiBVc2VyLFxufSlcblxuZXhwb3J0IGRlZmF1bHQgV2lkZ2V0U3RhdGUiLCJzdGF0ZXMiOltdLCJzY2hlbWFWYWxpZGl0eSI6InZhbGlkIiwidmlld1ZhbGlkaXR5IjoidmFsaWQiLCJkZWZhdWx0U3RhdGVWYWxpZGl0eSI6InZhbGlkIn0" +} diff --git a/tests/assets/widgets/list_view_no_data.json b/tests/assets/widgets/list_view_no_data.json new file mode 100644 index 0000000..7900375 --- /dev/null +++ b/tests/assets/widgets/list_view_no_data.json @@ -0,0 +1,53 @@ +{ + "type": "ListView", + "children": [ + { + "type": "ListViewItem", + "key": "blue", + "gap": 5, + "onClickAction": { + "type": "line.select", + "payload": { + "id": "blue" + } + }, + "children": [ + { + "type": "Box", + "background": "blue-500", + "radius": "full", + "size": 25 + }, + { + "type": "Text", + "value": "Blue line", + "size": "sm" + } + ] + }, + { + "type": "ListViewItem", + "key": "orange", + "gap": 5, + "onClickAction": { + "type": "line.select", + "payload": { + "id": "orange" + } + }, + "children": [ + { + "type": "Box", + "background": "orange-500", + "radius": "full", + "size": 25 + }, + { + "type": "Text", + "value": "Orange line", + "size": "sm" + } + ] + } + ] +} diff --git a/tests/assets/widgets/list_view_no_data.widget b/tests/assets/widgets/list_view_no_data.widget new file mode 100644 index 0000000..7dfdfab --- /dev/null +++ b/tests/assets/widgets/list_view_no_data.widget @@ -0,0 +1,65 @@ +{ + "version": "1.0", + "name": "Untitled widget", + "template": "{\"type\":\"ListView\",\"children\":[{\"type\":\"ListViewItem\",\"key\":\"blue\",\"gap\":5,\"onClickAction\":{\"type\":\"line.select\",\"payload\":{\"id\":\"blue\"}},\"children\":[{\"type\":\"Box\",\"background\":\"blue-500\",\"radius\":\"full\",\"size\":25},{\"type\":\"Text\",\"value\":\"Blue line\",\"size\":\"sm\"}]},{\"type\":\"ListViewItem\",\"key\":\"orange\",\"gap\":5,\"onClickAction\":{\"type\":\"line.select\",\"payload\":{\"id\":\"orange\"}},\"children\":[{\"type\":\"Box\",\"background\":\"orange-500\",\"radius\":\"full\",\"size\":25},{\"type\":\"Text\",\"value\":\"Orange line\",\"size\":\"sm\"}]}]}", + "jsonSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "outputJsonPreview": { + "type": "ListView", + "children": [ + { + "type": "ListViewItem", + "key": "blue", + "gap": 5, + "onClickAction": { + "type": "line.select", + "payload": { + "id": "blue" + } + }, + "children": [ + { + "type": "Box", + "background": "blue-500", + "radius": "full", + "size": 25 + }, + { + "type": "Text", + "value": "Blue line", + "size": "sm" + } + ] + }, + { + "type": "ListViewItem", + "key": "orange", + "gap": 5, + "onClickAction": { + "type": "line.select", + "payload": { + "id": "orange" + } + }, + "children": [ + { + "type": "Box", + "background": "orange-500", + "radius": "full", + "size": 25 + }, + { + "type": "Text", + "value": "Orange line", + "size": "sm" + } + ] + } + ] + }, + "encodedWidget": "eyJpZCI6IndpZ18xN2U5dWtoaiIsIm5hbWUiOiJVbnRpdGxlZCB3aWRnZXQiLCJ2aWV3IjoiPExpc3RWaWV3PlxuICA8TGlzdFZpZXdJdGVtXG4gICAga2V5PVwiYmx1ZVwiXG4gICAgZ2FwPXs1fVxuICAgIG9uQ2xpY2tBY3Rpb249e3sgdHlwZTogXCJsaW5lLnNlbGVjdFwiLCBwYXlsb2FkOiB7IGlkOiBcImJsdWVcIiB9IH19XG4gID5cbiAgICA8Qm94IGJhY2tncm91bmQ9XCJibHVlLTUwMFwiIHJhZGl1cz1cImZ1bGxcIiBzaXplPXsyNX0gLz5cbiAgICA8VGV4dCB2YWx1ZT1cIkJsdWUgbGluZVwiIHNpemU9XCJzbVwiIC8-XG4gIDwvTGlzdFZpZXdJdGVtPlxuICA8TGlzdFZpZXdJdGVtXG4gICAga2V5PVwib3JhbmdlXCJcbiAgICBnYXA9ezV9XG4gICAgb25DbGlja0FjdGlvbj17eyB0eXBlOiBcImxpbmUuc2VsZWN0XCIsIHBheWxvYWQ6IHsgaWQ6IFwib3JhbmdlXCIgfSB9fVxuICA-XG4gICAgPEJveCBiYWNrZ3JvdW5kPVwib3JhbmdlLTUwMFwiIHJhZGl1cz1cImZ1bGxcIiBzaXplPXsyNX0gLz5cbiAgICA8VGV4dCB2YWx1ZT1cIk9yYW5nZSBsaW5lXCIgc2l6ZT1cInNtXCIgLz5cbiAgPC9MaXN0Vmlld0l0ZW0-XG48L0xpc3RWaWV3PiIsImRlZmF1bHRTdGF0ZSI6e30sInNjaGVtYU1vZGUiOiJ6b2QiLCJqc29uU2NoZW1hIjp7InR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7InRpdGxlIjp7InR5cGUiOiJzdHJpbmcifX0sInJlcXVpcmVkIjpbInRpdGxlIl0sImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZX0sInNjaGVtYSI6ImltcG9ydCB7IHogfSBmcm9tIFwiem9kXCJcblxuY29uc3QgV2lkZ2V0U3RhdGUgPSB6LnN0cmljdE9iamVjdCh7fSlcblxuZXhwb3J0IGRlZmF1bHQgV2lkZ2V0U3RhdGUiLCJzdGF0ZXMiOltdLCJzY2hlbWFWYWxpZGl0eSI6InZhbGlkIiwidmlld1ZhbGlkaXR5IjoidmFsaWQiLCJkZWZhdWx0U3RhdGVWYWxpZGl0eSI6InZhbGlkIn0" +} diff --git a/tests/assets/widgets/list_view_with_data.json b/tests/assets/widgets/list_view_with_data.json new file mode 100644 index 0000000..bb5ab12 --- /dev/null +++ b/tests/assets/widgets/list_view_with_data.json @@ -0,0 +1,77 @@ +{ + "type": "ListView", + "children": [ + { + "type": "ListViewItem", + "key": "blue", + "gap": 5, + "onClickAction": { + "type": "line.select", + "payload": { + "id": "blue" + } + }, + "children": [ + { + "type": "Box", + "background": "blue-500", + "radius": "full", + "size": 25 + }, + { + "type": "Text", + "value": "Blue line", + "size": "sm" + } + ] + }, + { + "type": "ListViewItem", + "key": "orange", + "gap": 5, + "onClickAction": { + "type": "line.select", + "payload": { + "id": "orange" + } + }, + "children": [ + { + "type": "Box", + "background": "orange-500", + "radius": "full", + "size": 25 + }, + { + "type": "Text", + "value": "Orange line", + "size": "sm" + } + ] + }, + { + "type": "ListViewItem", + "key": "purple", + "gap": 5, + "onClickAction": { + "type": "line.select", + "payload": { + "id": "purple" + } + }, + "children": [ + { + "type": "Box", + "background": "purple-500", + "radius": "full", + "size": 25 + }, + { + "type": "Text", + "value": "Purple line", + "size": "sm" + } + ] + } + ] +} diff --git a/tests/assets/widgets/list_view_with_data.widget b/tests/assets/widgets/list_view_with_data.widget new file mode 100644 index 0000000..f5b541a --- /dev/null +++ b/tests/assets/widgets/list_view_with_data.widget @@ -0,0 +1,121 @@ +{ + "version": "1.0", + "name": "list_view_with_data", + "template": "{\"type\":\"ListView\",\"children\":[{%- set _c -%}{%-for item in items -%},{\"type\":\"ListViewItem\",\"key\":{{ (item.id) | tojson }},\"gap\":5,\"onClickAction\":{\"type\":\"line.select\",\"payload\":{\"id\":{{ (item.id) | tojson }}}},\"children\":[{\"type\":\"Box\",\"background\":{{ (item.color) | tojson }},\"radius\":\"full\",\"size\":25},{\"type\":\"Text\",\"value\":{{ (item.label) | tojson }},\"size\":\"sm\"}]}{%-endfor-%}{%- endset -%}{{- (_c[1:] if _c and _c[0] == ',' else _c) -}}]}", + "jsonSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "color": { + "type": "string", + "enum": [ + "blue-500", + "orange-500", + "purple-500" + ] + } + }, + "required": [ + "id", + "label", + "color" + ], + "additionalProperties": false + } + } + }, + "required": [ + "items" + ], + "additionalProperties": false + }, + "outputJsonPreview": { + "type": "ListView", + "children": [ + { + "type": "ListViewItem", + "key": "blue", + "gap": 5, + "onClickAction": { + "type": "line.select", + "payload": { + "id": "blue" + } + }, + "children": [ + { + "type": "Box", + "background": "blue-500", + "radius": "full", + "size": 25 + }, + { + "type": "Text", + "value": "Blue line", + "size": "sm" + } + ] + }, + { + "type": "ListViewItem", + "key": "orange", + "gap": 5, + "onClickAction": { + "type": "line.select", + "payload": { + "id": "orange" + } + }, + "children": [ + { + "type": "Box", + "background": "orange-500", + "radius": "full", + "size": 25 + }, + { + "type": "Text", + "value": "Orange line", + "size": "sm" + } + ] + }, + { + "type": "ListViewItem", + "key": "purple", + "gap": 5, + "onClickAction": { + "type": "line.select", + "payload": { + "id": "purple" + } + }, + "children": [ + { + "type": "Box", + "background": "purple-500", + "radius": "full", + "size": 25 + }, + { + "type": "Text", + "value": "Purple line", + "size": "sm" + } + ] + } + ] + }, + "encodedWidget": "eyJpZCI6ImIyZTVmZTllLWVhOWItNGUyMy1iNjMxLTFmNTRhMDI4Mjg4MCIsIm5hbWUiOiJMaW5lIHNlbGVjdG9yIiwidmlldyI6IjxMaXN0Vmlldz5cbiAge2l0ZW1zLm1hcCgoaXRlbSkgPT4gKFxuICAgIDxMaXN0Vmlld0l0ZW1cbiAgICAgIGtleT17aXRlbS5pZH1cbiAgICAgIGdhcD17NX1cbiAgICAgIG9uQ2xpY2tBY3Rpb249e3sgdHlwZTogXCJsaW5lLnNlbGVjdFwiLCBwYXlsb2FkOiB7IGlkOiBpdGVtLmlkIH0gfX1cbiAgICA-XG4gICAgICA8Qm94IGJhY2tncm91bmQ9e2l0ZW0uY29sb3J9IHJhZGl1cz1cImZ1bGxcIiBzaXplPXsyNX0gLz5cbiAgICAgIDxUZXh0IHZhbHVlPXtpdGVtLmxhYmVsfSBzaXplPVwic21cIiAvPlxuICAgIDwvTGlzdFZpZXdJdGVtPlxuICApKX1cbjwvTGlzdFZpZXc-IiwiZGVmYXVsdFN0YXRlIjp7Iml0ZW1zIjpbeyJpZCI6ImJsdWUiLCJsYWJlbCI6IkJsdWUgbGluZSIsImNvbG9yIjoiYmx1ZS01MDAifSx7ImlkIjoib3JhbmdlIiwibGFiZWwiOiJPcmFuZ2UgbGluZSIsImNvbG9yIjoib3JhbmdlLTUwMCJ9LHsiaWQiOiJwdXJwbGUiLCJsYWJlbCI6IlB1cnBsZSBsaW5lIiwiY29sb3IiOiJwdXJwbGUtNTAwIn1dfSwic3RhdGVzIjpbXSwic2NoZW1hIjoiaW1wb3J0IHsgeiB9IGZyb20gXCJ6b2RcIlxuXG5jb25zdCBMaW5lSXRlbSA9IHouc3RyaWN0T2JqZWN0KHtcbiAgaWQ6IHouc3RyaW5nKCksXG4gIGxhYmVsOiB6LnN0cmluZygpLFxuICBjb2xvcjogei5lbnVtKFtcImJsdWUtNTAwXCIsIFwib3JhbmdlLTUwMFwiLCBcInB1cnBsZS01MDBcIl0pLFxufSlcblxuY29uc3QgV2lkZ2V0U3RhdGUgPSB6LnN0cmljdE9iamVjdCh7XG4gIGl0ZW1zOiB6LmFycmF5KExpbmVJdGVtKSxcbn0pXG5cbmV4cG9ydCBkZWZhdWx0IFdpZGdldFN0YXRlIiwic2NoZW1hTW9kZSI6InpvZCIsImpzb25TY2hlbWEiOnsidHlwZSI6Im9iamVjdCIsInByb3BlcnRpZXMiOnsidGl0bGUiOnsidHlwZSI6InN0cmluZyJ9fSwicmVxdWlyZWQiOlsidGl0bGUiXSwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlfSwic2NoZW1hVmFsaWRpdHkiOiJ2YWxpZCIsInZpZXdWYWxpZGl0eSI6InZhbGlkIiwiZGVmYXVsdFN0YXRlVmFsaWRpdHkiOiJ2YWxpZCJ9" +} diff --git a/tests/test_widgets.py b/tests/test_widgets.py index dff8503..02df23d 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -1,12 +1,19 @@ import json from datetime import datetime -from typing import Literal +from typing import Any, Literal import pytest from chatkit.server import diff_widget from chatkit.types import WidgetItem -from chatkit.widgets import Card, Text, WidgetRoot +from chatkit.widgets import ( + Card, + DynamicWidgetComponent, + DynamicWidgetRoot, + Text, + WidgetRoot, + WidgetTemplate, +) @pytest.mark.parametrize( @@ -28,6 +35,83 @@ Card(children=[Text(value="world!")]), ["widget.root.updated"], ), + # DynamicWidgetRoot tests + ( + DynamicWidgetRoot(type="Card", children=[]), + DynamicWidgetRoot(type="Card", children=[]), + [], + ), + ( + DynamicWidgetRoot( + type="Card", + children=[ + DynamicWidgetComponent.model_validate({ + "type": "Text", + "id": "text", + "value": "Hello", + "streaming": True, + }) + ], + ), + DynamicWidgetRoot( + type="Card", + children=[ + DynamicWidgetComponent.model_validate({ + "type": "Text", + "id": "text", + "value": "Hello, world!", + "streaming": True, + }) + ], + ), + ["widget.streaming_text.value_delta"], + ), + ( + DynamicWidgetRoot( + type="Card", + children=[ + DynamicWidgetComponent.model_validate({ + "type": "Text", + "id": "text", + "value": "Hello", + "streaming": True, + }) + ], + ), + DynamicWidgetRoot( + type="Card", + children=[ + DynamicWidgetComponent.model_validate({ + "type": "Text", + "id": "text", + "value": "Hello, world!", + "streaming": False, + }) + ], + ), + ["widget.root.updated"], + ), + ( + DynamicWidgetRoot( + type="Card", + children=[ + DynamicWidgetComponent.model_validate({ + "type": "Text", + "value": "Hello", + }) + ], + ), + DynamicWidgetRoot( + type="Card", + children=[ + DynamicWidgetComponent.model_validate({ + "type": "Text", + "value": "world!", + }) + ], + ), + ["widget.root.updated"], + ), ], ) def test_diff( @@ -102,3 +186,58 @@ def test_json_dump_excludes_none_fields_nested(): assert "streaming" not in text_dump assert "color" not in text_dump assert "key" not in text_dump + + +@pytest.mark.parametrize( + "widget_name, data", + [ + ("list_view_no_data", None), + ("card_no_data", None), + ( + "list_view_with_data", + { + "items": [ + { + "id": "blue", + "label": "Blue line", + "color": "blue-500", + }, + { + "id": "orange", + "label": "Orange line", + "color": "orange-500", + }, + { + "id": "purple", + "label": "Purple line", + "color": "purple-500", + }, + ], + }, + ), + ( + "card_with_data", + { + "channel": "#proj-chatkit", + "time": "4:48 PM", + "user": { + "image": "/pam.png", + "name": "Pam Beesly", + }, + }, + ), + ], +) +def test_widget_template_from_file( + widget_name: str, + data: dict[str, Any] | None, +): + template = WidgetTemplate.from_file(f"assets/widgets/{widget_name}.widget") + + with open(f"tests/assets/widgets/{widget_name}.json", "r") as file: + expected_widget_dict = json.load(file) + + widget = template.build(data) + + assert isinstance(widget, DynamicWidgetRoot) + assert widget.model_dump(exclude_none=True) == expected_widget_dict diff --git a/uv.lock b/uv.lock index 45f7133..acbe51b 100644 --- a/uv.lock +++ b/uv.lock @@ -822,6 +822,7 @@ name = "openai-chatkit" version = "1.3.1" source = { virtual = "." } dependencies = [ + { name = "jinja2" }, { name = "openai" }, { name = "openai-agents" }, { name = "pydantic" }, @@ -851,6 +852,7 @@ lint = [ [package.metadata] requires-dist = [ + { name = "jinja2" }, { name = "openai" }, { name = "openai-agents", specifier = ">=0.3.2" }, { name = "pydantic" },