From 256f2af584ad8879d7fe71f6b9287924c6f9339e Mon Sep 17 00:00:00 2001 From: "Graciliano M. P." Date: Wed, 12 Aug 2026 05:12:40 -0300 Subject: [PATCH 1/3] Fix bugs across component, layout, navigator and async content Fixes found by auditing the library, each covered by a new test: - `UIComponent.onChildRendered` was notified with the parent instead of the rendered child. - `onEventKeyPress` without a `key:action` delimiter threw a `RangeError` that aborted the whole render. - `focusField` searched the text input in the component itself instead of in the resolved field component. - `getFieldsComponentsMap` let a plain element overwrite an `UIField` component with the same field name (`getFieldsExtended` had a sort with that intent, but it compared `MapEntry is UIComponent`). - `UIAsyncContent`: a multi-root HTML content returned the raw `String` instead of the built element (missing `return`). - `UILayout`: an element without an `id` built the invalid selector `#`, breaking any evaluated `uiLayout` command; fractional `px` values threw a `FormatException`; `'0 px'` is not a valid CSS value; the window `resize` listener was registered by every instance. - `UINavigator.navigateOnClick` threw on a `null` route; the "navigable not found" retry dropped `fromURL`, duplicating history entries. - `UIElementExtension.resolveUIComponent` threw when there was no `UIRoot`. - `$uiButtonLoader` emitted `button-style` only when `buttonClasses` was set, and built `loaded-text-classes` from `buttonClasses`. Adds 34 tests in 5 new test files. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 55 ++++++ lib/src/bones_ui.dart | 2 +- lib/src/bones_ui_async_content.dart | 2 +- lib/src/bones_ui_base.dart | 2 +- lib/src/bones_ui_component.dart | 73 ++++--- lib/src/bones_ui_extension.dart | 2 +- lib/src/bones_ui_layout.dart | 36 ++-- lib/src/bones_ui_navigator.dart | 10 +- lib/src/component/button.dart | 8 +- pubspec.yaml | 4 +- test/bones_ui_async_content_test.dart | 100 ++++++++++ test/bones_ui_button_test.dart | 126 ++++++++++++ test/bones_ui_component_test.dart | 269 ++++++++++++++++++++++++++ test/bones_ui_layout_test.dart | 109 +++++++++++ test/bones_ui_navigator_test.dart | 103 ++++++++++ 15 files changed, 840 insertions(+), 61 deletions(-) create mode 100644 test/bones_ui_async_content_test.dart create mode 100644 test/bones_ui_button_test.dart create mode 100644 test/bones_ui_component_test.dart create mode 100644 test/bones_ui_layout_test.dart create mode 100644 test/bones_ui_navigator_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 24aedf7..2dee22c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,58 @@ +## 3.0.19 + +- `UIComponent`: + - `onChildRendered`: fixed the notification passing the parent component instead of the + rendered child (`_callOnChildRendered` called `onChildRendered(this)`). + - `_parseOnEventKeyPress`: an `onEventKeyPress` attribute without a `key:action` delimiter + threw a `RangeError` that aborted the whole render (`toContentElements` returned an empty + list). The value is now handled as an action triggered by any key. + - `focusField`: was looking for the text input in the component itself instead of in the + resolved field component. + - `getFieldsComponentsMap`: for a repeated field name it now keeps the `UIField` component + instead of letting a plain element overwrite it. `getFieldsExtended` had a sort with the + same intent, but it compared `MapEntry is UIComponent` (always `false`) over an + already deduplicated map. + - `appendStyle`: ensures the previous declarations are terminated before appending. + +- `UIAsyncContent`: + - `_ensureElementForDOM`: an HTML content with multiple root nodes returned the raw + `String` instead of the built element (missing `return` in the branch). + +- `UILayout`: + - `_getElementIndexByID`: an element without an `id` built the invalid CSS selector `#`, + throwing a `SyntaxError`. This broke *any* evaluated `uiLayout` command + (e.g. `x(10px)`) on elements without an `id`. + - `_parseValuePx`: crashed with a `FormatException` on fractional values (e.g. `4.5px`); + now parsed as a `num` and ignored when not a plain `px` value (e.g. `calc(...)`). + - `_valueXY`: returned the invalid CSS value `'0 px'` (with a space) when the parent had + no dimensions. + - The window `resize` listener is now registered once per `Window` (it was registered by + every `UILayout` instance, since `_registeredWindow` was not `static`). + - `_configure`: removed a no-op `split()` over a regex passed as a literal `String`. + - `_getElementIndex`/`_getElementIndexByID`: null-safe for detached elements. + +- `UINavigator`: + - `navigateOnClick`: a `null`/empty route threw a null-check error; it now clears a + previously registered route and returns `null`. + - `_navigateTo`: the "navigable not found" retry dropped the `fromURL` flag, pushing a + duplicated entry in the browser history for URL-driven navigations. + +- `UIElementExtension.resolveUIComponent`: no longer throws when there is no `UIRoot`. + +- `$uiButtonLoader`: + - `button-style` was only emitted when `buttonClasses` was provided (and was emitted empty + when only `buttonClasses` was given). + - `loaded-text-classes` was built from `buttonClasses` instead of `loadedTextClass`. + - `UIButtonLoader.generator` now also accepts the `loaded-text-classes` attribute + (the one generated by `$uiButtonLoader`). + +- `UIEventHandler.trackAllRegisteredEventListeners`: fixed an empty-check using `isElement`. + +- Tests: + - Added `test/bones_ui_component_test.dart`, `test/bones_ui_async_content_test.dart`, + `test/bones_ui_layout_test.dart`, `test/bones_ui_navigator_test.dart` and + `test/bones_ui_button_test.dart`. + ## 3.0.18 - `UIDocument`: diff --git a/lib/src/bones_ui.dart b/lib/src/bones_ui.dart index db813f8..dd10112 100644 --- a/lib/src/bones_ui.dart +++ b/lib/src/bones_ui.dart @@ -1,3 +1,3 @@ class BonesUI { - static const String version = '3.0.18'; + static const String version = '3.0.19'; } diff --git a/lib/src/bones_ui_async_content.dart b/lib/src/bones_ui_async_content.dart index b29e321..54b6b5d 100644 --- a/lib/src/bones_ui_async_content.dart +++ b/lib/src/bones_ui_async_content.dart @@ -390,7 +390,7 @@ Object? _ensureElementForDOM(Object? element) { if (childNodes.length == 1) { return childNodes.toIterable().first; } else { - div; + return div; } } else { var span = HTMLSpanElement(); diff --git a/lib/src/bones_ui_base.dart b/lib/src/bones_ui_base.dart index 128314c..cbf9c2e 100644 --- a/lib/src/bones_ui_base.dart +++ b/lib/src/bones_ui_base.dart @@ -104,7 +104,7 @@ abstract class _EventHandlerPrivate { void _trackAllRegisteredEventListeners( List? listeners, ) { - if (listeners == null || listeners.isElement) return; + if (listeners == null || listeners.isEmpty) return; var registeredEventListeners = _registeredEventListeners ??= []; registeredEventListeners.addAll(listeners); diff --git a/lib/src/bones_ui_component.dart b/lib/src/bones_ui_component.dart index 5277d7b..75d7fdc 100644 --- a/lib/src/bones_ui_component.dart +++ b/lib/src/bones_ui_component.dart @@ -830,11 +830,16 @@ abstract class UIComponent extends UIEventHandler { final content = this.content!; - var cssText = content.style.cssText; - if (cssText == '') { + var cssText = content.style.cssText.trim(); + if (cssText.isEmpty) { cssText = allStylesLine; } else { - cssText += allStylesLine; + // Ensure that the previous declarations are terminated, otherwise + // the appended entry would be merged into the last property value: + if (!cssText.endsWith(';')) { + cssText += ';'; + } + cssText += ' $allStylesLine'; } content.style.cssText = cssText; @@ -1049,9 +1054,19 @@ abstract class UIComponent extends UIEventHandler { List? fields, List? ignoreFields, }) { - var map = Map.fromEntries( - _listFieldsEntriesInContentDeepImpl(_content!.children.toList()), - ); + var map = {}; + + for (var entry in _listFieldsEntriesInContentDeepImpl( + _content!.children.toList(), + )) { + var value = entry.value; + + // For a repeated field name prefer an `UIComponent` over a plain + // element, since it resolves the field value through `UIField`: + if (map[entry.key] is UIComponent && value is! UIComponent) continue; + + map[entry.key] = value; + } if (fields != null) { map.removeWhere((key, value) => !fields.contains(key)); @@ -1982,7 +1997,7 @@ abstract class UIComponent extends UIEventHandler { void _callOnChildRendered(UIComponent child) { try { - onChildRendered(this); + onChildRendered(child); } catch (e, s) { logger.error('Error calling onChildRendered() for instance: $this', e, s); } @@ -3306,34 +3321,11 @@ abstract class UIComponent extends UIEventHandler { List? fields, List? ignoreFields, }) { - var fieldsElementsMap = getFieldsComponentsMap( - fields: fields, - ignoreFields: ignoreFields, + // Repeated field names are already resolved by [getFieldsComponentsMap], + // that prefers an `UIComponent` over a plain element: + return Map.of( + getFieldsComponentsMap(fields: fields, ignoreFields: ignoreFields), ); - - var entries = fieldsElementsMap.entries.toList(); - entries.sort((a, b) { - var aIsUIComponent = a is UIComponent; - var bIsUIComponent = b is UIComponent; - - if (aIsUIComponent && !bIsUIComponent) { - return -1; - } else if (bIsUIComponent && !aIsUIComponent) { - return 1; - } else { - return 0; - } - }); - - var fieldsValues = {}; - - for (var entry in entries) { - var key = entry.key; - if (fieldsValues.containsKey(key)) continue; - fieldsValues[key] = entry.value; - } - - return fieldsValues; } Map? _renderedFieldsValues; @@ -3622,7 +3614,7 @@ abstract class UIComponent extends UIEventHandler { (component as HTMLElement).focus(); return true; } else if (component is UIComponent) { - var input = findInContentChildDeep((e) => e.isTextInput); + var input = component.findInContentChildDeep((e) => e.isTextInput); if (input != null) { return input.focus(); } @@ -3725,9 +3717,14 @@ abstract class UIComponent extends UIEventHandler { var keypress = getElementAttribute(elem, 'onEventKeyPress'); if (keypress != null && keypress.isNotEmpty) { - var parts = keypress.split(':'); - var key = parts[0].trim(); - var actionType = parts[1]; + var idx = keypress.indexOf(':'); + + // Without a `key:action` delimiter the whole value is the action, + // triggered by any key: + var key = idx >= 0 ? keypress.substring(0, idx).trim() : '*'; + var actionType = idx >= 0 ? keypress.substring(idx + 1) : keypress; + + if (key.isEmpty) key = '*'; if (key == '*') { addTrackedEventListener( diff --git a/lib/src/bones_ui_extension.dart b/lib/src/bones_ui_extension.dart index 253a2a2..803f69b 100644 --- a/lib/src/bones_ui_extension.dart +++ b/lib/src/bones_ui_extension.dart @@ -15,7 +15,7 @@ extension UIElementExtension on UIElement { if (parentUIComponent != null) { return parentUIComponent.findUIComponentByChild(this); } else { - return UIRoot.getInstance()!.findUIComponentByChild(this); + return UIRoot.getInstance()?.findUIComponentByChild(this); } } diff --git a/lib/src/bones_ui_layout.dart b/lib/src/bones_ui_layout.dart index 27642b7..38ceaeb 100644 --- a/lib/src/bones_ui_layout.dart +++ b/lib/src/bones_ui_layout.dart @@ -612,18 +612,25 @@ class UILayout { } int _getElementIndex(UIElement elem) { - var idx = elem.parentElement!.children.indexOf(elem); - return idx; + var parent = elem.parentElement; + if (parent == null) return -1; + return parent.children.indexOf(elem); } int _getElementIndexByID(UIElement elem) { - var elemID = elem.id; - List elemsSameID = elem.parentElement! + var elemID = elem.id.trim(); + // An empty ID would build the invalid CSS selector `#`: + if (elemID.isEmpty) return -1; + + var parent = elem.parentElement; + if (parent == null) return -1; + + List elemsSameID = parent .querySelectorAll('#$elemID') .toElements(); if (elemsSameID.isEmpty) return -1; - var idx = elemsSameID.indexOf(elem); - return idx; + + return elemsSameID.indexOf(elem); } Map _getElementCenter(HTMLElement elem) { @@ -638,8 +645,10 @@ class UILayout { return {'x': x2, 'y': y2}; } - Window? _registeredWindow; + static Window? _registeredWindow; + /// Registers the `resize` listener only once per [Window], since + /// [_onWindowResize] refreshes all the [UILayout] instances. void _registerWindowResize() { if (_registeredWindow != window) { _registeredWindow = window; @@ -726,8 +735,7 @@ class UILayout { element.style.position = 'absolute'; - var commands = this.layout.split('\\s*;\\s*'); - commands.forEach(_commandsParser); + _commandsParser(layout); } void _commandsParser(String cmds) { @@ -850,7 +858,7 @@ class UILayout { if (pw == 0 || ph == 0) { _needRefresh = true; - return '0 px'; + return '0px'; } return valueCenter(pw, ph, w, h); @@ -940,10 +948,12 @@ class UILayout { return context; } - int? _parseValuePx(String val) { + /// Parses a `px` value, returning `null` if [val] is not a plain + /// `px` value (like `calc(...)` or a value with another unit). + num? _parseValuePx(String val) { + val = val.trim(); if (val.endsWith('px')) { - var n = int.parse(val.substring(0, val.length - 2)); - return n; + return num.tryParse(val.substring(0, val.length - 2).trim()); } return null; } diff --git a/lib/src/bones_ui_navigator.dart b/lib/src/bones_ui_navigator.dart index b743488..dd7f13c 100644 --- a/lib/src/bones_ui_navigator.dart +++ b/lib/src/bones_ui_navigator.dart @@ -418,6 +418,7 @@ class UINavigator { route, parameters: parameters, force: force, + fromURL: fromURL, cantFindNavigableRetry: cantFindNavigableRetry + 1, ), ); @@ -613,13 +614,20 @@ class UINavigator { ParametersProvider? parametersProvider, bool force = false, ]) { + if (route == null || route.isEmpty) { + if (element.isA()) { + clearNavigateOnClick(element as HTMLElement); + } + return null; + } + var paramsStr = encodeQueryString(parameters); var attrRoute = element.getAttribute('__navigate__route'); var attrParams = element.getAttribute('__navigate__parameters'); if (route != attrRoute || paramsStr != attrParams) { - element.setAttribute('__navigate__route', route!); + element.setAttribute('__navigate__route', route); element.setAttribute('__navigate__parameters', paramsStr); var subscriptionHolder = []; diff --git a/lib/src/component/button.dart b/lib/src/component/button.dart index beee808..abadc26 100644 --- a/lib/src/component/button.dart +++ b/lib/src/component/button.dart @@ -313,13 +313,13 @@ DOMElement $uiButtonLoader({ if (buttonClasses != null) 'button-classes': parseStringFromInlineList(buttonClasses)?.join(',') ?? '', - if (buttonClasses != null) 'button-style': CSS(buttonStyle).style, + if (buttonStyle != null) 'button-style': CSS(buttonStyle).style, if (withProgress != null) 'with-progress': '$withProgress', if (loadedTextStyle != null) 'loaded-text-style': CSS(loadedTextStyle).style, if (loadedTextClass != null) 'loaded-text-classes': - parseStringFromInlineList(buttonClasses)?.join(',') ?? '', + parseStringFromInlineList(loadedTextClass)?.join(',') ?? '', if (loadedTextErrorStyle != null) 'loaded-text-error-style': CSS(loadedTextErrorStyle).style, if (loadedTextErrorClass != null) @@ -347,7 +347,9 @@ class UIButtonLoader extends UIButtonBase { '', (parent, attributes, contentHolder, contentNodes) { var loadedTextStyle = attributes['loaded-text-style']; - var loadedTextClass = attributes['loaded-text-class']; + var loadedTextClass = + attributes['loaded-text-class'] ?? + attributes['loaded-text-classes']; var loadedTextErrorStyle = attributes['loaded-text-error-style']; var loadedTextErrorClass = attributes['loaded-text-error-class'] ?? diff --git a/pubspec.yaml b/pubspec.yaml index 45de174..0ab4dd9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: bones_ui description: Bones_UI - An intuitive and user-friendly Web User Interface framework for Dart. -version: 3.0.18 +version: 3.0.19 homepage: https://github.com/Colossus-Services/bones_ui environment: @@ -43,7 +43,7 @@ dependencies: dev_dependencies: build_web_compilers: ^4.8.0 - build_runner: ^2.15.0 + build_runner: ^2.15.1 lints: ^5.1.1 dependency_validator: ^5.0.5 diff --git a/test/bones_ui_async_content_test.dart b/test/bones_ui_async_content_test.dart new file mode 100644 index 0000000..e04089a --- /dev/null +++ b/test/bones_ui_async_content_test.dart @@ -0,0 +1,100 @@ +@TestOn('browser') +library; + +import 'package:bones_ui/bones_ui_test.dart'; +import 'package:test/test.dart'; +import 'package:web_utils/web_utils.dart' as web; + +void main() { + group('UIAsyncContent', () { + UIAsyncContent newAsyncContent(Object? loadingContent) => + UIAsyncContent.future(Future.value('loaded'), loadingContent); + + /// Casts [o] to an [web.HTMLElement], failing the test if it's not one. + web.HTMLElement asHTMLElement(Object? o) { + expect( + o.asJSAny.isA(), + isTrue, + reason: 'Not an `HTMLElement`: <<$o>> (${o.runtimeType})', + ); + return o as web.HTMLElement; + } + + test('HTML with a single root node resolves to that node', () { + var asyncContent = newAsyncContent('bold'); + var content = asHTMLElement(asyncContent.loadingContent); + + expect(content.tagName.toLowerCase(), equals('b')); + expect(content.text, equals('bold')); + }); + + test('HTML with multiple root nodes is wrapped in an element', () { + var asyncContent = newAsyncContent('bolditalic'); + + Object? content = asyncContent.loadingContent; + expect( + content, + isNot(isA()), + reason: 'A multi-root HTML content should be converted to an element', + ); + + var element = asHTMLElement(content); + expect(element.children.length, equals(2)); + expect(element.text, equals('bolditalic')); + }); + + test('plain text is wrapped in a `span`', () { + var asyncContent = newAsyncContent('just text'); + + Object? content = asyncContent.loadingContent; + expect(content.asJSAny.isA(), isTrue); + expect(asHTMLElement(content).text, equals('just text')); + }); + + test('an element is kept as is', () { + var div = web.HTMLDivElement()..text = 'the div'; + var asyncContent = newAsyncContent(div); + + expect(identical(asyncContent.loadingContent, div), isTrue); + }); + + test('loads the content of the `Future`', () async { + var asyncContent = UIAsyncContent.future( + Future.delayed(Duration(milliseconds: 50), () => 'ok!'), + 'loading...', + ); + + expect(asyncContent.isLoaded, isFalse); + expect(asyncContent.loadCount, equals(0)); + + await testUISleep(ms: 300); + + expect(asyncContent.isLoaded, isTrue); + expect(asyncContent.isOK, isTrue); + expect(asyncContent.isWithError, isFalse); + expect(asyncContent.loadCount, equals(1)); + + expect(asHTMLElement(asyncContent.content).text, equals('ok!')); + }); + + test('an error in the `Future` exposes the error content', () async { + var asyncContent = UIAsyncContent.future( + Future.delayed( + Duration(milliseconds: 50), + () => throw StateError('the error'), + ), + 'loading...', + errorContent: 'failed!', + ); + + await testUISleep(ms: 300); + + expect(asyncContent.isLoaded, isTrue); + expect(asyncContent.isOK, isFalse); + expect(asyncContent.isWithError, isTrue); + expect(asyncContent.error, isA()); + + expect(asHTMLElement(asyncContent.content).text, equals('failed!')); + }); + }); +} diff --git a/test/bones_ui_button_test.dart b/test/bones_ui_button_test.dart new file mode 100644 index 0000000..1b20c43 --- /dev/null +++ b/test/bones_ui_button_test.dart @@ -0,0 +1,126 @@ +@TestOn('browser') +library; + +import 'package:bones_ui/bones_ui_test.dart'; +import 'package:test/test.dart'; +import 'package:web_utils/web_utils.dart' as web; + +void main() { + group(r'$uiButtonLoader', () { + test('`buttonStyle` is emitted without `buttonClasses`', () { + var domElement = $uiButtonLoader(buttonStyle: 'color: red'); + + expect( + domElement.getAttributeValue('button-style'), + contains('color'), + reason: '`buttonStyle` should not depend on `buttonClasses`', + ); + expect(domElement.getAttributeValue('button-classes'), isNull); + }); + + test('`buttonClasses` does not emit an empty `button-style`', () { + var domElement = $uiButtonLoader(buttonClasses: 'btn btn-primary'); + + expect( + domElement.getAttributeValue('button-classes'), + equals('btn btn-primary'), + ); + expect(domElement.getAttributeValue('button-style'), isNull); + }); + + test('`loadedTextClass` is used for `loaded-text-classes`', () { + var domElement = $uiButtonLoader( + buttonClasses: 'btn', + loadedTextClass: 'text-ok text-bold', + ); + + expect( + domElement.getAttributeValue('loaded-text-classes'), + equals('text-ok text-bold'), + reason: + '`loaded-text-classes` should be built from `loadedTextClass`, ' + 'not from `buttonClasses`', + ); + }); + + test('`loadedTextErrorClass` is used for `loaded-text-error-classes`', () { + var domElement = $uiButtonLoader(loadedTextErrorClass: 'text-error'); + + expect( + domElement.getAttributeValue('loaded-text-error-classes'), + equals('text-error'), + ); + }); + + test('texts and `withProgress`', () { + var domElement = $uiButtonLoader( + loadedTextOK: 'Done', + loadedTextError: 'Failed', + withProgress: true, + ); + + expect(domElement.getAttributeValue('loaded-text-ok'), equals('Done')); + expect( + domElement.getAttributeValue('loaded-text-error'), + equals('Failed'), + ); + expect(domElement.getAttributeValue('with-progress'), equals('true')); + }); + }); + + group('UIButton', () { + late final _ButtonRoot uiRoot; + + setUpAll(() async { + uiRoot = await initializeTestUIRoot((rootContainer) { + return _ButtonRoot(rootContainer); + }); + await uiRoot.callRenderAndWait(); + }); + + test('renders a `button` element with the text', () async { + var button = UIButton(uiRoot.content, 'Click me'); + await button.callRenderAndWait(); + + expect(button.content.isA(), isTrue); + expect(button.text, equals('Click me')); + expect(button.content!.text, contains('Click me')); + }); + + test('fires the click event', () async { + var button = UIButton(uiRoot.content, 'Click me'); + await button.callRenderAndWait(); + + var clicks = 0; + button.onClick.listen((_) => ++clicks); + + button.click(); + await testUISleep(ms: 50); + + expect(clicks, equals(1)); + }); + + test('a disabled button does not fire the click event', () async { + var button = UIButton(uiRoot.content, 'Click me'); + await button.callRenderAndWait(); + + var clicks = 0; + button.onClick.listen((_) => ++clicks); + + button.disabled = true; + await testUISleep(ms: 50); + + button.click(); + await testUISleep(ms: 50); + + expect(clicks, equals(0)); + }); + }); +} + +class _ButtonRoot extends UIRoot { + _ButtonRoot(super.rootContainer) : super(id: 'button-root'); + + @override + UIComponent? renderContent() => null; +} diff --git a/test/bones_ui_component_test.dart b/test/bones_ui_component_test.dart new file mode 100644 index 0000000..c98776d --- /dev/null +++ b/test/bones_ui_component_test.dart @@ -0,0 +1,269 @@ +@TestOn('browser') +library; + +import 'package:bones_ui/bones_ui_test.dart'; +import 'package:test/test.dart'; +import 'package:web_utils/web_utils.dart' as web; + +void main() { + group('UIComponent', () { + late final _TestRoot uiRoot; + + setUpAll(() async { + uiRoot = await initializeTestUIRoot((rootContainer) { + return _TestRoot(rootContainer); + }); + await uiRoot.callRenderAndWait(); + }); + + test('onChildRendered receives the child component', () async { + var parent = uiRoot.parentComponent; + + await parent.callRenderAndWait(); + await testUISleep(ms: 100); + + expect( + parent.notifiedChildren, + isNotEmpty, + reason: '`onChildRendered` should have been called', + ); + + // The notified component must be the rendered child, never the parent + // that is being notified: + for (var c in parent.notifiedChildren) { + expect( + identical(c, parent), + isFalse, + reason: '`onChildRendered` received the parent instead of the child', + ); + } + + expect(parent.notifiedChildren, contains(same(parent.child))); + }); + + test('appendStyle keeps previous declarations', () { + var component = _StyledComponent(uiRoot.content); + component.ensureRendered(); + + var style = component.content!.style; + expect(style.color, equals('red')); + + component.appendStyle('background-color: blue'); + + expect( + style.color, + equals('red'), + reason: 'The previous style entry was corrupted by the appended one', + ); + expect(style.backgroundColor, equals('blue')); + + component.appendStyle('font-weight: bold'); + + expect(style.color, equals('red')); + expect(style.backgroundColor, equals('blue')); + expect(style.fontWeight, equals('bold')); + }); + + test('appendClasses adds and removes (`!` prefix) classes', () { + var component = _StyledComponent(uiRoot.content); + component.ensureRendered(); + + component.appendClasses('foo bar'); + expect(component.content!.classList.contains('foo'), isTrue); + expect(component.content!.classList.contains('bar'), isTrue); + + component.appendClasses('!foo baz'); + expect(component.content!.classList.contains('foo'), isFalse); + expect(component.content!.classList.contains('bar'), isTrue); + expect(component.content!.classList.contains('baz'), isTrue); + }); + + test('getFieldsExtended prioritizes an `UIField` component', () async { + var component = _FieldsComponent(uiRoot.content); + await component.callRenderAndWait(); + await testUISleep(ms: 50); + + var fields = component.getFieldsExtended(); + + expect(fields.keys, contains('foo')); + expect( + fields['foo'], + isA<_FieldComponent>(), + reason: + 'An `UIField` component should win over a plain element ' + 'with the same field name', + ); + + expect(fields['bar'], isA<_FieldComponent>()); + + // The resolved value should come from the `UIField` component, + // not from the text of the plain element: + expect(component.getField('foo'), equals('component-foo')); + }); + + test('`onEventKeyPress` without a `key:action` delimiter', () async { + var component = _KeyPressComponent(uiRoot.content, 'myAction'); + await component.callRenderAndWait(); + await testUISleep(ms: 50); + + expect( + component.renderedElements, + isNotEmpty, + reason: 'A malformed `onEventKeyPress` should not break the render', + ); + + var input = component.querySelectorTyped( + 'input', + Web.HTMLInputElement, + ); + expect(input, isNotNull); + + input!.dispatchEvent( + web.KeyboardEvent('keypress', web.KeyboardEventInit(key: 'x')), + ); + + expect(component.actions, equals(['myAction'])); + }); + + test('`onEventKeyPress` with a `key:action` delimiter', () async { + var component = _KeyPressComponent(uiRoot.content, 'x:myAction'); + await component.callRenderAndWait(); + await testUISleep(ms: 50); + + var input = component.querySelectorTyped( + 'input', + Web.HTMLInputElement, + ); + expect(input, isNotNull); + + input!.dispatchEvent( + web.KeyboardEvent('keypress', web.KeyboardEventInit(key: 'y')), + ); + expect(component.actions, isEmpty, reason: 'Key `y` should be ignored'); + + input.dispatchEvent( + web.KeyboardEvent('keypress', web.KeyboardEventInit(key: 'x')), + ); + expect(component.actions, equals(['myAction'])); + }); + + test('focusField focuses the input inside the field component', () async { + var component = _FieldsComponent(uiRoot.content); + await component.callRenderAndWait(); + await testUISleep(ms: 50); + + var focused = component.focusField('bar'); + expect(focused, isTrue); + + var input = component.querySelectorTyped( + '#bar-input', + Web.HTMLInputElement, + ); + expect(input, isNotNull); + + // Note: `identical` is not reliable for JS interop wrappers on + // `dart2wasm`, so the focused element is checked by containment + ID: + var activeElement = document.activeElement; + expect( + component.content!.contains(activeElement), + isTrue, + reason: 'The focused element should be inside the component', + ); + expect( + activeElement?.id, + equals('bar-input'), + reason: 'The input of the field component should be focused', + ); + }); + }); +} + +class _TestRoot extends UIRoot { + _TestRoot(super.rootContainer) : super(id: 'test-root'); + + late final _ParentComponent parentComponent = _ParentComponent(this); + + @override + UIComponent? renderContent() => parentComponent; +} + +class _ParentComponent extends UIComponent { + final List notifiedChildren = []; + + _ParentComponent(super.parent) : super(id: 'parent-component'); + + late final _ChildComponent child = _ChildComponent(this); + + @override + dynamic render() => child; + + @override + void onChildRendered(UIComponent child) { + notifiedChildren.add(child); + } +} + +class _ChildComponent extends UIComponent { + _ChildComponent(super.parent) : super(id: 'child-component'); + + @override + dynamic render() => 'child content'; +} + +class _StyledComponent extends UIComponent { + _StyledComponent(super.parent) : super(style: 'color: red'); + + @override + dynamic render() => 'styled'; +} + +/// Renders a plain element and an [UIField] component sharing the field +/// name `foo`, plus a field component named `bar`. +class _FieldsComponent extends UIComponent { + _FieldsComponent(super.parent) : super(id: 'fields-component'); + + @override + dynamic render() => [ + // The plain element comes after the component with the same field name, + // to check that the component is the one kept: + _FieldComponent(this, 'foo', 'component-foo'), + $div(attributes: {'field': 'foo'}, content: 'plain-foo'), + _FieldComponent(this, 'bar', 'component-bar'), + ]; +} + +class _FieldComponent extends UIComponent implements UIField { + @override + final String fieldName; + + String? _value; + + _FieldComponent(super.parent, this.fieldName, this._value) + : super(id: '$fieldName-field'); + + @override + String? getFieldValue() => _value; + + @override + void setFieldValue(String? value) => _value = value; + + @override + dynamic render() => $input(id: '$fieldName-input', type: 'text'); +} + +class _KeyPressComponent extends UIComponent { + final String onEventKeyPress; + + final List actions = []; + + _KeyPressComponent(super.parent, this.onEventKeyPress); + + @override + dynamic render() => + $input(type: 'text', attributes: {'onEventKeyPress': onEventKeyPress}); + + @override + void action(String action) { + actions.add(action); + } +} diff --git a/test/bones_ui_layout_test.dart b/test/bones_ui_layout_test.dart new file mode 100644 index 0000000..21b97de --- /dev/null +++ b/test/bones_ui_layout_test.dart @@ -0,0 +1,109 @@ +@TestOn('browser') +library; + +import 'package:bones_ui/bones_ui_test.dart'; +import 'package:bones_ui/src/bones_ui_layout.dart'; +import 'package:test/test.dart'; +import 'package:web_utils/web_utils.dart' as web; + +void main() { + group('UILayout commands', () { + late final _LayoutRoot uiRoot; + + setUpAll(() async { + uiRoot = await initializeTestUIRoot((rootContainer) { + return _LayoutRoot(rootContainer); + }); + await uiRoot.callRenderAndWait(); + }); + + /// Creates a `relative` container (in the DOM) holding [children]. + web.HTMLDivElement newContainer(List children) { + var container = web.HTMLDivElement() + ..style.position = 'relative' + ..style.width = '200px' + ..style.height = '100px'; + + for (var e in children) { + container.append(e); + } + + uiRoot.content!.append(container); + return container; + } + + test('multiple `;` separated commands are all applied', () { + var element = web.HTMLDivElement(); + newContainer([element]); + + UILayout(uiRoot, element, 'x(10px); y(20px)'); + + expect(element.style.position, equals('absolute')); + expect(element.style.left, equals('10px')); + expect(element.style.top, equals('20px')); + }); + + test('a `container` layout is relative', () { + var element = web.HTMLDivElement(); + newContainer([element]); + + UILayout(uiRoot, element, 'container'); + + expect(element.style.position, equals('relative')); + }); + + test('`width`/`height` as a percentage of the parent', () { + var element = web.HTMLDivElement(); + newContainer([element]); + + UILayout(uiRoot, element, 'width(50%); height(50%)'); + + expect(element.style.width, equals('100px')); + expect(element.style.height, equals('50px')); + }); + + test('`centerx` with a fractional `px` reference', () { + var reference = web.HTMLDivElement() + ..id = 'layoutref' + ..style.position = 'absolute' + ..style.left = '4.5px'; + + var element = web.HTMLDivElement(); + + newContainer([reference, element]); + + // Resolves the `style.left` of `#layoutref` (`4.5px`), which used to + // throw a `FormatException` when parsed as an `int`: + UILayout(uiRoot, element, 'centerx(#layoutref)'); + + expect( + element.style.left, + equals('4.5px'), + reason: 'A fractional `px` reference should be resolved', + ); + }); + + test('a non-numeric `px` reference is applied as is', () { + var reference = web.HTMLDivElement() + ..id = 'layoutrefcalc' + ..style.position = 'absolute'; + + reference.style.setProperty('left', 'calc(10px + 2px)'); + + var element = web.HTMLDivElement(); + + newContainer([reference, element]); + + UILayout(uiRoot, element, 'centerx(#layoutrefcalc)'); + + expect(element.style.left, contains('calc')); + }); + }); +} + +class _LayoutRoot extends UIRoot { + _LayoutRoot(super.rootContainer) : super(id: 'layout-root'); + + @override + UIComponent? renderContent() => null; +} diff --git a/test/bones_ui_navigator_test.dart b/test/bones_ui_navigator_test.dart new file mode 100644 index 0000000..818e69e --- /dev/null +++ b/test/bones_ui_navigator_test.dart @@ -0,0 +1,103 @@ +@TestOn('browser') +library; + +import 'package:bones_ui/bones_ui_test.dart'; +import 'package:test/test.dart'; +import 'package:web_utils/web_utils.dart' as web; + +void main() { + group('Navigation', () { + test('encodeRouteAndParameters', () { + expect(Navigation.encodeRouteAndParameters('home', null), equals('home')); + expect(Navigation.encodeRouteAndParameters('home', {}), equals('home')); + expect( + Navigation.encodeRouteAndParameters(' home ', {'a': '1'}), + equals('home?a=1'), + ); + }); + + test('parameters are not URL-encoded for `,`', () { + var encoded = Navigation.encodeParameters({'ids': '1,2,3'}); + expect(encoded, equals('ids=1,2,3')); + }); + + test('typed parameters', () { + var navigation = Navigation('route', { + 'i': '10', + 'n': '1.5', + 'b': 'true', + 'l': '1, 2,3', + }); + + expect(navigation.isValid, isTrue); + expect(navigation.parameter('i'), equals('10')); + expect(navigation.parameter('x', 'def'), equals('def')); + expect(navigation.parameterAsInt('i'), equals(10)); + expect(navigation.parameterAsNum('n'), equals(1.5)); + expect(navigation.parameterAsBool('b'), isTrue); + expect(navigation.parameterAsIntList('l'), equals([1, 2, 3])); + }); + + test('an empty route is not valid', () { + expect(Navigation('').isValid, isFalse); + }); + }); + + group('UINavigator.navigateOnClick', () { + web.HTMLDivElement newElement() { + var div = web.HTMLDivElement(); + document.body!.append(div); + return div; + } + + test('registers the route in the element', () { + var div = newElement(); + + var subscription = UINavigator.navigateOnClick(div, 'my-route', { + 'a': '1', + }); + + expect(subscription, isNotNull); + expect(UINavigator.getNavigateOnClick(div), equals('my-route?a=1')); + expect(div.style.cursor, equals('pointer')); + + subscription!.cancel(); + }); + + test('a null route does not throw and clears a previous route', () { + var div = newElement(); + + var subscription = UINavigator.navigateOnClick(div, 'my-route'); + expect(UINavigator.getNavigateOnClick(div), equals('my-route')); + subscription!.cancel(); + + // Should not throw a null-check error: + var subscription2 = UINavigator.navigateOnClick(div, null); + + expect(subscription2, isNull); + expect(UINavigator.getNavigateOnClick(div), isNull); + expect(div.style.cursor, isNot(equals('pointer'))); + }); + + test('an empty route does not throw', () { + var div = newElement(); + expect(UINavigator.navigateOnClick(div, ''), isNull); + expect(UINavigator.getNavigateOnClick(div), isNull); + }); + + test('clearNavigateOnClick', () { + var div = newElement(); + + expect( + UINavigator.clearNavigateOnClick(div), + isFalse, + reason: 'Nothing to clear yet', + ); + + UINavigator.navigateOnClick(div, 'my-route')!.cancel(); + + expect(UINavigator.clearNavigateOnClick(div), isTrue); + expect(UINavigator.getNavigateOnClick(div), isNull); + }); + }); +} From 9aed5d052a93982556c06331821477ed88bfd231 Mon Sep 17 00:00:00 2001 From: "Graciliano M. P." Date: Wed, 12 Aug 2026 22:18:30 -0300 Subject: [PATCH 2/3] Improve test coverage: 47.8% -> 60.8% Adds 162 tests in 6 new files, covering areas that had little or no coverage: - `bones_ui_extension.dart` (0% -> 87.8%): element value resolution for input/textarea/select/checkbox/radio, `field_value`, `UIField`/`UIFieldMap`. - `component/component_async.dart` (0% -> 91.8%): `UIComponentAsync` loading, error, property invalidation, `stop` and reuse. - `component/dialog.dart` (5.2% -> 77.1%): show/hide/cancel, `showAndWait`, `UIDialogAlert`/`UIDialogInput`/`UIDialogLoading`. - `component/loading.dart` (14.2% -> 84.9%): `UILoadingConfig` parse/round-trip. - `bones_ui_utils.dart` (52.4% -> 90.3%) and `getLanguageByExtension`. - `bones_ui_component.dart` (67.9% -> 77.5%): classes/style parsing, the attributes API and the fields/fields-group API. - `bones_ui_navigator.dart` (62.5% -> 79.1%): `UINavigableComponent` routes, wildcard routes and `UINavigableContent` head/body/foot. Bugs found and fixed while writing these tests: - `getUILoadingType`: the `dualRing` case was unreachable, since the value is lower-cased before the `switch`, so it silently fell back to `ring`. - `UILoadingConfig`: `textZoom` and `withProgress` were lost on a `toInlineProperties` -> `parse` round-trip (key mismatch and a missing entry). - `UIDialog.getAllDialogs`/`removeAllDialogs`: mapped `.ui-dialog` elements back to components through an `Expando`, which is keyed by identity; on `dart2wasm` an element re-read from the DOM is a new Dart wrapper, so both methods were no-ops. They now resolve through `UIRootComponent.getInstances`. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 20 ++ lib/src/component/dialog.dart | 39 +-- lib/src/component/loading.dart | 17 +- test/bones_ui_component_async_test.dart | 294 ++++++++++++++++++++ test/bones_ui_dialog_test.dart | 262 ++++++++++++++++++ test/bones_ui_extension_test.dart | 196 +++++++++++++ test/bones_ui_fields_test.dart | 348 ++++++++++++++++++++++++ test/bones_ui_navigable_test.dart | 274 +++++++++++++++++++ test/bones_ui_utils_test.dart | 185 +++++++++++++ 9 files changed, 1613 insertions(+), 22 deletions(-) create mode 100644 test/bones_ui_component_async_test.dart create mode 100644 test/bones_ui_dialog_test.dart create mode 100644 test/bones_ui_extension_test.dart create mode 100644 test/bones_ui_fields_test.dart create mode 100644 test/bones_ui_navigable_test.dart create mode 100644 test/bones_ui_utils_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dee22c..e9f7d03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,10 +48,30 @@ - `UIEventHandler.trackAllRegisteredEventListeners`: fixed an empty-check using `isElement`. +- `getUILoadingType`: the `dualRing` case was unreachable, since the value is lower-cased + before the `switch`. `UILoadingType.dualRing` could never be parsed from a `String` and + silently fell back to `UILoadingType.ring`. Also accepts `dual-ring` and `dual_ring`. + +- `UILoadingConfig`: + - `toInlineProperties` emitted `textZoom`, but `fromMap` only read `text-zoom`, and + `withProgress` was not emitted at all. Both were silently lost on a + `toInlineProperties` → `parse` round-trip (used by the `ui-button-loader` tag). + +- `UIDialog.getAllDialogs`/`removeAllDialogs`: resolved the dialogs by mapping the + `.ui-dialog` elements back to their components through an `Expando`. An `Expando` is + keyed by identity, and on `dart2wasm` an element re-read from the DOM is a new Dart + wrapper, so the lookup always missed and both methods were no-ops. They now resolve + the instances through `UIRootComponent.getInstances`. + - Tests: - Added `test/bones_ui_component_test.dart`, `test/bones_ui_async_content_test.dart`, `test/bones_ui_layout_test.dart`, `test/bones_ui_navigator_test.dart` and `test/bones_ui_button_test.dart`. + - Coverage: added `test/bones_ui_extension_test.dart`, + `test/bones_ui_component_async_test.dart`, `test/bones_ui_fields_test.dart`, + `test/bones_ui_navigable_test.dart`, `test/bones_ui_utils_test.dart` and + `test/bones_ui_dialog_test.dart`. + - Line coverage: 47.8% → 60.8%. ## 3.0.18 diff --git a/lib/src/component/dialog.dart b/lib/src/component/dialog.dart index dd881e2..1515dd8 100644 --- a/lib/src/component/dialog.dart +++ b/lib/src/component/dialog.dart @@ -355,29 +355,30 @@ DOMElement $uiDialog({ } class UIDialog extends UIDialogBase { - /// Returns all the `.ui-dialog` as [UIDialogBase]. - static List getAllDialogs() { - var dialogs = window.document.querySelectorAll('.ui-dialog'); - - return dialogs - .whereElement() - .map(UIComponent.getContentUIComponent) - .whereType() - .toList(); - } + /// Returns all the [UIDialogBase] instances currently in the DOM. + /// + /// Note: the [UIDialogBase] instances are resolved through + /// [UIRootComponent.getInstances] instead of mapping the `.ui-dialog` + /// elements back to their components, because the element to component + /// mapping uses an [Expando], which is keyed by identity and doesn't + /// resolve an element re-read from the DOM when compiled to `Wasm`. + static List getAllDialogs() => + UIRootComponent.getInstances().whereType().where((d) { + var content = d.content; + return content != null && isNodeInDOM(content); + }).toList(); /// Removes and clears all the `.ui-dialog` [Element]s. static void removeAllDialogs() { - var dialogs = window.document.querySelectorAll('.ui-dialog'); - - for (var d in dialogs.whereElement()) { - var component = UIComponent.getContentUIComponent(d); - if (component is UIDialogBase) { - component.hide(); - } - - component?.clear(); + for (var dialog in getAllDialogs()) { + dialog.hide(); + dialog.clear(); + dialog.content?.remove(); + } + // Remove any remaining `.ui-dialog` element without a live component: + for (var d + in window.document.querySelectorAll('.ui-dialog').whereElement()) { d.remove(); } } diff --git a/lib/src/component/loading.dart b/lib/src/component/loading.dart index ebfc83b..d69a26d 100644 --- a/lib/src/component/loading.dart +++ b/lib/src/component/loading.dart @@ -418,7 +418,10 @@ UILoadingType? getUILoadingType(Object? type) { switch (typeStr) { case 'ring': return UILoadingType.ring; - case 'dualRing': + // Note: `typeStr` is lower-cased, so it can't be matched as `dualRing`: + case 'dualring': + case 'dual-ring': + case 'dual_ring': return UILoadingType.dualRing; case 'roller': return UILoadingType.roller; @@ -657,8 +660,15 @@ class UILoadingConfig implements AsDOMElement { var color = parseString(attributes['${prefix}color']); var zoom = parseString(attributes['${prefix}zoom']); var text = parseString(attributes['${prefix}text']); - var textZoom = parseString(attributes['${prefix}text-zoom']); - var withProgress = parseBool(attributes['${prefix}with-progress']); + // Accepts both the attribute style (`text-zoom`) and the + // inline properties style generated by [toInlineProperties] (`textZoom`): + var textZoom = parseString( + attributes['${prefix}text-zoom'] ?? attributes['${prefix}textZoom'], + ); + var withProgress = parseBool( + attributes['${prefix}with-progress'] ?? + attributes['${prefix}withProgress'], + ); if (isNotEmptyString(type, trim: true) || isNotEmptyString(color, trim: true) || @@ -707,6 +717,7 @@ class UILoadingConfig implements AsDOMElement { if (zoom != null) 'zoom: $zoom', if (textZoom != null) 'textZoom: $textZoom', if (isNotEmptyString(text, trim: true)) 'text: $text', + if (withProgress != null) 'withProgress: $withProgress', ].join('; '); } diff --git a/test/bones_ui_component_async_test.dart b/test/bones_ui_component_async_test.dart new file mode 100644 index 0000000..c7bbd41 --- /dev/null +++ b/test/bones_ui_component_async_test.dart @@ -0,0 +1,294 @@ +@TestOn('browser') +library; + +import 'package:bones_ui/bones_ui_test.dart'; +import 'package:test/test.dart'; + +void main() { + group('UIComponentAsync', () { + late final _AsyncRoot uiRoot; + + setUpAll(() async { + uiRoot = await initializeTestUIRoot((rootContainer) { + return _AsyncRoot(rootContainer); + }); + await uiRoot.callRenderAndWait(); + }); + + test('renders the loading content and then the async content', () async { + var component = UIComponentAsync( + uiRoot.content, + () => {'k': 'v'}, + (properties) => Future.delayed( + Duration(milliseconds: 100), + () => 'loaded: ${properties['k']}', + ), + 'loading...', + 'error!', + ); + + await component.callRenderAndWait(); + + expect(component.isLoaded, isFalse); + expect(component.content!.text, contains('loading...')); + + await testUISleep(ms: 400); + + expect(component.isLoaded, isTrue); + expect(component.isOK, isTrue); + expect(component.isWithError, isFalse); + expect(component.loadCount, equals(1)); + expect(component.loadTime, isNotNull); + expect(component.content!.text, contains('loaded: v')); + expect(component.asyncContentProperties, equals({'k': 'v'})); + }); + + test('renders the error content when the `Future` fails', () async { + var component = UIComponentAsync( + uiRoot.content, + () => {}, + (properties) => Future.delayed( + Duration(milliseconds: 50), + () => throw StateError('boom'), + ), + 'loading...', + 'the error content', + ); + + await component.callRenderAndWait(); + await testUISleep(ms: 400); + + expect(component.isLoaded, isTrue); + expect(component.isOK, isFalse); + expect(component.isWithError, isTrue); + expect(component.content!.text, contains('the error content')); + }); + + test('a subclass provides the properties and the async render', () async { + var component = _AsyncSubComponent(uiRoot.content); + + await component.callRenderAndWait(); + await testUISleep(ms: 300); + + expect(component.isOK, isTrue); + expect(component.renderAsyncCount, equals(1)); + expect(component.content!.text, contains('async #1')); + }); + + test('re-renders with the same properties reuse the content', () async { + var component = _AsyncSubComponent(uiRoot.content); + + await component.callRenderAndWait(); + await testUISleep(ms: 300); + expect(component.renderAsyncCount, equals(1)); + + component.refresh(); + await testUISleep(ms: 300); + + expect( + component.renderAsyncCount, + equals(1), + reason: 'The async content should be reused for the same properties', + ); + expect(component.asyncContentEqualsProperties({'v': 1}), isTrue); + }); + + test('changing the properties triggers a new async render', () async { + var component = _AsyncSubComponent(uiRoot.content); + + await component.callRenderAndWait(); + await testUISleep(ms: 300); + expect(component.renderAsyncCount, equals(1)); + + component.propertyValue = 2; + expect(component.isNotValid(), isTrue); + + component.refresh(); + await testUISleep(ms: 300); + + expect(component.renderAsyncCount, equals(2)); + expect(component.content!.text, contains('async #2')); + }); + + test('`stop` prevents further loads', () async { + var component = _AsyncSubComponent(uiRoot.content); + + await component.callRenderAndWait(); + await testUISleep(ms: 300); + + component.stop(); + expect(component.stopped, isTrue); + + component.refreshAsyncContent(); + await testUISleep(ms: 200); + + expect(component.renderAsyncCount, equals(1)); + }); + + test('`onLoadAsyncContent` is notified', () async { + var component = _AsyncSubComponent(uiRoot.content); + + var loaded = []; + component.onLoadAsyncContent.listen(loaded.add); + + await component.callRenderAndWait(); + await testUISleep(ms: 400); + + expect(loaded, isNotEmpty); + }); + + test('`isValidComponentAsync` static checks', () async { + expect(UIComponentAsync.isValidComponentAsync(null), isFalse); + + var component = _AsyncSubComponent(uiRoot.content); + expect( + UIComponentAsync.isValidComponentAsync(component), + isFalse, + reason: 'Not rendered yet: there is no async content', + ); + + await component.callRenderAndWait(); + await testUISleep(ms: 300); + + expect(UIComponentAsync.isValidComponentAsync(component, {'v': 1}), true); + expect( + UIComponentAsync.isValidLocaleComponentAsync(component), + isTrue, + reason: 'The locale did not change', + ); + }); + }); + + group('UILoadingConfig', () { + test('parses inline properties', () { + var config = UILoadingConfig.parse( + 'type: roller; color: #fff; zoom: 0.5', + ); + + expect(config, isNotNull); + expect(config!.type, equals(UILoadingType.roller)); + expect(config.color, equals('#fff')); + expect(config.zoom, equals(0.5)); + }); + + test('parses the `dualRing` type', () { + // `getUILoadingType` lower-cases the value before matching: + expect(getUILoadingType('dualRing'), equals(UILoadingType.dualRing)); + expect(getUILoadingType('dualring'), equals(UILoadingType.dualRing)); + expect(getUILoadingType('dual-ring'), equals(UILoadingType.dualRing)); + expect(getUILoadingType(UILoadingType.ripple), UILoadingType.ripple); + expect(getUILoadingType('nope'), isNull); + expect(getUILoadingType(null), isNull); + }); + + test('`toInlineProperties` round-trips', () { + var config = UILoadingConfig( + type: UILoadingType.dualRing, + color: '#123456', + zoom: 0.75, + text: 'Wait...', + textZoom: 1.5, + withProgress: true, + ); + + var inline = config.toInlineProperties(); + var parsed = UILoadingConfig.parse(inline); + + expect(parsed, isNotNull); + expect(parsed!.type, equals(UILoadingType.dualRing)); + expect(parsed.color, equals('#123456')); + expect(parsed.zoom, equals(0.75)); + expect(parsed.text, equals('Wait...')); + expect( + parsed.textZoom, + equals(1.5), + reason: '`textZoom` should survive the round-trip', + ); + expect( + parsed.withProgress, + isTrue, + reason: '`withProgress` should survive the round-trip', + ); + }); + + test('`fromMap` accepts the attribute style keys', () { + var config = UILoadingConfig.fromMap({ + 'type': 'spinner', + 'color': '#000', + 'text-zoom': '2.0', + 'with-progress': 'true', + }); + + expect(config, isNotNull); + expect(config!.type, equals(UILoadingType.spinner)); + expect(config.textZoom, equals(2.0)); + expect(config.withProgress, isTrue); + }); + + test('`fromMap` with a prefix', () { + var config = UILoadingConfig.fromMap({ + 'loading-type': 'ripple', + 'loading-color': '#f00', + }, 'loading-'); + + expect(config, isNotNull); + expect(config!.type, equals(UILoadingType.ripple)); + expect(config.color, equals('#f00')); + }); + + test('returns null for an empty config', () { + expect(UILoadingConfig.parse(null), isNull); + expect(UILoadingConfig.parse(''), isNull); + expect(UILoadingConfig.fromMap({}), isNull); + expect(UILoadingConfig.from(null), isNull); + }); + + test('`from` accepts a config, a Map and a String', () { + var config = UILoadingConfig(type: UILoadingType.blocks); + + expect(identical(UILoadingConfig.from(config), config), isTrue); + expect( + UILoadingConfig.from({'type': 'blocks'})?.type, + equals(UILoadingType.blocks), + ); + expect( + UILoadingConfig.from('type: blocks')?.type, + equals(UILoadingType.blocks), + ); + }); + + test('builds a `div` element', () { + var config = UILoadingConfig(type: UILoadingType.ring, text: 'Loading'); + var div = config.asDivElement(); + + expect(div.text, contains('Loading')); + expect(div.outerHTML.toString(), contains('ui-loading')); + }); + }); +} + +class _AsyncRoot extends UIRoot { + _AsyncRoot(super.rootContainer) : super(id: 'async-root'); + + @override + UIComponent? renderContent() => null; +} + +class _AsyncSubComponent extends UIComponentAsync { + int propertyValue = 1; + + int renderAsyncCount = 0; + + _AsyncSubComponent(Object? parent) + : super(parent, null, null, 'loading...', 'error!'); + + @override + Map renderPropertiesProvider() => {'v': propertyValue}; + + @override + Future renderAsync(Map properties) async { + ++renderAsyncCount; + await Future.delayed(Duration(milliseconds: 50)); + return 'async #${properties['v']}'; + } +} diff --git a/test/bones_ui_dialog_test.dart b/test/bones_ui_dialog_test.dart new file mode 100644 index 0000000..e3cf0ad --- /dev/null +++ b/test/bones_ui_dialog_test.dart @@ -0,0 +1,262 @@ +@TestOn('browser') +library; + +import 'package:bones_ui/bones_ui_test.dart'; +import 'package:test/test.dart'; +import 'package:web_utils/web_utils.dart' as web; + +void main() { + group('UIDialog', () { + late final _DialogRoot uiRoot; + + setUpAll(() async { + uiRoot = await initializeTestUIRoot((rootContainer) { + return _DialogRoot(rootContainer); + }); + await uiRoot.callRenderAndWait(); + }); + + tearDown(() { + UIDialog.removeAllDialogs(); + }); + + test('is hidden by default and shows on demand', () async { + var dialog = UIDialog($div(content: 'the dialog content')); + + expect(dialog.isShowing, isFalse); + + dialog.show(); + await testUISleep(ms: 100); + + expect(dialog.isShowing, isTrue); + expect(dialog.content!.text, contains('the dialog content')); + expect(isComponentInDOM(dialog.content), isTrue); + }); + + test('`show: true` shows it on construction', () async { + var dialog = UIDialog($div(content: 'shown'), show: true); + await testUISleep(ms: 100); + + expect(dialog.isShowing, isTrue); + }); + + test('hide restores the previous display style', () async { + var dialog = UIDialog($div(content: 'x'), show: true); + await testUISleep(ms: 50); + + expect(dialog.isShowing, isTrue); + + dialog.hide(); + + expect(dialog.isShowing, isFalse); + expect(dialog.content!.style.display, equals('none')); + expect(dialog.content!.style.visibility, equals('hidden')); + + dialog.show(); + await testUISleep(ms: 50); + + expect(dialog.isShowing, isTrue); + expect(dialog.content!.style.display, isNot(equals('none'))); + }); + + test('onShow/onHide are notified', () async { + var dialog = UIDialog($div(content: 'x')); + + var shown = 0; + var hidden = 0; + dialog.onShow.listen((_) => ++shown); + dialog.onHide.listen((_) => ++hidden); + + dialog.show(); + await testUISleep(ms: 100); + expect(shown, equals(1)); + + dialog.hide(); + await testUISleep(ms: 100); + expect(hidden, equals(1)); + + // Hiding an already hidden dialog does not notify again: + dialog.hide(); + await testUISleep(ms: 100); + expect(hidden, equals(1)); + }); + + test('cancel hides and marks it as canceled', () async { + var dialog = UIDialog($div(content: 'x'), show: true); + await testUISleep(ms: 50); + + expect(dialog.isCanceled, isFalse); + + dialog.cancel(); + + expect(dialog.isCanceled, isTrue); + expect(dialog.isShowing, isFalse); + }); + + test('showAndWait completes when hidden', () async { + var dialog = UIDialog($div(content: 'x')); + + var future = dialog.showAndWait(); + await testUISleep(ms: 100); + + expect(dialog.isShowing, isTrue); + + dialog.hide(); + + expect(await future, isTrue, reason: 'Hidden without cancel'); + }); + + test('showAndWait completes with false when canceled', () async { + var dialog = UIDialog($div(content: 'x')); + + var future = dialog.showAndWait(); + await testUISleep(ms: 100); + + dialog.cancel(); + + expect(await future, isFalse); + }); + + test('getAllDialogs and removeAllDialogs', () async { + expect(UIDialog.getAllDialogs(), isEmpty); + + UIDialog($div(content: 'a'), show: true); + UIDialog($div(content: 'b'), show: true); + await testUISleep(ms: 100); + + expect(UIDialog.getAllDialogs().length, equals(2)); + + UIDialog.removeAllDialogs(); + + expect(UIDialog.getAllDialogs(), isEmpty); + expect( + document.querySelectorAll('.ui-dialog').toList(), + isEmpty, + reason: 'The dialog elements should be removed from the DOM', + ); + }); + + test('a close button is rendered when requested', () async { + var dialog = UIDialog( + $div(content: 'x'), + show: true, + showCloseButton: true, + ); + await testUISleep(ms: 100); + + var buttons = dialog.querySelectorAllNonTyped( + '.${UIDialogBase.dialogButtonClass}', + ); + expect(buttons, isNotEmpty); + }); + }); + + group('UIDialogAlert / UIDialogInput / UIDialogLoading', () { + late final _DialogRoot uiRoot; + + setUpAll(() async { + uiRoot = await initializeTestUIRoot((rootContainer) { + return _DialogRoot(rootContainer); + }); + await uiRoot.callRenderAndWait(); + }); + + tearDown(() { + UIDialog.removeAllDialogs(); + }); + + test('UIDialogAlert renders the text and the button', () async { + var dialog = UIDialogAlert('The message', 'OK'); + dialog.show(); + await testUISleep(ms: 100); + + var text = dialog.content!.text!; + expect(text, contains('The message')); + expect(text, contains('OK')); + }); + + test('UIDialogInput exposes the input field', () async { + var dialog = UIDialogInput( + 'Name', + 'Send', + value: 'initial', + buttonCancelLabel: 'Cancel', + ); + dialog.show(); + await testUISleep(ms: 100); + + expect(dialog.content!.text, contains('Name')); + + var input = dialog.getFieldElementTyped( + UIDialogInput.dialogInputField, + Web.HTMLInputElement, + ); + + expect(input, isNotNull); + expect(input!.value, equals('initial')); + expect( + dialog.getField(UIDialogInput.dialogInputField), + equals('initial'), + ); + }); + + test('UIDialogInput.ask returns the typed value', () async { + var dialog = UIDialogInput('Name', 'Send'); + + var future = dialog.ask(); + await testUISleep(ms: 100); + + var input = dialog.getFieldElementTyped( + UIDialogInput.dialogInputField, + Web.HTMLInputElement, + )!; + input.value = 'typed value'; + + dialog.hide(); + + expect(await future, equals('typed value')); + }); + + test('UIDialogInput.ask returns null when canceled', () async { + var dialog = UIDialogInput('Name', 'Send'); + + var future = dialog.ask(); + await testUISleep(ms: 100); + + dialog.cancel(); + + expect(await future, isNull); + }); + + test('UIDialogLoading renders the text and the loading', () async { + var dialog = UIDialogLoading( + 'Loading data', + UILoadingType.ring, + show: true, + ); + await testUISleep(ms: 100); + + expect(dialog.isShowing, isTrue); + expect(dialog.content!.text, contains('Loading data')); + + var loading = dialog.querySelectorNonTyped('.ui-loading'); + expect(loading, isNotNull); + + // The type class is suffixed with a color ID (e.g. `ui-loading-ring-_fff`): + expect( + loading!.classList.toIterable().any( + (c) => c.toString().startsWith('ui-loading-ring'), + ), + isTrue, + reason: 'Classes: ${loading.classList.value}', + ); + }); + }); +} + +class _DialogRoot extends UIRoot { + _DialogRoot(super.rootContainer) : super(id: 'dialog-root'); + + @override + UIComponent? renderContent() => null; +} diff --git a/test/bones_ui_extension_test.dart b/test/bones_ui_extension_test.dart new file mode 100644 index 0000000..0e9469d --- /dev/null +++ b/test/bones_ui_extension_test.dart @@ -0,0 +1,196 @@ +@TestOn('browser') +library; + +import 'package:bones_ui/bones_ui_test.dart'; +import 'package:test/test.dart'; +import 'package:web_utils/web_utils.dart' as web; + +void main() { + group('UIElementExtension', () { + late final _ExtensionRoot uiRoot; + + setUpAll(() async { + uiRoot = await initializeTestUIRoot((rootContainer) { + return _ExtensionRoot(rootContainer); + }); + await uiRoot.callRenderAndWait(); + }); + + group('resolveInputElementValue', () { + test('`input` text', () { + var input = web.HTMLInputElement() + ..type = 'text' + ..value = 'the value'; + + expect(input.resolveInputElementValue(), equals('the value')); + expect(input.elementValue, equals('the value')); + expect(input.isElementValueEmpty, isFalse); + }); + + test('`textarea`', () { + var textArea = web.HTMLTextAreaElement()..value = 'multi\nline'; + expect(textArea.resolveInputElementValue(), equals('multi\nline')); + }); + + test('`input` checkbox', () { + var checkbox = web.HTMLInputElement() + ..type = 'checkbox' + ..value = 'yes'; + + expect( + checkbox.resolveInputElementValue(), + isNull, + reason: 'An unchecked checkbox has no value', + ); + + checkbox.checked = true; + expect(checkbox.resolveInputElementValue(), equals('yes')); + }); + + test('`input` radio', () { + var radio = web.HTMLInputElement() + ..type = 'radio' + ..value = 'option-a'; + + expect(radio.resolveInputElementValue(), isNull); + + radio.checked = true; + expect(radio.resolveInputElementValue(), equals('option-a')); + }); + + test('`select` with a selected option', () { + var select = web.HTMLSelectElement(); + select.appendHTML(''); + select.appendHTML(''); + + // The first option is selected by default: + expect(select.resolveInputElementValue(), equals('a')); + + select.value = 'b'; + expect(select.resolveInputElementValue(), equals('b')); + }); + + test('a non-input element has no input value', () { + var div = web.HTMLDivElement()..text = 'text'; + expect(div.resolveInputElementValue(), isNull); + }); + }); + + group('resolveElementValue', () { + test('falls back to the `field_value` attribute', () { + var div = web.HTMLDivElement()..text = 'the text'; + div.setAttribute('field_value', 'the attribute value'); + + expect(div.resolveElementValue(), equals('the attribute value')); + }); + + test('falls back to the text when allowed', () { + var div = web.HTMLDivElement()..text = 'the text'; + + expect(div.resolveElementValue(), equals('the text')); + expect( + div.resolveElementValue(allowTextAsValue: false), + isNot(equals('the text')), + ); + }); + + test('resolves through an `UIField` component', () async { + var component = _ValueComponent(uiRoot.content, 'the-field-value'); + await component.callRenderAndWait(); + + var value = component.content!.resolveElementValue( + uiComponent: component, + ); + expect(value, equals('the-field-value')); + }); + + test('resolves through an `UIFieldMap` component', () async { + var component = _MapValueComponent(uiRoot.content); + await component.callRenderAndWait(); + + var value = component.content!.resolveElementValue( + uiComponent: component, + ); + expect(value, contains('a')); + expect(value, contains('1')); + }); + + test('does not throw for a detached element', () { + var div = web.HTMLDivElement()..text = 'detached'; + expect(div.resolveElementValue(), equals('detached')); + expect(div.uiComponent, isNull); + }); + }); + + group('UIIterableElementExtension', () { + test('elementsValues of a list of inputs', () { + var inputs = [ + web.HTMLInputElement() + ..type = 'text' + ..value = 'v1', + web.HTMLInputElement() + ..type = 'text' + ..value = 'v2', + ]; + + expect(inputs.elementsValues, equals(['v1', 'v2'])); + }); + + test('uiComponents of unmapped elements', () { + var elements = [web.HTMLDivElement(), web.HTMLDivElement()]; + expect(elements.uiComponents, equals([null, null])); + }); + }); + + test('isElementValueEmptyTrimmed', () { + var blank = web.HTMLInputElement() + ..type = 'text' + ..value = ' '; + + expect(blank.isElementValueEmpty, isFalse); + expect(blank.isElementValueEmptyTrimmed, isTrue); + + var empty = web.HTMLInputElement() + ..type = 'text' + ..value = ''; + + expect(empty.isElementValueEmpty, isTrue); + expect(empty.isElementValueEmptyTrimmed, isTrue); + }); + }); +} + +class _ExtensionRoot extends UIRoot { + _ExtensionRoot(super.rootContainer) : super(id: 'extension-root'); + + @override + UIComponent? renderContent() => null; +} + +class _ValueComponent extends UIComponent implements UIField { + String? _value; + + _ValueComponent(super.parent, this._value); + + @override + String get fieldName => 'the-field'; + + @override + String? getFieldValue() => _value; + + @override + void setFieldValue(String? value) => _value = value; + + @override + dynamic render() => 'ignored text'; +} + +class _MapValueComponent extends UIComponent implements UIFieldMap { + _MapValueComponent(super.parent); + + @override + Map getFieldMap() => {'a': '1'}; + + @override + dynamic render() => 'ignored text'; +} diff --git a/test/bones_ui_fields_test.dart b/test/bones_ui_fields_test.dart new file mode 100644 index 0000000..46e2943 --- /dev/null +++ b/test/bones_ui_fields_test.dart @@ -0,0 +1,348 @@ +@TestOn('browser') +library; + +import 'package:bones_ui/bones_ui_test.dart'; +import 'package:test/test.dart'; +import 'package:web_utils/web_utils.dart' as web; + +void main() { + group('UIComponent.parseClasses/parseStyle', () { + test('parses classes from a String, a List and nested Lists', () { + expect(UIComponent.parseClasses('a b'), equals(['a', 'b'])); + expect(UIComponent.parseClasses('a,b;c'), equals(['a', 'b', 'c'])); + expect(UIComponent.parseClasses(['a', 'b']), equals(['a', 'b'])); + expect( + UIComponent.parseClasses([ + 'a', + ['b', 'c'], + ]), + equals(['a', 'b', 'c']), + ); + }); + + test('merges and deduplicates two class sources', () { + expect(UIComponent.parseClasses('a b', 'b c'), equals(['a', 'b', 'c'])); + expect(UIComponent.parseClasses('a a'), equals(['a'])); + expect(UIComponent.parseClasses(null, 'a'), equals(['a'])); + expect(UIComponent.parseClasses(null, null), isEmpty); + }); + + test('ignores empty entries', () { + expect(UIComponent.parseClasses(' a ,, b '), equals(['a', 'b'])); + expect(UIComponent.parseClasses(''), isEmpty); + }); + + test('parses style entries', () { + expect( + UIComponent.parseStyle('color: red; background: blue'), + equals(['color: red', 'background: blue']), + ); + expect(UIComponent.parseStyle(''), isEmpty); + }); + }); + + group('UIComponent attributes', () { + late final _FieldsRoot uiRoot; + + setUpAll(() async { + uiRoot = await initializeTestUIRoot((rootContainer) { + return _FieldsRoot(rootContainer); + }); + await uiRoot.callRenderAndWait(); + }); + + test('get/set/clear the `style` attribute', () { + var component = _PlainComponent(uiRoot.content); + component.ensureRendered(); + + expect(component.setAttribute('style', 'color: red'), isTrue); + expect(component.getAttribute('style'), contains('color')); + expect(component.content!.style.color, equals('red')); + + expect(component.clearAttribute('style'), isTrue); + expect(component.content!.style.color, equals('')); + }); + + test('get/set the `class` attribute', () { + var component = _PlainComponent(uiRoot.content); + component.ensureRendered(); + + expect(component.setAttribute('class', 'a b'), isTrue); + expect(component.content!.classList.contains('a'), isTrue); + expect(component.content!.classList.contains('b'), isTrue); + expect(component.getAttribute('class'), contains('a')); + + // `setAttribute` replaces the previous classes: + component.setAttribute('class', 'c'); + expect(component.content!.classList.contains('a'), isFalse); + expect(component.content!.classList.contains('c'), isTrue); + + // `appendAttribute` keeps them: + component.appendAttribute('class', 'd'); + expect(component.content!.classList.contains('c'), isTrue); + expect(component.content!.classList.contains('d'), isTrue); + }); + + test('the `id` attribute is set through `appendAttribute`', () { + var component = _PlainComponent(uiRoot.content); + component.ensureRendered(); + + expect(component.appendAttribute('id', 'the-id'), isTrue); + expect(component.id, equals('the-id')); + expect(component.content!.id, equals('the-id')); + + component.setID(null); + expect(component.id, isNull); + expect(component.content!.id, equals('')); + }); + + test('the `navigate` attribute', () { + var component = _PlainComponent(uiRoot.content); + component.ensureRendered(); + + expect(component.setAttribute('navigate', 'my-route'), isTrue); + expect(component.getAttribute('navigate'), equals('my-route')); + + expect(component.clearAttribute('navigate'), isTrue); + expect(component.getAttribute('navigate'), isNull); + }); + + test('`setAttribute` with a null value clears it', () { + var component = _PlainComponent(uiRoot.content); + component.ensureRendered(); + + component.setAttribute('class', 'a'); + expect(component.content!.classList.contains('a'), isTrue); + + expect(component.setAttribute('class', null), isTrue); + expect(component.content!.classList.contains('a'), isFalse); + }); + + test('an unknown attribute is not set without a generator', () { + var component = _PlainComponent(uiRoot.content); + component.ensureRendered(); + + expect(component.setAttribute('unknown', 'x'), isFalse); + expect(component.getAttribute('unknown'), isNull); + expect(component.clearAttribute('unknown'), isFalse); + expect(component.setAttribute(null, 'x'), isFalse); + expect(component.setAttribute(' ', 'x'), isFalse); + }); + }); + + group('UIComponent fields', () { + late final _FieldsRoot uiRoot; + + setUpAll(() async { + uiRoot = await initializeTestUIRoot((rootContainer) { + return _FieldsRoot(rootContainer); + }); + await uiRoot.callRenderAndWait(); + }); + + Future<_FormComponent> newForm() async { + var form = _FormComponent(uiRoot.content); + await form.callRenderAndWait(); + await testUISleep(ms: 50); + return form; + } + + test('getFields returns all the field values', () async { + var form = await newForm(); + + var fields = form.getFields(); + + expect(fields['name'], equals('Joe')); + expect(fields['age'], equals('42')); + expect(fields['accept'], isNull, reason: 'Unchecked checkbox'); + }); + + test('getFieldsNames and getFieldsElements', () async { + var form = await newForm(); + + expect(form.getFieldsNames(), containsAll(['name', 'age', 'accept'])); + expect(form.getFieldsElements().length, greaterThanOrEqualTo(3)); + expect(form.hasEmptyField(), isFalse); + }); + + test('getField and getFieldAs convert the value', () async { + var form = await newForm(); + + expect(form.getField('name'), equals('Joe')); + expect(form.getField('nope', 'def'), equals('def')); + expect(form.getField(null, 'def'), equals('def')); + + expect(form.getFieldAs('age'), equals(42)); + expect(form.getFieldAs('age'), equals(42.0)); + expect(form.getFieldAs('age'), equals(42)); + expect(form.getFieldAs('name'), equals('Joe')); + expect(form.getFieldAs('nope'), isNull); + }); + + test('setField updates the element value', () async { + var form = await newForm(); + + form.setField('name', 'Ana'); + + expect(form.getField('name'), equals('Ana')); + expect(form.getPreviousRenderedFieldValue('name'), equals('Ana')); + }); + + test('getFieldElement / getFieldElementByValue', () async { + var form = await newForm(); + + var elem = form.getFieldElementNonTyped('name'); + expect(elem, isNotNull); + + var typed = form.getFieldElementTyped( + 'name', + Web.HTMLInputElement, + ); + expect(typed, isNotNull); + expect(typed!.value, equals('Joe')); + + expect(form.getFieldElementByValue('name', 'Joe'), isNotNull); + expect(form.getFieldElementByValue('name', 'nope'), isNull); + }); + + test('isEmptyField and getEmptyFields', () async { + var form = await newForm(); + + expect(form.isEmptyField('name'), isFalse); + expect(form.isEmptyField('accept'), isTrue); + expect(form.isEmptyField(null), isFalse); + + expect(form.getEmptyFields(), contains('accept')); + expect(form.getEmptyFields(), isNot(contains('name'))); + }); + + test('forEach over fields', () async { + var form = await newForm(); + + var count = form.forEachFieldElement((_) {}); + expect(count, greaterThanOrEqualTo(3)); + + var emptyCount = form.forEachEmptyFieldElement((_) {}); + expect(emptyCount, equals(1)); + }); + + test('fields filtered by `fields`/`ignoreFields`', () async { + var form = await newForm(); + + expect(form.getFields(fields: ['name']).keys, equals(['name'])); + expect( + form.getFields(ignoreFields: ['name']).keys, + isNot(contains('name')), + ); + }); + }); + + group('UIComponent fields group by prefix', () { + late final _FieldsRoot uiRoot; + + setUpAll(() async { + uiRoot = await initializeTestUIRoot((rootContainer) { + return _FieldsRoot(rootContainer); + }); + await uiRoot.callRenderAndWait(); + }); + + Future<_GroupComponent> newGroup() async { + var group = _GroupComponent(uiRoot.content); + await group.callRenderAndWait(); + await testUISleep(ms: 50); + return group; + } + + test('getFieldsGroupByPrefix', () async { + var group = await newGroup(); + + var map = group.getFieldsGroupByPrefix('item_'); + + expect(map['a'], equals('1')); + expect(map['b'], equals('2')); + expect(map.containsKey('other'), isFalse); + }); + + test('getFieldsGroupByPrefix with typed values', () async { + var group = await newGroup(); + + var map = group.getFieldsGroupByPrefix('item_'); + + expect(map['a'], equals(1)); + expect(map['b'], equals(2)); + }); + + test('getFieldsGroupKeysByPrefix / ValuesByPrefix', () async { + var group = await newGroup(); + + expect( + group.getFieldsGroupKeysByPrefix('item_'), + containsAll(['a', 'b']), + ); + expect( + group.getFieldsGroupValuesByPrefix('item_'), + containsAll(['1', '2']), + ); + }); + + test('getFieldsGroupChecks / CheckedKeys', () async { + var group = await newGroup(); + + var checks = group.getFieldsGroupChecks('check_'); + + expect(checks['x'], isTrue); + expect(checks['y'], isFalse); + + expect(group.getFieldsGroupCheckedKeys('check_'), equals(['x'])); + }); + + test('getFieldsGroupEntriesByPrefix with a filter', () async { + var group = await newGroup(); + + var entries = group.getFieldsGroupEntriesByPrefix( + 'item_', + filter: (k, v) => k == 'a', + ); + + expect(entries.length, equals(1)); + expect(entries.single.key, equals('a')); + }); + }); +} + +class _FieldsRoot extends UIRoot { + _FieldsRoot(super.rootContainer) : super(id: 'fields-root'); + + @override + UIComponent? renderContent() => null; +} + +class _PlainComponent extends UIComponent { + _PlainComponent(super.parent); + + @override + dynamic render() => 'plain'; +} + +class _FormComponent extends UIComponent { + _FormComponent(super.parent); + + @override + dynamic render() => + '' + '' + ''; +} + +class _GroupComponent extends UIComponent { + _GroupComponent(super.parent); + + @override + dynamic render() => + '' + '' + '' + '' + ''; +} diff --git a/test/bones_ui_navigable_test.dart b/test/bones_ui_navigable_test.dart new file mode 100644 index 0000000..ed30634 --- /dev/null +++ b/test/bones_ui_navigable_test.dart @@ -0,0 +1,274 @@ +@TestOn('browser') +library; + +import 'package:bones_ui/bones_ui_test.dart'; +import 'package:test/test.dart'; + +void main() { + group('UINavigableComponent', () { + late final _NavigableRoot uiRoot; + + setUpAll(() async { + uiRoot = await initializeTestUIRoot((rootContainer) { + return _NavigableRoot(rootContainer); + }); + await uiRoot.callRenderAndWait(); + }); + + test('exposes its routes', () { + var nav = _RoutesComponent(uiRoot.content, ['home', 'contact', 'admin']); + + expect(nav.routes, equals(['home', 'contact', 'admin'])); + expect(nav.currentRoute, equals('home'), reason: 'The first route'); + }); + + test('canNavigateTo only its own routes', () { + var nav = _RoutesComponent(uiRoot.content, ['home', 'contact']); + + expect(nav.canNavigateTo('home'), isTrue); + expect(nav.canNavigateTo('contact'), isTrue); + expect(nav.canNavigateTo('nope'), isFalse); + }); + + test('canNavigateTo sub-routes', () { + var nav = _RoutesComponent(uiRoot.content, ['docs']); + + expect(nav.canNavigateTo('docs/page-1'), isTrue); + expect(nav.canNavigateTo('docs2'), isFalse); + }); + + test('navigateTo changes the current route and renders it', () async { + var nav = _RoutesComponent(uiRoot.content, ['home', 'contact']); + await nav.callRenderAndWait(); + + expect(nav.content!.text, contains('route: home')); + + var navigated = nav.navigateTo('contact', {'id': '10'}); + expect(navigated, isTrue); + + await testUISleep(ms: 100); + + expect(nav.currentRoute, equals('contact')); + expect(nav.currentRouteParameters, equals({'id': '10'})); + expect(nav.content!.text, contains('route: contact')); + expect(nav.content!.text, contains('id=10')); + }); + + test('navigateTo an unknown route is ignored', () async { + var nav = _RoutesComponent(uiRoot.content, ['home', 'contact']); + await nav.callRenderAndWait(); + + expect(nav.navigateTo('nope'), isFalse); + expect(nav.currentRoute, equals('home')); + }); + + test('onChangeRoute is notified once per route change', () async { + var nav = _RoutesComponent(uiRoot.content, ['home', 'contact']); + + var notified = []; + nav.onChangeRoute.listen(notified.add); + + await nav.callRenderAndWait(); + await testUISleep(ms: 100); + + nav.navigateTo('contact'); + await testUISleep(ms: 150); + + expect(notified, contains('contact')); + + var countBefore = notified.length; + + // Navigating to the same route should not notify again: + nav.navigateTo('contact'); + await testUISleep(ms: 150); + + expect(notified.length, equals(countBefore)); + }); + + test('routesAndNames and menuRoutes hide the marked routes', () { + var nav = _RoutesComponent(uiRoot.content, ['home', 'contact', 'admin']); + + expect( + nav.routesAndNames, + equals({'home': 'Home', 'contact': 'Contact', 'admin': 'admin'}), + ); + + expect(nav.isRouteHiddenFromMenu('admin'), isTrue); + expect(nav.menuRoutes, equals(['home', 'contact'])); + expect( + nav.menuRoutesAndNames, + equals({'home': 'Home', 'contact': 'Contact'}), + ); + }); + + test('setRoutes replaces the routes', () { + var nav = _RoutesComponent(uiRoot.content, ['home']); + + nav.setRoutes(['a', 'b']); + + expect(nav.routes, equals(['a', 'b'])); + expect(nav.canNavigateTo('home'), isFalse); + expect(nav.canNavigateTo('a'), isTrue); + + nav.setRoutes(null); + expect(nav.routes, isEmpty); + }); + + test('updateRoutes adds new routes', () { + var nav = _RoutesComponent(uiRoot.content, ['home']); + + expect(nav.updateRoutes(['home']), isFalse, reason: 'Already present'); + expect(nav.updateRoutes(['extra']), isTrue); + expect(nav.routes, equals(['home', 'extra'])); + }); + + test('an empty routes list becomes a `*` wildcard', () { + var nav = _WildcardComponent(uiRoot.content); + + expect(nav.findRoutes, isTrue); + + // `*` accepts any route that `renderRoute` can handle: + expect(nav.canNavigateTo('known-a'), isTrue); + expect(nav.canNavigateTo('known-b'), isTrue); + expect(nav.canNavigateTo('unknown'), isFalse); + + // The accepted routes are memorized: + expect(nav.routes, containsAll(['known-a', 'known-b'])); + }); + + test('is registered in UINavigator', () { + var nav = _RoutesComponent(uiRoot.content, ['unique-route']); + + expect(UINavigator.navigables, contains(nav)); + expect(UINavigator.navigableRoutes, contains('unique-route')); + expect(UINavigator.get().findNavigable('unique-route'), same(nav)); + expect(UINavigator.get().findNavigable('no-such-route'), isNull); + }); + + test('an inaccessible route redirects', () async { + var nav = _RestrictedComponent(uiRoot.content); + await nav.callRenderAndWait(); + + expect(nav.isAccessibleRoute('public'), isTrue); + expect(nav.isAccessibleRoute('private'), isFalse); + expect(nav.deniedAccessRouteOfRoute('private'), equals('public')); + }); + }); + + group('UINavigableContent', () { + late final _NavigableRoot uiRoot; + + setUpAll(() async { + uiRoot = await initializeTestUIRoot((rootContainer) { + return _NavigableRoot(rootContainer); + }); + await uiRoot.callRenderAndWait(); + }); + + test('renders head, content and foot of the route', () async { + var content = _ContentComponent(uiRoot.content); + await content.callRenderAndWait(); + await testUISleep(ms: 100); + + var text = content.content!.text!; + + expect(text, contains('HEAD:home')); + expect(text, contains('BODY:home')); + expect(text, contains('FOOT:home')); + + expect( + text.indexOf('HEAD:home'), + lessThan(text.indexOf('BODY:home')), + reason: 'The head should be rendered before the body', + ); + expect( + text.indexOf('BODY:home'), + lessThan(text.indexOf('FOOT:home')), + reason: 'The body should be rendered before the foot', + ); + }); + + test('a `topMargin` adds a spacer div', () async { + var content = _ContentComponent(uiRoot.content, topMargin: 30); + await content.callRenderAndWait(); + await testUISleep(ms: 100); + + var firstChild = content.content!.children.toIterable().first; + expect(firstChild.style?.height, equals('30px')); + }); + }); +} + +class _NavigableRoot extends UIRoot { + _NavigableRoot(super.rootContainer) : super(id: 'navigable-root'); + + @override + UIComponent? renderContent() => null; +} + +class _RoutesComponent extends UINavigableComponent { + _RoutesComponent(super.parent, List super.routes); + + @override + String? getRouteName(String route) => switch (route) { + 'home' => 'Home', + 'contact' => 'Contact', + _ => null, + }; + + @override + bool isRouteHiddenFromMenu(String route) => route == 'admin'; + + @override + dynamic renderRoute(String? route, Map? parameters) { + var params = (parameters ?? {}).entries + .map((e) => '${e.key}=${e.value}') + .join('&'); + return '
route: $route [$params]
'; + } +} + +/// Declares no routes, so it becomes a `*` (wildcard) navigable. +class _WildcardComponent extends UINavigableComponent { + _WildcardComponent(Object? parent) : super(parent, []); + + @override + dynamic renderRoute(String? route, Map? parameters) { + if (route == 'known-a' || route == 'known-b') { + return '
$route
'; + } + return null; + } +} + +class _RestrictedComponent extends UINavigableComponent { + _RestrictedComponent(Object? parent) : super(parent, ['public', 'private']); + + @override + bool isAccessibleRoute(String route) => route != 'private'; + + @override + String? deniedAccessRouteOfRoute(String route) => + route == 'private' ? 'public' : null; + + @override + dynamic renderRoute(String? route, Map? parameters) => + '
$route
'; +} + +class _ContentComponent extends UINavigableContent { + _ContentComponent(Object? parent, {super.topMargin}) + : super(parent, ['home', 'about']); + + @override + dynamic renderRouteHead(String? route, Map? parameters) => + '
HEAD:$route
'; + + @override + dynamic renderRoute(String? route, Map? parameters) => + '
BODY:$route
'; + + @override + dynamic renderRouteFoot(String? route, Map? parameters) => + '
FOOT:$route
'; +} diff --git a/test/bones_ui_utils_test.dart b/test/bones_ui_utils_test.dart new file mode 100644 index 0000000..d8e288f --- /dev/null +++ b/test/bones_ui_utils_test.dart @@ -0,0 +1,185 @@ +@TestOn('browser') +library; + +import 'package:bones_ui/bones_ui_test.dart'; +import 'package:bones_ui/src/bones_ui_utils.dart'; +import 'package:test/test.dart'; +import 'package:web_utils/web_utils.dart' as web; + +void main() { + group('isEmptyValue', () { + test('null, empty collections and empty strings are empty', () { + expect(isEmptyValue(null), isTrue); + expect(isEmptyValue(''), isTrue); + expect(isEmptyValue([]), isTrue); + expect(isEmptyValue({}), isTrue); + }); + + test('non-empty values', () { + expect(isEmptyValue('x'), isFalse); + expect(isEmptyValue(' '), isFalse); + expect(isEmptyValue([1]), isFalse); + expect(isEmptyValue({'a': 1}), isFalse); + expect(isEmptyValue(0), isFalse, reason: '`0` stringifies to `0`'); + expect(isEmptyValue(false), isFalse); + }); + }); + + group('containsIntlMessage', () { + test('detects an `{{intl:...}}` message', () { + expect(containsIntlMessage('{{intl:key}}'), isTrue); + expect(containsIntlMessage('before {{intl:key}} after'), isTrue); + }); + + test('rejects a text without a message', () { + expect(containsIntlMessage(''), isFalse); + expect(containsIntlMessage('plain text'), isFalse); + expect(containsIntlMessage('{{intl:key'), isFalse); + }); + }); + + group('resolveToText', () { + test('null and String', () { + expect(resolveToText(null), isNull); + expect(resolveToText('text'), equals('text')); + }); + + test('an Iterable is joined and nulls are dropped', () { + expect(resolveToText(['a', 'b']), equals('ab')); + expect(resolveToText(['a', null, 'b']), equals('ab')); + expect(resolveToText([]), isNull); + expect(resolveToText([null]), isNull); + }); + + test('an element resolves to its text', () { + var div = web.HTMLDivElement()..text = 'the text'; + expect(resolveToText(div), equals('the text')); + }); + + test('a DOMElement resolves to its text', () { + expect(resolveToText($div(content: 'dom text')), equals('dom text')); + }); + + test('any other object resolves via toString', () { + expect(resolveToText(42), equals('42')); + expect(resolveToText(true), equals('true')); + }); + }); + + group('stackTraceSafe / yeld', () { + test('stackTraceSafe never throws', () { + expect(stackTraceSafe(), isA()); + }); + + test('yeld resumes after the delay', () async { + var done = false; + var future = yeld(ms: 5).then((_) => done = true); + + expect(done, isFalse); + await future; + expect(done, isTrue); + }); + }); + + group('getLanguageByExtension', () { + test('maps the known extensions', () { + expect(getLanguageByExtension('dart'), equals('dart')); + expect(getLanguageByExtension('md'), equals('markdown')); + expect(getLanguageByExtension('markdown'), equals('markdown')); + expect(getLanguageByExtension('html'), equals('html')); + expect(getLanguageByExtension('htm'), equals('html')); + expect(getLanguageByExtension('json'), equals('json')); + expect(getLanguageByExtension('txt'), equals('text')); + expect(getLanguageByExtension('py'), equals('python')); + expect(getLanguageByExtension('rb'), equals('ruby')); + }); + + test('normalizes the extension', () { + expect(getLanguageByExtension(' MD '), equals('markdown')); + expect(getLanguageByExtension('.md'), equals('markdown')); + }); + + test('returns null for an unknown or empty extension', () { + expect(getLanguageByExtension('unknown'), isNull); + expect(getLanguageByExtension(''), isNull); + expect(getLanguageByExtension(' '), isNull); + expect(getLanguageByExtension('...'), isNull); + }); + }); + + group('URLLink', () { + test('holds the url and the target', () { + var link = URLLink('https://foo.com', '_blank'); + + expect(link.url, equals('https://foo.com')); + expect(link.target, equals('_blank')); + expect(link.toString(), contains('https://foo.com')); + + expect(URLLink('https://bar.com').target, isNull); + }); + }); + + group('isComponentInDOM / canBeInDOM', () { + late final _UtilsRoot uiRoot; + + setUpAll(() async { + uiRoot = await initializeTestUIRoot((rootContainer) { + return _UtilsRoot(rootContainer); + }); + await uiRoot.callRenderAndWait(); + }); + + test('a detached element is not in the DOM', () { + var div = web.HTMLDivElement(); + + expect(isComponentInDOM(div), isFalse); + expect(canBeInDOM(div), isTrue); + }); + + test('an attached element is in the DOM', () { + var div = web.HTMLDivElement(); + uiRoot.content!.append(div); + + expect(isComponentInDOM(div), isTrue); + }); + + test('a rendered component is in the DOM', () async { + var component = _UtilsComponent(uiRoot.content); + await component.callRenderAndWait(); + + expect(isComponentInDOM(component), isTrue); + expect(canBeInDOM(component), isTrue); + }); + + test('a List is checked recursively', () { + var attached = web.HTMLDivElement(); + uiRoot.content!.append(attached); + + expect(isComponentInDOM([web.HTMLDivElement(), attached]), isTrue); + expect(isComponentInDOM([web.HTMLDivElement()]), isFalse); + expect(isComponentInDOM([]), isFalse); + expect(canBeInDOM([]), isTrue); + }); + + test('non-renderable values', () { + expect(isComponentInDOM(null), isFalse); + expect(canBeInDOM(null), isFalse); + expect(canBeInDOM('text'), isFalse); + expect(canBeInDOM(42), isFalse); + }); + }); +} + +class _UtilsRoot extends UIRoot { + _UtilsRoot(super.rootContainer) : super(id: 'utils-root'); + + @override + UIComponent? renderContent() => null; +} + +class _UtilsComponent extends UIComponent { + _UtilsComponent(super.parent); + + @override + dynamic render() => '
utils
'; +} From 26e31e61f03faf4ec518d1fab191beaf2540c9c4 Mon Sep 17 00:00:00 2001 From: "Graciliano M. P." Date: Wed, 12 Aug 2026 22:37:14 -0300 Subject: [PATCH 3/3] Fix all lint issues reported by `dart analyze --fatal-infos` The CI `build` job runs `dart analyze --fatal-infos --fatal-warnings .`, which was failing on `master` too: the workflow pins `sdk: latest` and newer SDKs added `unawaited_return_in_try_block` and widened `use_super_parameters`. - `BUIViewProvider.fromManifestContent`: the `return` of the `async` `fromManifestTree` was inside a `try` block, so its errors were never caught by the `catch` (`unawaited_return_in_try_block`). Moved out of the `try`, preserving the behavior and making the guarded scope explicit. - `use_super_parameters` in `UIDocument`, `BUIRender`, `UIButtonCapture`, `UIDialogInput`, `UIDialogAlert` and `UIDialogLoading`. Applied with `dart fix`; the only default that differs from the super constructor (`fullScreen = false` in the `UIDialog` subclasses, vs `true` in `UIDialogBase`) is kept explicit. Verified locally with every step of the CI `build` job: `dart format`, `dart analyze --fatal-infos --fatal-warnings .`, `dependency_validator` and `dart pub publish --dry-run`. Full suite: 296 tests on dart2js and dart2wasm. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 9 ++++ lib/src/bones_ui_document.dart | 15 ++---- lib/src/component/bui.dart | 15 +++--- lib/src/component/capture.dart | 27 ++++------ lib/src/component/dialog.dart | 92 +++++++++++----------------------- 5 files changed, 61 insertions(+), 97 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9f7d03..6d786c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,15 @@ wrapper, so the lookup always missed and both methods were no-ops. They now resolve the instances through `UIRootComponent.getInstances`. +- Lints (`dart analyze --fatal-infos --fatal-warnings`): + - `BUIViewProvider.fromManifestContent`: the `return` of the `async` + `fromManifestTree` was inside a `try` block, so its errors were never caught by the + `catch` (`unawaited_return_in_try_block`). Moved out of the `try`, which keeps the + behavior and makes the guarded scope explicit. + - Converted the forwarded constructor parameters to super parameters in `UIDocument`, + `BUIRender`, `UIButtonCapture`, `UIDialogInput`, `UIDialogAlert` and `UIDialogLoading` + (`use_super_parameters`). + - Tests: - Added `test/bones_ui_component_test.dart`, `test/bones_ui_async_content_test.dart`, `test/bones_ui_layout_test.dart`, `test/bones_ui_navigator_test.dart` and diff --git a/lib/src/bones_ui_document.dart b/lib/src/bones_ui_document.dart index 489eb9b..23c04e0 100644 --- a/lib/src/bones_ui_document.dart +++ b/lib/src/bones_ui_document.dart @@ -139,11 +139,11 @@ class UIDocument extends UIComponentAsync { mk.ExtensionSet? markdownExtensionSet, loadingContent, errorContent, - dynamic classes, - dynamic classes2, - dynamic style, - dynamic style2, - dynamic id, + super.classes, + super.classes2, + super.style, + super.style2, + super.id, }) : _markdownExtensionSet = markdownExtensionSet, _resourceContent = resourceContent, super( @@ -153,11 +153,6 @@ class UIDocument extends UIComponentAsync { loadingContent, errorContent, componentClass: 'ui-document', - classes: classes, - classes2: classes2, - style: style, - style2: style2, - id: id, generator: generator, ); diff --git a/lib/src/component/bui.dart b/lib/src/component/bui.dart index b14c225..a37b2be 100644 --- a/lib/src/component/bui.dart +++ b/lib/src/component/bui.dart @@ -98,8 +98,8 @@ class BUIRender extends UINavigableComponent { DOMGenerator? domGenerator, DataAssets? dataAssets, BUIViewProviderBase? viewProvider, - dynamic classes, - dynamic style, + super.classes, + super.style, bool renderOnConstruction = true, }) : renderDomGenerator = domGenerator ?? DOMGeneratorDelegate(UIComponent.domGenerator), @@ -109,8 +109,6 @@ class BUIRender extends UINavigableComponent { parent, ['*'], componentClass: 'ui-render', - classes: classes, - style: style, renderOnConstruction: false, ) { _navbarSource = BUIRenderSource( @@ -701,15 +699,18 @@ class BUIViewProvider extends BUIViewProviderBase { var manifestTree = parseJSON(manifestContent); return BUIViewProvider.fromManifestTree(manifestTree); } else { + Map? manifestTree; try { - var manifestTree = loadYaml(manifestContent) as Map?; + manifestTree = loadYaml(manifestContent) as Map?; print(encodeJSON(manifestTree)); - return BUIViewProvider.fromManifestTree(manifestTree); } catch (e) { print(e); + return null; } + // Resolved outside of the `try` block: `fromManifestTree` is `async`, + // so its errors were never caught by the `catch` above. + return BUIViewProvider.fromManifestTree(manifestTree); } - return null; } static Future fromManifestTree( diff --git a/lib/src/component/capture.dart b/lib/src/component/capture.dart index 4634afb..28b3986 100644 --- a/lib/src/component/capture.dart +++ b/lib/src/component/capture.dart @@ -1323,31 +1323,22 @@ class UIButtonCapture extends UICapture { final String? fontSize; UIButtonCapture( - Element? parent, + Element? super.parent, this.text, - CaptureType captureType, { + super.captureType, { super.editCapture, super.photoEditor, - String? fieldName, - String? navigate, - Map? navigateParameters, - ParametersProvider? navigateParametersProvider, - dynamic classes, - dynamic classes2, + super.fieldName, + super.navigate, + super.navigateParameters, + super.navigateParametersProvider, + super.classes, + super.classes2, dynamic componentClass, - dynamic style, + super.style, bool small = false, this.fontSize, }) : super( - parent, - captureType, - fieldName: fieldName, - navigate: navigate, - navigateParameters: navigateParameters, - navigateParametersProvider: navigateParametersProvider, - classes: classes, - classes2: classes2, - style: style, componentClass: [ small ? 'ui-button-small' : 'ui-button', componentClass, diff --git a/lib/src/component/dialog.dart b/lib/src/component/dialog.dart index 1515dd8..66375e0 100644 --- a/lib/src/component/dialog.dart +++ b/lib/src/component/dialog.dart @@ -587,15 +587,15 @@ class UIDialogInput extends UIDialog { String? inputClasses, String? inputStyle, String? value, - bool hideUIRoot = false, - bool showCloseButton = false, - dynamic classes, - dynamic style, - String padding = '6px', - bool fullScreen = false, - int backgroundGrey = 0, - double backgroundAlpha = 0.80, - int? backgroundBlur, + super.hideUIRoot, + super.showCloseButton, + super.classes, + super.style, + super.padding, + super.fullScreen = false, + super.backgroundGrey, + super.backgroundAlpha, + super.backgroundBlur, }) : super( _buildContent( label, @@ -609,15 +609,6 @@ class UIDialogInput extends UIDialog { inputStyle, value, ), - hideUIRoot: hideUIRoot, - showCloseButton: showCloseButton, - classes: classes, - style: style, - padding: padding, - fullScreen: fullScreen, - backgroundGrey: backgroundGrey, - backgroundAlpha: backgroundAlpha, - backgroundBlur: backgroundBlur, ); Future ask() async { @@ -649,27 +640,16 @@ class UIDialogAlert extends UIDialog { String text, String buttonLabel, { String? buttonClasses, - bool hideUIRoot = false, - bool showCloseButton = false, - dynamic classes, - dynamic style, - String padding = '6px', - bool fullScreen = false, - int backgroundGrey = 0, - double backgroundAlpha = 0.80, - int? backgroundBlur, - }) : super( - _buildContent(text, buttonLabel, buttonClasses), - hideUIRoot: hideUIRoot, - showCloseButton: showCloseButton, - classes: classes, - style: style, - padding: padding, - fullScreen: fullScreen, - backgroundGrey: backgroundGrey, - backgroundAlpha: backgroundAlpha, - backgroundBlur: backgroundBlur, - ); + super.hideUIRoot, + super.showCloseButton, + super.classes, + super.style, + super.padding, + super.fullScreen = false, + super.backgroundGrey, + super.backgroundAlpha, + super.backgroundBlur, + }) : super(_buildContent(text, buttonLabel, buttonClasses)); } class UIDialogLoading extends UIDialog { @@ -688,27 +668,15 @@ class UIDialogLoading extends UIDialog { UIDialogLoading( String text, UILoadingType loadingType, { - bool hideUIRoot = false, - bool showCloseButton = false, - bool show = false, - dynamic classes, - dynamic style, - String padding = '6px', - bool fullScreen = false, - int backgroundGrey = 0, - double backgroundAlpha = 0.80, - int? backgroundBlur, - }) : super( - _buildContent(loadingType, text), - hideUIRoot: hideUIRoot, - showCloseButton: showCloseButton, - show: show, - classes: classes, - style: style, - padding: padding, - fullScreen: fullScreen, - backgroundGrey: backgroundGrey, - backgroundAlpha: backgroundAlpha, - backgroundBlur: backgroundBlur, - ); + super.hideUIRoot, + super.showCloseButton, + super.show, + super.classes, + super.style, + super.padding, + super.fullScreen = false, + super.backgroundGrey, + super.backgroundAlpha, + super.backgroundBlur, + }) : super(_buildContent(loadingType, text)); }