-
Notifications
You must be signed in to change notification settings - Fork 810
feat: Support component deletion via component: null #528
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -148,12 +148,18 @@ This message signals the client to create a new surface and begin rendering it. | |
|
|
||
| ### `updateComponents` | ||
|
|
||
| This message provides a list of UI components to be added to or updated within a specific surface. The components are provided as a flat list, and their relationships are defined by ID references in an adjacency list. This message may only be sent to a surface that has already been created. Note that components may reference children or data bindings that do not yet exist; clients should handle this gracefully by rendering placeholders (progressive rendering). | ||
| This message provides a list of UI components to be added, updated, or removed within a specific surface. The components are provided as a flat list, and their relationships are defined by ID references in an adjacency list. This message may only be sent to a surface that has already been created. Note that components may reference children or data bindings that do not yet exist; clients should handle this gracefully by rendering placeholders (progressive rendering). | ||
|
|
||
| **Adding or Updating Components:** | ||
| To add or update a component, provide the full component definition object. If a component with the same `id` already exists, it will be replaced. | ||
|
|
||
| **Deleting Components:** | ||
| To delete a component, provide an object with the component's `id` and omit the `component` property. This removes the component from the client's memory. | ||
|
|
||
| **Properties:** | ||
|
|
||
| - `surfaceId` (string, required): The unique identifier for the UI surface to be updated. This is typically a name with meaning (e.g. "user_profile_card"), and it has to be unique within the context of the GenUI session. | ||
| - `components` (array, required): A list of component objects. The components are provided as a flat list, and their relationships are defined by ID references in an adjacency list. | ||
| - `components` (array, required): A list of component objects (for updates) or tombstone objects (for deletions). | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What about the adjacency list description? I think we should keep that.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh yes, let's reinstate that sorry! |
||
|
|
||
| **Example:** | ||
|
|
||
|
|
@@ -180,6 +186,22 @@ This message provides a list of UI components to be added to or updated within a | |
| ] | ||
| } | ||
| } | ||
|
|
||
| { | ||
| "updateComponents": { | ||
| "surfaceId": "user_profile_card", | ||
| "components": [ | ||
| { | ||
| "id": "root", | ||
| "component": "Column", | ||
| "children": ["user_name"] | ||
| }, | ||
| { | ||
| "id": "user_title" | ||
| } | ||
| ] | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ### `updateDataModel` | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -297,8 +297,14 @@ export class Validator { | |||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| const componentType = component.component; | ||||||||||||||||
| if (!componentType || typeof componentType !== "string") { | ||||||||||||||||
| errors.push(`Component '${id}' is missing 'component' property.`); | ||||||||||||||||
|
|
||||||||||||||||
| // Handle deletion (tombstone) | ||||||||||||||||
| if (componentType === undefined || componentType === null) { | ||||||||||||||||
| return; | ||||||||||||||||
| } | ||||||||||||||||
|
Comment on lines
+302
to
+304
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This check is more permissive than what the JSON schema allows. The schema (
Suggested change
|
||||||||||||||||
|
|
||||||||||||||||
| if (typeof componentType !== "string") { | ||||||||||||||||
| errors.push(`Component '${id}' has invalid 'component' property.`); | ||||||||||||||||
| return; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| { | ||
| "schema": "server_to_client.json", | ||
| "tests": [ | ||
| { | ||
| "description": "UpdateComponents with component deletion (tombstone)", | ||
| "valid": true, | ||
| "data": { | ||
| "updateComponents": { | ||
| "surfaceId": "test_surface", | ||
| "components": [ | ||
| { | ||
| "id": "item_to_delete" | ||
| } | ||
| ] | ||
| } | ||
| } | ||
| }, | ||
| { | ||
| "description": "UpdateComponents mixing update and deletion", | ||
| "valid": true, | ||
| "data": { | ||
| "updateComponents": { | ||
| "surfaceId": "test_surface", | ||
| "components": [ | ||
| { | ||
| "id": "new_item", | ||
| "component": "Text", | ||
| "text": "Hello" | ||
| }, | ||
| { | ||
| "id": "old_item" | ||
| } | ||
| ] | ||
| } | ||
| } | ||
| }, | ||
| { | ||
| "description": "Deletion with extra properties (should be invalid)", | ||
| "valid": false, | ||
| "data": { | ||
| "updateComponents": { | ||
| "surfaceId": "test_surface", | ||
| "components": [ | ||
| { | ||
| "id": "item_to_delete", | ||
| "text": "Should not be here" | ||
| } | ||
| ] | ||
| } | ||
| } | ||
| }, | ||
| { | ||
| "description": "Deletion missing ID (should be invalid)", | ||
| "valid": false, | ||
| "data": { | ||
| "updateComponents": { | ||
| "surfaceId": "test_surface", | ||
| "components": [ | ||
| {} | ||
| ] | ||
| } | ||
| } | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These test cases are great for verifying the deletion-by-omission behavior. To make the contract even more explicit and prevent future regressions, it would be beneficial to add a test case that asserts that using },
{
"description": "Deletion with component: null (should be invalid per schema)",
"valid": false,
"data": {
"updateComponents": {
"surfaceId": "test_surface",
"components": [
{
"id": "item_to_delete",
"component": null
}
]
}
}
} |
||
| ] | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,4 @@ | ||
| {"createSurface":{"surfaceId":"contact_form_1","catalogId":"https://a2ui.dev/specification/v0_9/standard_catalog.json"}} | ||
| {"updateComponents":{"surfaceId":"contact_form_1","components":[{"id":"root","component":"Card","child":"form_container"},{"id":"form_container","component":"Column","children":["header_row","name_row","email_group","phone_group","pref_group","divider_1","newsletter_checkbox","submit_button"],"justify":"start","align":"stretch"},{"id":"header_row","component":"Row","children":["header_icon","header_text"],"align":"center"},{"id":"header_icon","component":"Icon","name":"mail"},{"id":"header_text","component":"Text","text":"# Contact Us","variant":"h2"},{"id":"name_row","component":"Row","children":["first_name_group","last_name_group"],"justify":"spaceBetween"},{"id":"first_name_group","component":"Column","children":["first_name_label","first_name_field"],"weight":1},{"id":"first_name_label","component":"Text","text":"First Name","variant":"caption"},{"id":"first_name_field","component":"TextField","label":"First Name","value":{"path":"/contact/firstName"},"variant":"shortText"},{"id":"last_name_group","component":"Column","children":["last_name_label","last_name_field"],"weight":1},{"id":"last_name_label","component":"Text","text":"Last Name","variant":"caption"},{"id":"last_name_field","component":"TextField","label":"Last Name","value":{"path":"/contact/lastName"},"variant":"shortText"},{"id":"email_group","component":"Column","children":["email_label","email_field"]},{"id":"email_label","component":"Text","text":"Email Address","variant":"caption"},{"id":"email_field","component":"TextField","label":"Email","value":{"path":"/contact/email"},"variant":"shortText","checks":[{"call":"required","args":[{"path":"/contact/email"}],"message":"Email is required."},{"call":"email","args":[{"path":"/contact/email"}],"message":"Please enter a valid email address."}]},{"id":"phone_group","component":"Column","children":["phone_label","phone_field"]},{"id":"phone_label","component":"Text","text":"Phone Number","variant":"caption"},{"id":"phone_field","component":"TextField","label":"Phone","value":{"path":"/contact/phone"},"variant":"shortText","checks":[{"call":"regex","args":[{"path":"/contact/phone"},"^\\d{10}$\\"],"message":"Phone number must be 10 digits."}]},{"id":"pref_group","component":"Column","children":["pref_label","pref_picker"]},{"id":"pref_label","component":"Text","text":"Preferred Contact Method","variant":"caption"},{"id":"pref_picker","component":"ChoicePicker","variant":"mutuallyExclusive","options":[{"label":"Email","value":"email"},{"label":"Phone","value":"phone"},{"label":"SMS","value":"sms"}],"value":{"path":"/contact/preference"}},{"id":"divider_1","component":"Divider","axis":"horizontal"},{"id":"newsletter_checkbox","component":"CheckBox","label":"Subscribe to our newsletter","value":{"path":"/contact/subscribe"}},{"id":"submit_button_label","component":"Text","text":"Send Message"},{"id":"submit_button","component":"Button","child":"submit_button_label","variant":"primary","action":{"name":"submitContactForm","context":{"formId":"contact_form_1","clientTime":{"call":"now","returnType":"string"},"isNewsletterSubscribed":{"path":"/contact/subscribe"}}}}}]}} | ||
| {"updateComponents": {"surfaceId": "contact_form_1", "components": [{"id": "root", "component": "Card", "child": "form_container"}, {"id": "form_container", "component": "Column", "children": ["header_row", "name_row", "email_group", "phone_group", "pref_group", "divider_1", "newsletter_checkbox", "submit_button"], "justify": "start", "align": "stretch"}, {"id": "header_row", "component": "Row", "children": ["header_icon", "header_text"], "align": "center"}, {"id": "header_icon", "component": "Icon", "name": "mail"}, {"id": "header_text", "component": "Text", "text": "# Contact Us", "variant": "h2"}, {"id": "name_row", "component": "Row", "children": ["first_name_group", "last_name_group"], "justify": "spaceBetween"}, {"id": "first_name_group", "component": "Column", "children": ["first_name_label", "first_name_field"], "weight": 1}, {"id": "first_name_label", "component": "Text", "text": "First Name", "variant": "caption"}, {"id": "first_name_field", "component": "TextField", "label": "First Name", "value": {"path": "/contact/firstName"}, "variant": "shortText"}, {"id": "last_name_group", "component": "Column", "children": ["last_name_label", "last_name_field"], "weight": 1}, {"id": "last_name_label", "component": "Text", "text": "Last Name", "variant": "caption"}, {"id": "last_name_field", "component": "TextField", "label": "Last Name", "value": {"path": "/contact/lastName"}, "variant": "shortText"}, {"id": "email_group", "component": "Column", "children": ["email_label", "email_field"]}, {"id": "email_label", "component": "Text", "text": "Email Address", "variant": "caption"}, {"id": "email_field", "component": "TextField", "label": "Email", "value": {"path": "/contact/email"}, "variant": "shortText", "checks": [{"call": "required", "args": [{"path": "/contact/email"}], "message": "Email is required."}, {"call": "email", "args": [{"path": "/contact/email"}], "message": "Please enter a valid email address."}]}, {"id": "phone_group", "component": "Column", "children": ["phone_label", "phone_field"]}, {"id": "phone_label", "component": "Text", "text": "Phone Number", "variant": "caption"}, {"id": "phone_field", "component": "TextField", "label": "Phone", "value": {"path": "/contact/phone"}, "variant": "shortText", "checks": [{"call": "regex", "args": [{"path": "/contact/phone"}, "^\\d{10}$"], "message": "Phone number must be 10 digits."}]}, {"id": "pref_group", "component": "Column", "children": ["pref_label", "pref_picker"]}, {"id": "pref_label", "component": "Text", "text": "Preferred Contact Method", "variant": "caption"}, {"id": "pref_picker", "component": "ChoicePicker", "variant": "mutuallyExclusive", "options": [{"label": "Email", "value": "email"}, {"label": "Phone", "value": "phone"}, {"label": "SMS", "value": "sms"}], "value": {"path": "/contact/preference"}}, {"id": "divider_1", "component": "Divider", "axis": "horizontal"}, {"id": "newsletter_checkbox", "component": "CheckBox", "label": "Subscribe to our newsletter", "value": {"path": "/contact/subscribe"}}, {"id": "submit_button_label", "component": "Text", "text": "Send Message"}, {"id": "submit_button", "component": "Button", "child": "submit_button_label", "variant": "primary", "action": {"event": {"name": "submitContactForm", "context": {"formId": "contact_form_1", "clientTime": {"call": "now", "returnType": "string"}, "isNewsletterSubscribed": {"path": "/contact/subscribe"}}}}}]}} | ||
| {"updateDataModel":{"surfaceId":"contact_form_1","path":"/contact","value":{"firstName":"John","lastName":"Doe","email":"john.doe@example.com","phone":"1234567890","preference":["email"],"subscribe":true}}} | ||
| {"deleteSurface":{"surfaceId":"contact_form_1"}} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This documentation correctly states that deletion is done by omitting the
componentproperty, which aligns with the JSON schema. However, the pull request title (feat: Support component deletion via component: null) and description are inconsistent with this, as they state deletion is done viacomponent: null. Please update the PR title and description to match the implemented behavior to avoid confusion.