diff --git a/lib/core/sdui/sdui_form_builder.dart b/lib/core/sdui/sdui_form_builder.dart new file mode 100644 index 00000000..d34f3699 --- /dev/null +++ b/lib/core/sdui/sdui_form_builder.dart @@ -0,0 +1,267 @@ +import 'package:flutter/material.dart' as material; +import 'package:file_selector/file_selector.dart'; +import 'package:querya_desktop/core/sdui/sdui_form_schema.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Renders a connection / settings form from an SDUI JSON schema (Block A). +/// +/// Call [collectValues] after the user submits; returns `null` when validation +/// fails. Password fields are included in the map — the host should persist +/// them via `ConnectionSecretsStore`, never via the plugin process disk. +class SduiFormBuilder extends material.StatefulWidget { + const SduiFormBuilder({ + super.key, + required this.schema, + this.initialValues = const {}, + this.onChanged, + this.filePicker, + }); + + final SduiFormSchema schema; + final Map initialValues; + final void Function(Map values)? onChanged; + + /// Injectable file picker for tests. Defaults to `openFile`. + final Future Function(SduiFormField field)? filePicker; + + @override + material.State createState() => SduiFormBuilderState(); +} + +class SduiFormBuilderState extends material.State { + final _formKey = material.GlobalKey(); + final Map _textControllers = {}; + final Map _checkboxValues = {}; + final Map _selectValues = {}; + + @override + void initState() { + super.initState(); + _hydrate(); + } + + @override + void didUpdateWidget(covariant SduiFormBuilder oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.schema != widget.schema) { + _disposeControllers(); + _hydrate(); + } + } + + void _hydrate() { + for (final field in widget.schema.fields) { + final initial = widget.initialValues[field.id] ?? field.defaultValue; + switch (field.type) { + case SduiFieldType.checkbox: + _checkboxValues[field.id] = initial == true || initial == 'true'; + case SduiFieldType.select: + _selectValues[field.id] = initial?.toString() ?? + (field.options.isNotEmpty ? field.options.first.value : null); + case SduiFieldType.text: + case SduiFieldType.number: + case SduiFieldType.password: + case SduiFieldType.filePicker: + _textControllers[field.id] = material.TextEditingController( + text: initial?.toString() ?? '', + )..addListener(_notifyChanged); + } + } + } + + void _notifyChanged() { + widget.onChanged?.call(snapshotValues()); + } + + /// Current values without validating required fields. + Map snapshotValues() { + final out = {}; + for (final field in widget.schema.fields) { + switch (field.type) { + case SduiFieldType.checkbox: + out[field.id] = _checkboxValues[field.id] ?? false; + case SduiFieldType.select: + out[field.id] = _selectValues[field.id]; + case SduiFieldType.number: + final raw = _textControllers[field.id]?.text.trim() ?? ''; + if (raw.isEmpty) { + out[field.id] = null; + } else { + out[field.id] = num.tryParse(raw) ?? raw; + } + case SduiFieldType.text: + case SduiFieldType.password: + case SduiFieldType.filePicker: + final text = _textControllers[field.id]?.text ?? ''; + out[field.id] = text; + } + } + return out; + } + + /// Validates the form and returns values, or `null` if invalid. + Map? collectValues() { + final valid = _formKey.currentState?.validate() ?? false; + if (!valid) return null; + return snapshotValues(); + } + + /// Ids of password fields (for secure storage by the host). + List get passwordFieldIds => widget.schema.fields + .where((f) => f.type == SduiFieldType.password) + .map((f) => f.id) + .toList(growable: false); + + @override + void dispose() { + _disposeControllers(); + super.dispose(); + } + + void _disposeControllers() { + for (final c in _textControllers.values) { + c.dispose(); + } + _textControllers.clear(); + _checkboxValues.clear(); + _selectValues.clear(); + } + + Future _pickFile(SduiFormField field) async { + final picker = widget.filePicker; + final path = picker != null + ? await picker(field) + : (await openFile())?.path; + if (path == null || !mounted) return; + _textControllers[field.id]?.text = path; + _notifyChanged(); + setState(() {}); + } + + @override + material.Widget build(material.BuildContext context) { + return material.Form( + key: _formKey, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + mainAxisSize: material.MainAxisSize.min, + children: [ + if (widget.schema.title != null) ...[ + Text(widget.schema.title!).large().semiBold(), + const Gap(12), + ], + for (var i = 0; i < widget.schema.fields.length; i++) ...[ + if (i > 0) const Gap(12), + _buildField(widget.schema.fields[i]), + ], + ], + ), + ); + } + + material.Widget _buildField(SduiFormField field) { + switch (field.type) { + case SduiFieldType.checkbox: + return material.CheckboxListTile( + contentPadding: material.EdgeInsets.zero, + title: Text(field.label), + value: _checkboxValues[field.id] ?? false, + controlAffinity: material.ListTileControlAffinity.leading, + onChanged: (v) { + setState(() => _checkboxValues[field.id] = v ?? false); + _notifyChanged(); + }, + ); + case SduiFieldType.select: + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + Text(field.label).small().semiBold(), + const Gap(4), + material.DropdownButtonFormField( + initialValue: _selectValues[field.id], + items: [ + for (final opt in field.options) + material.DropdownMenuItem( + value: opt.value, + child: material.Text(opt.label), + ), + ], + onChanged: (v) { + setState(() => _selectValues[field.id] = v); + _notifyChanged(); + }, + validator: field.required + ? (v) => + (v == null || v.isEmpty) ? '${field.label} is required' : null + : null, + ), + ], + ); + case SduiFieldType.filePicker: + final controller = _textControllers[field.id]!; + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + Text(field.label).small().semiBold(), + const Gap(4), + material.Row( + children: [ + material.Expanded( + child: material.TextFormField( + controller: controller, + decoration: material.InputDecoration( + hintText: field.placeholder ?? 'Path…', + ), + validator: _validatorFor(field), + ), + ), + const Gap(8), + OutlineButton( + onPressed: () => _pickFile(field), + child: const Text('Browse'), + ), + ], + ), + ], + ); + case SduiFieldType.text: + case SduiFieldType.number: + case SduiFieldType.password: + final controller = _textControllers[field.id]!; + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + Text(field.label).small().semiBold(), + const Gap(4), + material.TextFormField( + controller: controller, + obscureText: field.type == SduiFieldType.password, + keyboardType: field.type == SduiFieldType.number + ? material.TextInputType.number + : material.TextInputType.text, + decoration: material.InputDecoration( + hintText: field.placeholder, + ), + validator: _validatorFor(field), + ), + ], + ); + } + } + + material.FormFieldValidator? _validatorFor(SduiFormField field) { + return (value) { + final text = value?.trim() ?? ''; + if (field.required && text.isEmpty) { + return '${field.label} is required'; + } + if (field.type == SduiFieldType.number && text.isNotEmpty) { + if (num.tryParse(text) == null) { + return '${field.label} must be a number'; + } + } + return null; + }; + } +} diff --git a/lib/core/sdui/sdui_form_schema.dart b/lib/core/sdui/sdui_form_schema.dart new file mode 100644 index 00000000..bb157d18 --- /dev/null +++ b/lib/core/sdui/sdui_form_schema.dart @@ -0,0 +1,108 @@ +/// Field kinds supported by [SduiFormBuilder] (Block A §2.1). +enum SduiFieldType { + text('text'), + number('number'), + password('password'), + checkbox('checkbox'), + select('select'), + filePicker('file_picker'); + + const SduiFieldType(this.value); + final String value; + + static SduiFieldType fromString(String? value) { + return SduiFieldType.values.firstWhere( + (t) => t.value == value, + orElse: () => SduiFieldType.text, + ); + } +} + +class SduiSelectOption { + const SduiSelectOption({required this.value, required this.label}); + + final String value; + final String label; + + factory SduiSelectOption.fromJson(Map json) { + return SduiSelectOption( + value: '${json['value'] ?? ''}', + label: '${json['label'] ?? json['value'] ?? ''}', + ); + } +} + +class SduiFormField { + const SduiFormField({ + required this.id, + required this.type, + required this.label, + this.required = false, + this.placeholder, + this.defaultValue, + this.options = const [], + }); + + final String id; + final SduiFieldType type; + final String label; + final bool required; + final String? placeholder; + final Object? defaultValue; + final List options; + + factory SduiFormField.fromJson(Map json) { + final optionsRaw = json['options']; + final options = []; + if (optionsRaw is List) { + for (final item in optionsRaw) { + if (item is Map) { + options.add(SduiSelectOption.fromJson(item)); + } else if (item is Map) { + options.add(SduiSelectOption.fromJson(Map.from(item))); + } else if (item != null) { + options.add(SduiSelectOption(value: '$item', label: '$item')); + } + } + } + + return SduiFormField( + id: '${json['id'] ?? json['name'] ?? ''}', + type: SduiFieldType.fromString(json['type'] as String?), + label: '${json['label'] ?? json['id'] ?? ''}', + required: json['required'] == true, + placeholder: json['placeholder'] as String?, + defaultValue: json['default'] ?? json['defaultValue'], + options: options, + ); + } +} + +/// Schema returned by `extension.getConnectionForm`. +class SduiFormSchema { + const SduiFormSchema({ + this.title, + this.fields = const [], + }); + + final String? title; + final List fields; + + factory SduiFormSchema.fromJson(Map json) { + final fieldsRaw = json['fields']; + final fields = []; + if (fieldsRaw is List) { + for (final item in fieldsRaw) { + if (item is Map) { + fields.add(SduiFormField.fromJson(item)); + } else if (item is Map) { + fields.add(SduiFormField.fromJson(Map.from(item))); + } + } + } + return SduiFormSchema( + title: json['title'] as String?, + fields: fields, + ); + } +} diff --git a/lib/core/sdui/sdui_tree_builder.dart b/lib/core/sdui/sdui_tree_builder.dart new file mode 100644 index 00000000..37f551e9 --- /dev/null +++ b/lib/core/sdui/sdui_tree_builder.dart @@ -0,0 +1,177 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/sdui/sdui_tree_schema.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Renders a sidebar-style tree from an SDUI schema with lazy expansion. +class SduiTreeBuilder extends material.StatefulWidget { + const SduiTreeBuilder({ + super.key, + required this.schema, + this.fetchChildren, + this.onNodeSelected, + }); + + final SduiTreeSchema schema; + final SduiFetchTreeChildren? fetchChildren; + final void Function(SduiTreeNode node)? onNodeSelected; + + @override + material.State createState() => SduiTreeBuilderState(); +} + +class SduiTreeBuilderState extends material.State { + late List _roots; + final Set _loading = {}; + final Set _loaded = {}; + final Set _expanded = {}; + + @override + void initState() { + super.initState(); + _roots = List.from(widget.schema.roots); + } + + @override + void didUpdateWidget(covariant SduiTreeBuilder oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.schema != widget.schema) { + _roots = List.from(widget.schema.roots); + _loading.clear(); + _loaded.clear(); + _expanded.clear(); + } + } + + Future _onExpand(SduiTreeNode node) async { + setState(() => _expanded.add(node.id)); + if (!node.expandable || _loaded.contains(node.id) || node.hasChildren) { + return; + } + final fetch = widget.fetchChildren; + if (fetch == null) return; + + setState(() => _loading.add(node.id)); + try { + final children = await fetch(node.id); + if (!mounted) return; + setState(() { + _roots = _replaceNode(_roots, node.id, (n) => n.copyWith(children: children)); + _loaded.add(node.id); + _loading.remove(node.id); + }); + } catch (_) { + if (!mounted) return; + setState(() => _loading.remove(node.id)); + } + } + + void _onCollapse(SduiTreeNode node) { + setState(() => _expanded.remove(node.id)); + } + + List _replaceNode( + List nodes, + String id, + SduiTreeNode Function(SduiTreeNode) update, + ) { + return [ + for (final node in nodes) + if (node.id == id) + update(node) + else if (node.children.isNotEmpty) + node.copyWith(children: _replaceNode(node.children, id, update)) + else + node, + ]; + } + + @override + material.Widget build(material.BuildContext context) { + return material.ListView( + shrinkWrap: true, + children: [ + for (final root in _roots) _buildNode(root, depth: 0), + ], + ); + } + + material.Widget _buildNode(SduiTreeNode node, {required int depth}) { + final canExpand = node.expandable || node.hasChildren; + final isExpanded = _expanded.contains(node.id); + final isLoading = _loading.contains(node.id); + + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.InkWell( + onTap: () => widget.onNodeSelected?.call(node), + child: material.Padding( + padding: material.EdgeInsets.only( + left: 8.0 + depth * 16.0, + right: 8, + top: 6, + bottom: 6, + ), + child: material.Row( + children: [ + if (canExpand) + material.SizedBox( + width: 28, + height: 28, + child: material.IconButton( + padding: material.EdgeInsets.zero, + iconSize: 18, + onPressed: () { + if (isExpanded) { + _onCollapse(node); + } else { + _onExpand(node); + } + }, + icon: material.Icon( + isExpanded + ? material.Icons.expand_more + : material.Icons.chevron_right, + ), + ), + ) + else + const material.SizedBox(width: 28), + if (isLoading) + const material.SizedBox( + width: 14, + height: 14, + child: material.CircularProgressIndicator(strokeWidth: 2), + ) + else + material.Icon( + _iconFor(node), + size: 16, + ), + const Gap(8), + material.Expanded(child: Text(node.label).small()), + ], + ), + ), + ), + if (isExpanded) + for (final child in node.children) _buildNode(child, depth: depth + 1), + ], + ); + } + + material.IconData _iconFor(SduiTreeNode node) { + switch (node.icon) { + case 'database': + return material.Icons.storage_outlined; + case 'table': + return material.Icons.table_chart_outlined; + case 'folder': + return material.Icons.folder_outlined; + default: + return node.expandable + ? material.Icons.folder_outlined + : material.Icons.insert_drive_file_outlined; + } + } +} diff --git a/lib/core/sdui/sdui_tree_schema.dart b/lib/core/sdui/sdui_tree_schema.dart new file mode 100644 index 00000000..4e766df2 --- /dev/null +++ b/lib/core/sdui/sdui_tree_schema.dart @@ -0,0 +1,86 @@ +class SduiTreeNode { + const SduiTreeNode({ + required this.id, + required this.label, + this.expandable = false, + this.children = const [], + this.icon, + this.meta = const {}, + }); + + final String id; + final String label; + final bool expandable; + final List children; + final String? icon; + final Map meta; + + bool get hasChildren => children.isNotEmpty; + + SduiTreeNode copyWith({ + List? children, + bool? expandable, + }) { + return SduiTreeNode( + id: id, + label: label, + expandable: expandable ?? this.expandable, + children: children ?? this.children, + icon: icon, + meta: meta, + ); + } + + factory SduiTreeNode.fromJson(Map json) { + final childrenRaw = json['children']; + final children = []; + if (childrenRaw is List) { + for (final item in childrenRaw) { + if (item is Map) { + children.add(SduiTreeNode.fromJson(item)); + } else if (item is Map) { + children.add(SduiTreeNode.fromJson(Map.from(item))); + } + } + } + final metaRaw = json['meta']; + final meta = {}; + if (metaRaw is Map) { + meta.addAll(metaRaw.map((k, v) => MapEntry('$k', v))); + } + + return SduiTreeNode( + id: '${json['id'] ?? ''}', + label: '${json['label'] ?? json['name'] ?? json['id'] ?? ''}', + expandable: json['expandable'] == true || json['lazy'] == true, + children: children, + icon: json['icon'] as String?, + meta: meta, + ); + } +} + +/// Schema returned by `extension.getTreeSchema`. +class SduiTreeSchema { + const SduiTreeSchema({this.roots = const []}); + + final List roots; + + factory SduiTreeSchema.fromJson(Map json) { + final rootsRaw = json['roots'] ?? json['children'] ?? json['nodes']; + final roots = []; + if (rootsRaw is List) { + for (final item in rootsRaw) { + if (item is Map) { + roots.add(SduiTreeNode.fromJson(item)); + } else if (item is Map) { + roots.add(SduiTreeNode.fromJson(Map.from(item))); + } + } + } + return SduiTreeSchema(roots: roots); + } +} + +/// Loads children for an expandable node (`fetchTreeChildren` RPC). +typedef SduiFetchTreeChildren = Future> Function(String nodeId); diff --git a/test/core/sdui/sdui_builders_test.dart b/test/core/sdui/sdui_builders_test.dart new file mode 100644 index 00000000..b2979463 --- /dev/null +++ b/test/core/sdui/sdui_builders_test.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/sdui/sdui_form_builder.dart'; +import 'package:querya_desktop/core/sdui/sdui_form_schema.dart'; +import 'package:querya_desktop/core/sdui/sdui_tree_builder.dart'; +import 'package:querya_desktop/core/sdui/sdui_tree_schema.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('SduiFormSchema', () { + test('parses connection form JSON', () { + final schema = SduiFormSchema.fromJson(const { + 'title': 'ClickHouse', + 'fields': [ + { + 'id': 'host', + 'type': 'text', + 'label': 'Host', + 'required': true, + 'default': 'localhost', + }, + {'id': 'port', 'type': 'number', 'label': 'Port', 'default': 8123}, + {'id': 'password', 'type': 'password', 'label': 'Password'}, + {'id': 'ssl', 'type': 'checkbox', 'label': 'Use SSL'}, + { + 'id': 'auth', + 'type': 'select', + 'label': 'Auth', + 'options': [ + {'value': 'password', 'label': 'Password'}, + {'value': 'cert', 'label': 'Certificate'}, + ], + }, + {'id': 'cert', 'type': 'file_picker', 'label': 'Client cert'}, + ], + }); + + expect(schema.title, 'ClickHouse'); + expect(schema.fields, hasLength(6)); + expect(schema.fields[0].type, SduiFieldType.text); + expect(schema.fields[1].type, SduiFieldType.number); + expect(schema.fields[2].type, SduiFieldType.password); + expect(schema.fields[3].type, SduiFieldType.checkbox); + expect(schema.fields[4].options, hasLength(2)); + expect(schema.fields[5].type, SduiFieldType.filePicker); + }); + }); + + group('SduiFormBuilder', () { + testWidgets('validates required fields and collects values', (tester) async { + final schema = SduiFormSchema.fromJson(const { + 'title': 'Conn', + 'fields': [ + {'id': 'host', 'type': 'text', 'label': 'Host', 'required': true}, + {'id': 'port', 'type': 'number', 'label': 'Port', 'default': 5432}, + {'id': 'password', 'type': 'password', 'label': 'Password'}, + {'id': 'ssl', 'type': 'checkbox', 'label': 'SSL', 'default': false}, + ], + }); + + final key = material.GlobalKey(); + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: SduiFormBuilder(key: key, schema: schema), + ), + ), + ); + + expect(key.currentState!.collectValues(), isNull); + await tester.pump(); + expect(find.text('Host is required'), findsOneWidget); + + await tester.enterText(find.byType(material.TextFormField).first, 'db.local'); + await tester.pump(); + + final values = key.currentState!.collectValues(); + expect(values, isNotNull); + expect(values!['host'], 'db.local'); + expect(values['port'], 5432); + expect(values['ssl'], isFalse); + expect(key.currentState!.passwordFieldIds, ['password']); + }); + + testWidgets('file_picker uses injectable picker', (tester) async { + final schema = SduiFormSchema.fromJson(const { + 'fields': [ + {'id': 'db', 'type': 'file_picker', 'label': 'Database file'}, + ], + }); + final key = material.GlobalKey(); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: SduiFormBuilder( + key: key, + schema: schema, + filePicker: (_) async => '/tmp/test.db', + ), + ), + ), + ); + + await tester.tap(find.text('Browse')); + await tester.pumpAndSettle(); + + expect(key.currentState!.snapshotValues()['db'], '/tmp/test.db'); + }); + }); + + group('SduiTreeSchema', () { + test('parses tree schema with expandable nodes', () { + final schema = SduiTreeSchema.fromJson(const { + 'roots': [ + { + 'id': 'databases', + 'label': 'Databases', + 'expandable': true, + 'icon': 'folder', + }, + ], + }); + expect(schema.roots.single.id, 'databases'); + expect(schema.roots.single.expandable, isTrue); + }); + }); + + group('SduiTreeBuilder', () { + testWidgets('lazy-loads children on expand', (tester) async { + final schema = SduiTreeSchema.fromJson(const { + 'roots': [ + { + 'id': 'databases', + 'label': 'Databases', + 'expandable': true, + }, + ], + }); + + var fetches = 0; + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: SduiTreeBuilder( + schema: schema, + fetchChildren: (id) async { + fetches++; + expect(id, 'databases'); + return const [ + SduiTreeNode(id: 'db1', label: 'analytics'), + ]; + }, + ), + ), + ), + ); + + expect(find.text('Databases'), findsOneWidget); + expect(find.text('analytics'), findsNothing); + + await tester.tap(find.byIcon(material.Icons.chevron_right)); + await tester.pumpAndSettle(); + + expect(fetches, 1); + expect(find.text('analytics'), findsOneWidget); + }); + }); +}