Skip to content
Merged
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
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ First release. Renders A2UI generative user interfaces in Jaspr, building on
`copyWith` derives a catalog from an existing one.
- `ComponentScope` hands a builder its resolved properties, its children, and a
way to report errors. An action obtained through it never throws out of a
click handler.
click handler. Its `instanceId` is unique to each rendered instance across
the page, so DOM ids and radio group names derived from it stay distinct
between surfaces and between rows of a template.
- `JasprComponent.styles` declares the default rules for the classes a
component emits, and the `styles` getter `JasprCatalogComposition` adds to a
`Catalog<JasprComponent>` gathers them, so a catalog derived with `copyWith`
Expand Down
37 changes: 25 additions & 12 deletions lib/src/catalog/basic/components/choice_picker.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,24 @@ class ChoicePickerApi extends ComponentApi {
Schema.object(
properties: {
'label': CommonSchemas.dynamicString,
'options': Schema.list(
items: Schema.object(
properties: {
'label': CommonSchemas.dynamicString,
'value': Schema.string(),
},
required: ['label', 'value'],
),
// The reference implementation's `listOrReference`: a literal
// list whose options each bind their own `label`, or a binding or
// function call supplying the whole list. The renderer narrows the
// schema to whichever the value is, so both resolve fully.
'options': Schema.combined(
oneOf: [
Schema.list(
items: Schema.object(
properties: {
'label': CommonSchemas.dynamicString,
'value': Schema.string(),
},
required: ['label', 'value'],
),
),
CommonSchemas.dataBinding,
CommonSchemas.functionCall,
],
),
'value': Schema.combined(
anyOf: [
Expand All @@ -52,8 +62,9 @@ class ChoicePickerApi extends ComponentApi {
/// `multipleSelection` (the default, matching the A2UI reference
/// implementation) renders a checkbox per option and writes back the list of
/// every option currently checked. `mutuallyExclusive` renders radio buttons
/// sharing this component's id as their group name, so only one can ever be
/// checked.
/// sharing a group name unique to this rendered instance, so only one can ever
/// be checked, and a picker with the same id on another surface, or in
/// another row of a template, stays a separate group.
class ChoicePickerComponent extends JasprComponent {
/// Creates a [ChoicePickerComponent].
ChoicePickerComponent();
Expand Down Expand Up @@ -123,9 +134,11 @@ class ChoicePickerComponent extends JasprComponent {
Component build(ComponentScope scope) {
final labelText = scope.string('label');
final isMulti = scope.string('variant') != 'mutuallyExclusive';
// A bound list comes straight from the data model, which may hold
// anything, or nothing yet while the path is unset. Only maps are options.
final rawOptions = scope.props['options'];
final options = (rawOptions is List ? rawOptions : const <Object?>[])
.cast<Map<Object?, Object?>>();
.whereType<Map<Object?, Object?>>();
final write = scope.setter('value');
final errors = scope.validationErrors;

Expand All @@ -139,7 +152,7 @@ class ChoicePickerComponent extends JasprComponent {
], classes: 'a2ui-choice-picker__label'),
for (final option in options)
_option(
groupName: scope.id,
groupName: scope.instanceId,
option: option,
isMulti: isMulti,
selected: selected,
Expand Down
2 changes: 1 addition & 1 deletion lib/src/catalog/basic/components/modal.dart
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ class ModalComponent extends JasprComponent {
final contentId = scope.string('content');

return _Modal(
id: scope.id,
id: scope.instanceId,
trigger: triggerId == null
? const Component.empty()
: scope.buildChild(triggerId),
Expand Down
6 changes: 5 additions & 1 deletion lib/src/catalog/basic/components/tabs.dart
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,11 @@ class TabsComponent extends JasprComponent {
]
: const <_Tab>[];

return _Tabs(id: scope.id, tabs: tabs, buildChild: scope.buildChild);
return _Tabs(
id: scope.instanceId,
tabs: tabs,
buildChild: scope.buildChild,
);
}
}

Expand Down
11 changes: 11 additions & 0 deletions lib/src/catalog/jaspr_component.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ final class ComponentScope {
/// Creates a [ComponentScope].
const ComponentScope({
required this.id,
required this.instanceId,
required this.type,
required this.props,
required this.theme,
Expand All @@ -23,6 +24,16 @@ final class ComponentScope {
/// This component's id within its surface.
final String id;

/// A key for this rendered instance, unique across the whole page.
///
/// [id] is only unique within one surface, and a component repeated by a
/// template renders once per row under the same [id]. Derive DOM `id`s and
/// radio group `name`s from this instead, so two surfaces, or two rows,
/// never share one. It is built from the surface id, [id] and the row's
/// data-model path, so the server and the hydrated client agree on it.
/// Treat it as opaque: its format is the renderer's to change.
final String instanceId;

/// The component type, as named in the catalog.
final String type;

Expand Down
94 changes: 94 additions & 0 deletions lib/src/rendering/narrowed_schema.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import 'package:json_schema_builder/json_schema_builder.dart';

/// A component's schema with each list-or-binding property narrowed to the
/// alternative its current value actually takes.
///
/// `a2ui_core`'s binder decides how to resolve a property once, from its
/// schema alone. A property that may be either a literal list or a binding to
/// a whole list, like the reference implementation's `listOrReference`, reads
/// as a binding, and a binding resolves its value as one opaque whole. A
/// literal list would then arrive with every binding inside its elements left
/// unresolved, such as each of `ChoicePicker`'s options' own `label`.
///
/// So wherever the value is a literal list, the binder is handed the list
/// alternative instead, which resolves element by element. A binding or a
/// function call keeps the schema as written and is watched as a whole.
typedef NarrowedSchema = ({Schema schema, Set<String> narrowed});

/// Narrows [schema] against a component's raw [properties].
///
/// The result's `narrowed` names the properties that took their list
/// alternative, so a caller can tell when a later update needs a fresh binder
/// rather than a re-resolve against the old one.
NarrowedSchema narrowSchema(Schema schema, Map<String, dynamic> properties) {
final narrowed = <String>{};
final result = _narrow(schema.value, properties, narrowed);
if (narrowed.isEmpty) return (schema: schema, narrowed: narrowed);
return (schema: Schema.fromMap(result), narrowed: narrowed);
}

Map<String, Object?> _narrow(
Map<String, Object?> schema,
Map<String, dynamic> properties,
Set<String> narrowed,
) {
final result = Map<String, Object?>.of(schema);

// The binder gathers properties from every branch of a combined schema, so
// the narrowing looks in the same places.
for (final key in const ['allOf', 'anyOf', 'oneOf']) {
final branches = schema[key];
if (branches is List) {
// A branch that is not a map, such as a boolean schema, has no
// properties to narrow and passes through as written.
result[key] = branches
.map(
(branch) => branch is Map<String, Object?>
? _narrow(branch, properties, narrowed)
: branch,
)
.toList();
}
}

final shape = schema['properties'];
if (shape is Map<String, Object?>) {
final next = Map<String, Object?>.of(shape);
for (final entry in shape.entries) {
final alternative = _listAlternative(entry.value);
if (alternative != null && properties[entry.key] is List) {
next[entry.key] = alternative;
narrowed.add(entry.key);
}
}
result['properties'] = next;
}
return result;
}

/// The literal-list alternative of a property that may also be a binding, or
/// null when [property] is not that shape.
///
/// Requires a genuine data binding among the alternatives, a `path` with no
/// `componentId`. A child list's template also carries a `path`, but it names
/// a component to repeat, and narrowing it away would stop a literal child
/// list from resolving to child references.
Object? _listAlternative(Object? property) {
if (property is! Map) return null;
final alternatives = [
for (final key in const ['anyOf', 'oneOf'])
if (property[key] case final List<Object?> list) ...list,
];
final list = alternatives.firstWhere(
(alternative) => alternative is Map && alternative['type'] == 'array',
orElse: () => null,
);
final hasBinding = alternatives.any((alternative) {
if (alternative is! Map) return false;
final Object? shape = alternative['properties'];
return shape is Map &&
shape.containsKey('path') &&
!shape.containsKey('componentId');
});
return hasBinding ? list : null;
}
41 changes: 40 additions & 1 deletion lib/src/rendering/surface.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'dart:async';
import 'package:a2ui_core/a2ui_core.dart';
import 'package:genui_jaspr/src/catalog/jaspr_component.dart';
import 'package:genui_jaspr/src/conversation/client_messages.dart';
import 'package:genui_jaspr/src/rendering/narrowed_schema.dart';
import 'package:genui_jaspr/src/rendering/signal_builder.dart';
import 'package:genui_jaspr/src/rendering/theme_properties.dart';
import 'package:jaspr/dom.dart';
Expand Down Expand Up @@ -182,6 +183,7 @@ class _A2uiComponentState extends State<A2uiComponent> {
GenericBinder? _binder;
JasprComponent? _entry;
ComponentModel? _model;
Set<String> _narrowed = const {};

@override
void initState() {
Expand Down Expand Up @@ -246,17 +248,43 @@ class _A2uiComponentState extends State<A2uiComponent> {
if (entry == null) return;
_entry = entry;

final narrowed = narrowSchema(entry.schema, model.properties);
_narrowed = narrowed.narrowed;
model.onUpdated.addListener(_onModelUpdated);
_binder = GenericBinder(
ComponentContext(component.surface, model, basePath: component.basePath),
entry.schema,
narrowed.schema,
);
}

/// Re-binds when an update moves a list-or-binding property between a
/// literal list and a binding.
///
/// The binder re-resolves an edited component on its own, but against the
/// schema it was built with, which was narrowed for the previous value. A
/// property that became a literal list would keep resolving as one opaque
/// value, and one that became a binding would never be watched.
void _onModelUpdated(ComponentModel model) {
final entry = _entry;
if (entry == null || !mounted) return;
final narrowed = narrowSchema(entry.schema, model.properties).narrowed;
if (narrowed.length == _narrowed.length &&
narrowed.containsAll(_narrowed)) {
return;
}
setState(() {
_unbind();
_bind();
});
}

void _unbind() {
_model?.onUpdated.removeListener(_onModelUpdated);
_binder?.dispose();
_binder = null;
_entry = null;
_model = null;
_narrowed = const {};
}

@override
Expand Down Expand Up @@ -290,6 +318,7 @@ class _A2uiComponentState extends State<A2uiComponent> {
builder: (context, props) => entry.build(
ComponentScope(
id: component.componentId,
instanceId: _instanceId,
type: model.type,
props: props,
theme: component.surface.theme,
Expand All @@ -301,6 +330,16 @@ class _A2uiComponentState extends State<A2uiComponent> {
);
}

/// Joins the parts that together identify this instance, each encoded so a
/// separator inside one part cannot make two different instances collide.
/// The root data-model path is left off, so a component outside any
/// template reads as just its surface and id.
String get _instanceId => [
component.surface.id,
component.componentId,
if (component.basePath != '/') component.basePath,
].map(Uri.encodeComponent).join(':');

Component _buildChild(String componentId) {
return A2uiComponent(
surface: component.surface,
Expand Down
45 changes: 42 additions & 3 deletions test/browser/choice_picker_browser_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@ library;
import 'package:a2ui_core/a2ui_core.dart';
import 'package:genui_jaspr/genui_jaspr.dart';
import 'package:genui_jaspr/src/catalog/basic/components/choice_picker.dart';
import 'package:jaspr/dom.dart';
import 'package:jaspr_test/client_test.dart';
import 'package:universal_web/web.dart' as web;

/// The hop a VM test cannot reach: a real click on a real radio or checkbox
/// reaching the data model.
SurfaceModel<JasprComponent> surfaceWith(
List<Map<String, dynamic>> components, {
Map<String, Object?> data = const {},
String surfaceId = 'main',
}) {
final catalog = MinimalJasprCatalog().copyWith(
add: [ChoicePickerComponent()],
Expand All @@ -20,17 +23,17 @@ SurfaceModel<JasprComponent> surfaceWith(
A2uiMessage.fromJson({
'version': 'v0.9',
'createSurface': {
'surfaceId': 'main',
'surfaceId': surfaceId,
'catalogId': catalog.id,
'sendDataModel': true,
},
}),
A2uiMessage.fromJson({
'version': 'v0.9',
'updateComponents': {'surfaceId': 'main', 'components': components},
'updateComponents': {'surfaceId': surfaceId, 'components': components},
}),
]);
final surface = processor.groupModel.getSurface('main')!;
final surface = processor.groupModel.getSurface(surfaceId)!;
data.forEach(surface.dataModel.set);
return surface;
}
Expand Down Expand Up @@ -65,6 +68,42 @@ void main() {
expect(surface.dataModel.get('/colour'), 'blue');
});

testClient(
'picking a radio leaves a same-id picker on another surface alone',
(tester) async {
List<Map<String, dynamic>> picker() => pickerSurface({
'variant': 'mutuallyExclusive',
'value': {'path': '/colour'},
});
final left = surfaceWith(
picker(),
data: {'/colour': 'red'},
surfaceId: 'left',
);
final right = surfaceWith(
picker(),
data: {'/colour': 'red'},
surfaceId: 'right',
);

tester.pumpComponent(
div([Surface(surface: left), Surface(surface: right)]),
);

await tester.click(find.tag('input').at(1));

// Radios sharing a name form one group across the whole document, so
// checking the left picker's Blue would silently uncheck the right
// picker's Red without either data model hearing about it.
final rightRed =
web.document.querySelectorAll('input').item(2)!
as web.HTMLInputElement;
expect(left.dataModel.get('/colour'), 'blue');
expect(right.dataModel.get('/colour'), 'red');
expect(rightRed.checked, isTrue);
},
);

testClient('checking a box adds its value to the list', (tester) async {
final surface = surfaceWith(
pickerSurface({
Expand Down
Loading
Loading