diff --git a/lona/client/_lona/client/context.js b/lona/client/_lona/client/context.js index 986b4b99..a7b6520d 100644 --- a/lona/client/_lona/client/context.js +++ b/lona/client/_lona/client/context.js @@ -22,7 +22,7 @@ SOFTWARE. */ -import { LonaWindowShim } from './window-shim.js'; +import { LonaWindowShim } from '../client2/window-shim.js'; import { LonaWindow } from './window.js'; import { Lona } from './lona.js'; diff --git a/lona/client/_lona/client/dom-renderer.js b/lona/client/_lona/client/dom-renderer.js index e50720a3..34b799a7 100644 --- a/lona/client/_lona/client/dom-renderer.js +++ b/lona/client/_lona/client/dom-renderer.js @@ -22,7 +22,7 @@ SOFTWARE. */ -import { LonaWindowShim } from './window-shim.js'; +import { Widget } from '../client2/widget.js'; import { Lona } from './lona.js'; @@ -30,8 +30,17 @@ export class LonaDomRenderer { constructor(lona_context, lona_window) { this.lona_context = lona_context; this.lona_window = lona_window; + + this._dom_parser = new DOMParser(); }; + _parse_html_string(html_string) { + return this._dom_parser.parseFromString( + html_string, + 'text/html', + ).documentElement.textContent; + } + // html rendering --------------------------------------------------------- _render_node(node_spec) { var property_names = ['value', 'checked', 'selected']; @@ -114,25 +123,23 @@ export class LonaDomRenderer { throw(`RuntimeError: unknown widget name '${widget_class_name}'`); } - var widget_class = Lona.widget_classes[widget_class_name]; - - var window_shim = new LonaWindowShim( + const widget = new Widget( this.lona_context, this.lona_window, + node, node_id, + Lona.widget_classes[widget_class_name], + widget_data, ); - var widget = new widget_class(window_shim); - this.lona_window._widgets[node_id] = widget; - this.lona_window._widget_data[node_id] = widget_data; this.lona_window._widgets_to_setup.splice(0, 0, node_id); } // TextNode } else if(node_type == Lona.protocol.NODE_TYPE.TEXT_NODE) { var node_id = node_spec[1]; - var node_content = node_spec[2]; + var node_content = this._parse_html_string(node_spec[2]); var node = document.createTextNode(node_content); @@ -170,18 +177,16 @@ export class LonaDomRenderer { // setup widget if(node_widget_class_name in Lona.widget_classes) { - var widget_class = Lona.widget_classes[node_widget_class_name]; - - var window_shim = new LonaWindowShim( + const widget = new Widget( this.lona_context, this.lona_window, + node, node_id, + Lona.widget_classes[widget_class_name], + widget_data, ); - var widget = new widget_class(window_shim); - this.lona_window._widgets[node_id] = widget; - this.lona_window._widget_data[node_id] = widget_data; this.lona_window._widgets_to_setup.splice(0, 0, node_id); }; }; diff --git a/lona/client/_lona/client/dom-updater.js b/lona/client/_lona/client/dom-updater.js index be61806e..cbeb294c 100644 --- a/lona/client/_lona/client/dom-updater.js +++ b/lona/client/_lona/client/dom-updater.js @@ -275,9 +275,12 @@ export class LonaDomUpdater { _remove_node(node_id) { - // TextNode + // Node if(node_id in this.lona_window._nodes) { - this.lona_window._nodes[node_id].remove(); + node = this.lona_window._nodes[node_id]; + + node.remove(); + this.lona_window._remove_widget_if_present(node_id); delete this.lona_window._nodes[node_id]; @@ -310,11 +313,7 @@ export class LonaDomUpdater { node.remove(); }; - // Node - } else { - node = this.lona_window._nodes[node_id]; - - node.remove(); + this.lona_window._remove_widget_if_present(node_id); }; }; diff --git a/lona/client/_lona/client/widget-data-updater.js b/lona/client/_lona/client/widget-data-updater.js index 63f9d114..953bfe14 100644 --- a/lona/client/_lona/client/widget-data-updater.js +++ b/lona/client/_lona/client/widget-data-updater.js @@ -40,9 +40,11 @@ export class LonaWidgetDataUpdater { var key_path = payload[0]; var data = payload.splice(1); + const widget = this.lona_window._widgets[node_id]; + // key path var parent_data = undefined; - var widget_data = this.lona_window._widget_data[node_id]; + let widget_data = widget.raw_widget_data; key_path.forEach(function(key) { parent_data = widget_data; @@ -56,7 +58,7 @@ export class LonaWidgetDataUpdater { // RESET } else if(operation == Lona.protocol.OPERATION.RESET) { if(parent_data === undefined) { - this.lona_window._widget_data[node_id] = data[0]; + widget.raw_widget_data = data[0]; } else { parent_data = data[0]; @@ -74,7 +76,7 @@ export class LonaWidgetDataUpdater { }; if(parent_data === undefined) { - this.lona_window._widget_data[node_id] = new_data; + widget.raw_widget_data = new_data; } else { parent_data[key_path[key_path.length-1]] = new_data; diff --git a/lona/client/_lona/client/window-shim.js b/lona/client/_lona/client/window-shim.js deleted file mode 100644 index c38ff3ea..00000000 --- a/lona/client/_lona/client/window-shim.js +++ /dev/null @@ -1,49 +0,0 @@ -/* MIT License - -Copyright (c) 2020 Florian Scherf - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -*/ - -export class LonaWindowShim { - constructor(lona_context, lona_window, widget_id) { - this.lona_context = lona_context; - - this._lona_window = lona_window; - this._widget_id = widget_id; - }; - - fire_input_event(node, event_type, data, target_node) { - return this._lona_window._input_event_handler.fire_input_event( - node || this._widget_id, - event_type, - data, - target_node, - ); - }; - - set_html(html) { - if(this._lona_window._view_running) { - throw('RuntimeError: cannot set HTML while a view is running'); - }; - - this._lona_window._root.innerHTML = html; - }; -}; diff --git a/lona/client/_lona/client/window.js b/lona/client/_lona/client/window.js index dac46cb5..e973d3eb 100644 --- a/lona/client/_lona/client/window.js +++ b/lona/client/_lona/client/window.js @@ -25,7 +25,7 @@ SOFTWARE. import { LonaWidgetDataUpdater } from './widget-data-updater.js' import { LonaInputEventHandler } from './input-events.js'; import { LonaDomRenderer } from './dom-renderer.js'; -import { LonaDomUpdater } from './dom-updater.js' +import { LonaDomUpdater } from './dom-updater.js'; import { Lona } from './lona.js' @@ -59,7 +59,7 @@ export class LonaWindow { this._crashed = false; this._view_running = false; this._view_runtime_id = undefined; - this._url = ''; + this._url = new URL(window.location); this._nodes = {}; this._widget_marker = {}; this._widget_data = {}; @@ -70,41 +70,7 @@ export class LonaWindow { // urls ------------------------------------------------------------------- _set_url(raw_url) { - // parse pathname, search and hash - var _raw_url = new URL(raw_url, window.location.origin); - - _raw_url = _raw_url.pathname + _raw_url.search + _raw_url.hash; - - if(raw_url.startsWith('..')) { - _raw_url = '..' + _raw_url; - - } else if(raw_url.startsWith('.')) { - _raw_url = '.' + _raw_url; - - }; - - if(!raw_url.startsWith('http://') && !raw_url.startsWith('https://')) { - if(_raw_url.startsWith('/') && !raw_url.startsWith('/')) { - _raw_url = _raw_url.substr(1); - } - }; - - raw_url = _raw_url; - - // handle relative URLs - if(!raw_url.startsWith('/')) { - var current_url = this._url; - - if(!current_url.endsWith('/')) { - current_url = current_url + '/'; - }; - - raw_url = current_url + raw_url; - }; - - var url = new URL(raw_url, window.location.origin); - - this._url = url.pathname + url.search + url.hash; + this._url = new URL(raw_url, this._url); }; get_url() { @@ -120,9 +86,7 @@ export class LonaWindow { _clear_node_cache() { // running widget deconstructors for(var key in this._widgets) { - if(this._widgets[key].deconstruct !== undefined) { - this._widgets[key].deconstruct(); - }; + this._widgets[key].destroy_widget(); }; // resetting node state @@ -134,6 +98,33 @@ export class LonaWindow { this._widgets_to_update = []; }; + _remove_widget_if_present(node_id) { + if(!(node_id in this._widgets)) { + return; + } + + // run deconstructor + this._widgets[node_id].destroy_widget(); + + // remove widget + delete this._widgets[node_id]; + + // remove widget data + delete this._widget_data[node_id]; + + // remove widget from _widgets_to_setup + if(this._widgets_to_setup.indexOf(node_id) > -1) { + this._widgets_to_setup.splice( + this._widgets_to_setup.indexOf(node_id), 1); + }; + + // remove widget from _widgets_to_update + if(this._widgets_to_update.indexOf(node_id) > -1) { + this._widgets_to_update.splice( + this._widgets_to_update.indexOf(node_id), 1); + }; + } + _clean_node_cache() { // nodes Object.keys(this._nodes).forEach(key => { @@ -155,32 +146,8 @@ export class LonaWindow { delete this._widget_marker[key]; - // widget - if(key in this._widgets) { - - // run deconstructor - if(this._widgets[key].deconstruct !== undefined) { - this._widgets[key].deconstruct(); - }; - - // remove widget - delete this._widgets[key]; - - // remove widget data - delete this._widget_data[key]; - - // remove widget from _widgets_to_setup - if(this._widgets_to_setup.indexOf(key) > -1) { - this._widgets_to_setup.splice( - this._widgets_to_setup.indexOf(key), 1); - }; - - // remove widget from _widgets_to_update - if(this._widgets_to_update.indexOf(key) > -1) { - this._widgets_to_update.splice( - this._widgets_to_update.indexOf(key), 1); - }; - }; + // frontend widget + this._remove_widget_if_present(key); }); }; @@ -188,37 +155,24 @@ export class LonaWindow { _run_widget_hooks() { // setup this._widgets_to_setup.forEach(node_id => { - var widget = this._widgets[node_id]; - var widget_data = this._widget_data[node_id]; - - widget.data = JSON.parse(JSON.stringify(widget_data)); - - if(widget === undefined) { + if(this._widgets[node_id] === undefined) { return; - }; + } - widget.nodes = this._dom_updater._get_widget_nodes(node_id); - widget.root_node = widget.nodes[0]; + const widget = this._widgets[node_id]; - if(widget.setup !== undefined) { - widget.setup(); - }; + widget.initialize_widget(); }); // data_updated this._widgets_to_update.forEach(node_id => { - var widget = this._widgets[node_id]; - var widget_data = this._widget_data[node_id]; - - widget.data = JSON.parse(JSON.stringify(widget_data)); - - if(widget === undefined) { + if(this._widgets[node_id] === undefined) { return; - }; + } - if(widget.data_updated !== undefined) { - widget.data_updated(); - }; + const widget = this._widgets[node_id]; + + widget.run_data_updated_hook(); }); this._widgets_to_setup = []; @@ -318,7 +272,7 @@ export class LonaWindow { this._view_running = true; if(this.lona_context.settings.update_address_bar) { - history.pushState({}, '', this.get_url()); + history.pushState({}, '', this.get_url().href); }; this._clear(); @@ -342,7 +296,9 @@ export class LonaWindow { // http redirect } else if(method == Lona.protocol.METHOD.HTTP_REDIRECT) { if(this.lona_context.settings.follow_http_redirects) { - window.location = payload; + this._set_url(payload); + + window.location = this.get_url().href; } else { console.debug( @@ -433,7 +389,7 @@ export class LonaWindow { this._window_id, this._view_runtime_id, Lona.protocol.METHOD.VIEW, - [this.get_url(), post_data], + [this.get_url().href, post_data], ]; // update html title diff --git a/lona/client2/_lona/client2/rendering-engine.js b/lona/client2/_lona/client2/rendering-engine.js index e922a6c1..ce28500e 100644 --- a/lona/client2/_lona/client2/rendering-engine.js +++ b/lona/client2/_lona/client2/rendering-engine.js @@ -24,7 +24,7 @@ SOFTWARE. 'use strict'; -import { LonaWindowShim } from './window-shim.js'; +import { Widget } from './widget.js'; import { Lona } from './lona.js'; const SPECIAL_ATTRIBUTE_NAMES = ['id', 'class', 'style', 'data-lona-node-id']; @@ -38,10 +38,10 @@ export class LonaRenderingEngine { this._root = root; this._nodes = new Map(); - this._widget_data = new Map(); this._widgets = new Map(); this._widgets_to_setup = new Array(); this._widgets_to_update = new Array(); + this._dom_parser = new DOMParser(); }; // helper ----------------------------------------------------------------- @@ -72,6 +72,14 @@ export class LonaRenderingEngine { return this._nodes.get(node_id); }; + _cache_node(node_id, node) { + if(this._nodes.has(node_id)) { + throw(`node with id ${node_id} is already cached`); + } + + this._nodes.set(node_id, node); + }; + _insert_node(node, node_id, index) { const target_node = this._get_node(node_id); @@ -93,26 +101,58 @@ export class LonaRenderingEngine { this._nodes.delete(node_id); node.remove(); + + this._remove_widget_if_present(node_id); }; + _remove_widget_if_present(node_id) { + if(!(this._widgets.has(node_id))) { + return; + } + + const widget = this._widgets.get(node_id); + + // destroy widget + widget.destroy_widget(); + + // remove widget + this._widgets.delete(node_id); + + // remove widget from _widgets_to_setup + if(this._widgets_to_setup.indexOf(node_id) > -1) { + this._widgets_to_setup.splice( + this._widgets_to_setup.indexOf(node_id), 1); + }; + + // remove widget from _widgets_to_update + if(this._widgets_to_update.indexOf(node_id) > -1) { + this._widgets_to_update.splice( + this._widgets_to_update.indexOf(node_id), 1); + }; + } + _clear_node(node_id) { const node = this._get_node(node_id); node.innerHTML = ''; }; + _parse_html_string(html_string) { + return this._dom_parser.parseFromString( + html_string, + 'text/html', + ).documentElement.textContent; + } + // node cache ------------------------------------------------------------- _clear_node_cache() { - // running widget deconstructors + // destroy widgets this._widgets.forEach(widget => { - if(widget.deconstruct !== undefined) { - widget.deconstruct(); - }; + widget.destroy_widget(); }); // resetting node state this._nodes.clear(); - this._widget_data.clear(); this._widgets.clear(); this._widgets_to_setup.length = 0; this._widgets_to_update.length = 0; @@ -120,43 +160,12 @@ export class LonaRenderingEngine { _clean_node_cache() { this._nodes.forEach((node, node_id) => { - - // nodes if(this._root.contains(node)) { return; } this._remove_node(node_id); - - // widgets - if(!(this._widgets.has(node_id))) { - return; - } - - const widget = this._widgets.get(node_id); - - // run deconstructor - if(widget.deconstruct !== undefined) { - widget.deconstruct(); - }; - - // remove widget - this._widgets.delete(node_id); - - // remove widget data - this._widget_data.delete(node_id); - - // remove widget from _widgets_to_setup - if(this._widgets_to_setup.indexOf(node_id) > -1) { - this._widgets_to_setup.splice( - this._widgets_to_setup.indexOf(node_id), 1); - }; - - // remove widget from _widgets_to_update - if(this._widgets_to_update.indexOf(node_id) > -1) { - this._widgets_to_update.splice( - this._widgets_to_update.indexOf(node_id), 1); - }; + this._remove_widget_if_present(node_id); }); }; @@ -167,11 +176,11 @@ export class LonaRenderingEngine { // TextNode if(node_type == Lona.protocol.NODE_TYPE.TEXT_NODE) { const node_id = node_spec[1]; - const node_content = node_spec[2]; + const node_content = this._parse_html_string(node_spec[2]); const node = document.createTextNode(node_content); - this._nodes.set(node_id, node); + this._cache_node(node_id, node); return node; }; @@ -237,7 +246,7 @@ export class LonaRenderingEngine { node.appendChild(child_node); }); - this._nodes.set(node_id, node); + this._cache_node(node_id, node); // widget if(widget_class_name != '') { @@ -245,18 +254,16 @@ export class LonaRenderingEngine { throw(`RuntimeError: unknown widget name '${widget_class_name}'`); } - const widget_class = Lona.widget_classes[widget_class_name]; - - const window_shim = new LonaWindowShim( + const widget = new Widget( this.lona_context, this.lona_window, + node, node_id, + Lona.widget_classes[widget_class_name], + widget_data, ); - const widget = new widget_class(window_shim); - this._widgets.set(node_id, widget); - this._widget_data.set(node_id, widget_data); this._widgets_to_setup.splice(0, 0, node_id); } @@ -270,37 +277,24 @@ export class LonaRenderingEngine { _run_widget_hooks() { // setup this._widgets_to_setup.forEach(node_id => { - const widget = this._widgets.get(node_id); - const widget_data = this._widget_data.get(node_id); - - widget.data = JSON.parse(JSON.stringify(widget_data)); - - if(widget === undefined) { + if(!this._widgets.has(node_id)) { return; - }; + } - widget.nodes = [this._get_node(node_id)]; - widget.root_node = widget.nodes[0]; + const widget = this._widgets.get(node_id); - if(widget.setup !== undefined) { - widget.setup(); - }; + widget.initialize_widget(); }); // data_updated this._widgets_to_update.forEach(node_id => { - const widget = this._widgets.get(node_id); - const widget_data = this._widget_data.get(node_id); - - widget.data = JSON.parse(JSON.stringify(widget_data)); - - if(widget === undefined) { + if(!this._widgets.has(node_id)) { return; - }; + } - if(widget.data_updated !== undefined) { - widget.data_updated(); - }; + const widget = this._widgets.get(node_id); + + widget.run_data_updated_hook(); }); this._widgets_to_setup = []; @@ -526,9 +520,11 @@ export class LonaRenderingEngine { const key_path = payload[0]; const data = payload.splice(1); + const widget = this._widgets.get(node_id); + // key path let parent_data = undefined; - let widget_data = this._widget_data.get(node_id); + let widget_data = widget.raw_widget_data; let new_data = undefined; key_path.forEach(key => { @@ -543,7 +539,7 @@ export class LonaRenderingEngine { // RESET } else if(operation == Lona.protocol.OPERATION.RESET) { if(parent_data === undefined) { - this._widget_data.set(node_id, data[0]); + widget.raw_widget_data = data[0]; } else { parent_data = data[0]; @@ -561,7 +557,7 @@ export class LonaRenderingEngine { }; if(parent_data === undefined) { - this._widget_data.set(node_id, new_data); + widget.raw_widget_data.new_data; } else { parent_data[key_path[key_path.length-1]] = new_data; diff --git a/lona/client2/_lona/client2/widget.js b/lona/client2/_lona/client2/widget.js new file mode 100644 index 00000000..a14708c4 --- /dev/null +++ b/lona/client2/_lona/client2/widget.js @@ -0,0 +1,137 @@ +/* MIT License + +Copyright (c) 2020 Florian Scherf + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +*/ + +'use strict'; + +import { LonaWindowShim } from './window-shim.js'; + + +function deepCopy(object) { + return JSON.parse(JSON.stringify(object)); +} + + +class WidgetData { + constructor(widget) { + this._widget = widget; + + this.data = {}; + + this._update_data(); + } + + _update_data() { + this.data = deepCopy(this._widget.raw_widget_data); + } +} + + +export class Widget { + constructor( + lona_context, + lona_window, + root_node, + widget_id, + widget_class, + raw_widget_data, + ) { + + this.lona_context = lona_context; + this.lona_window = lona_window; + this.root_node = root_node; + this.widget_id = widget_id; + this.widget_class = widget_class; + this.raw_widget_data = raw_widget_data; + + this.window_shim = new LonaWindowShim( + this.lona_context, + this.lona_window, + this.widget_id, + ); + + this.widget_data = new WidgetData(this); + + this.widget_object = undefined; + } + + initialize_widget() { + this.widget_object = new this.widget_class( + this.window_shim, + this.root_node, + this.widget_data, + ); + + // legacy API + // TODO: remove in 2.0 + // set data + this.widget_object.data = this.widget_data.data; + + // set nodes + this.widget_object.root_node = this.root_node; + this.widget_object.nodes = [this.root_node]; + + // legacy setup hook + if(this.widget_object.setup !== undefined) { + this.widget_object.setup(); + } + } + + destroy_widget() { + if(this.widget_object === undefined) { + return; + } + + if(this.widget_object.destroy !== undefined) { + this.widget_object.destroy(); + } + + // legacy API + // TODO: remove in 2.0 + if(this.widget_object.deconstruct !== undefined) { + this.widget_object.deconstruct(); + } + } + + run_data_updated_hook() { + if(this.widget_object === undefined) { + return; + } + + this.widget_data._update_data(); + + if(this.widget_object.onDataUpdated !== undefined) { + this.widget_object.onDataUpdated(this.widget_data); + } + + // legacy API + // TODO: remove in 2.0 + this.widget_object.data = this.widget_data.data; + + if(this.widget_object.data_updated !== undefined) { + this.widget_object.data_updated(); + + return; + } + } +} diff --git a/lona/client2/_lona/client2/window-shim.js b/lona/client2/_lona/client2/window-shim.js index c38ff3ea..db5dde7e 100644 --- a/lona/client2/_lona/client2/window-shim.js +++ b/lona/client2/_lona/client2/window-shim.js @@ -28,6 +28,8 @@ export class LonaWindowShim { this._lona_window = lona_window; this._widget_id = widget_id; + + this.root_node = this._lona_window._root; }; fire_input_event(node, event_type, data, target_node) { diff --git a/lona/client2/_lona/client2/window.js b/lona/client2/_lona/client2/window.js index 9d68ca78..27df9137 100644 --- a/lona/client2/_lona/client2/window.js +++ b/lona/client2/_lona/client2/window.js @@ -48,46 +48,12 @@ export class LonaWindow { this._crashed = false; this._view_running = false; this._view_runtime_id = undefined; - this._url = ''; + this._url = new URL(window.location); }; // urls ------------------------------------------------------------------- _set_url(raw_url) { - // parse pathname, search and hash - var _raw_url = new URL(raw_url, window.location.origin); - - _raw_url = _raw_url.pathname + _raw_url.search + _raw_url.hash; - - if(raw_url.startsWith('..')) { - _raw_url = '..' + _raw_url; - - } else if(raw_url.startsWith('.')) { - _raw_url = '.' + _raw_url; - - }; - - if(!raw_url.startsWith('http://') && !raw_url.startsWith('https://')) { - if(_raw_url.startsWith('/') && !raw_url.startsWith('/')) { - _raw_url = _raw_url.substr(1); - } - }; - - raw_url = _raw_url; - - // handle relative URLs - if(!raw_url.startsWith('/')) { - var current_url = this._url; - - if(!current_url.endsWith('/')) { - current_url = current_url + '/'; - }; - - raw_url = current_url + raw_url; - }; - - var url = new URL(raw_url, window.location.origin); - - this._url = url.pathname + url.search + url.hash; + this._url = new URL(raw_url, this._url); }; get_url() { @@ -139,7 +105,7 @@ export class LonaWindow { this._view_running = true; if(this.lona_context.settings.update_address_bar) { - history.pushState({}, '', this.get_url()); + history.pushState({}, '', this.get_url().href); }; this._rendering_engine._clear(); @@ -163,7 +129,9 @@ export class LonaWindow { // http redirect } else if(method == Lona.protocol.METHOD.HTTP_REDIRECT) { if(this.lona_context.settings.follow_http_redirects) { - window.location = payload; + this._set_url(payload); + + window.location = this.get_url().href; } else { console.debug( @@ -254,7 +222,7 @@ export class LonaWindow { this._window_id, this._view_runtime_id, Lona.protocol.METHOD.VIEW, - [this.get_url(), post_data], + [this.get_url().href, post_data], ]; // update html title diff --git a/lona/html/node_list.py b/lona/html/node_list.py index 26b5753f..ba45df89 100644 --- a/lona/html/node_list.py +++ b/lona/html/node_list.py @@ -213,14 +213,14 @@ def _reset(self, values): self._nodes.append(node) - self._node.document.add_patch( - node_id=self._node.id, - patch_type=PATCH_TYPE.NODES, - operation=OPERATION.RESET, - payload=[ - [i._serialize() for i in self._nodes], - ], - ) + self._node.document.add_patch( + node_id=self._node.id, + patch_type=PATCH_TYPE.NODES, + operation=OPERATION.RESET, + payload=[ + [i._serialize() for i in self._nodes], + ], + ) def _serialize(self, include_node_ids=True): return [i._serialize(include_node_ids=include_node_ids) diff --git a/lona/html/parsing.py b/lona/html/parsing.py index 471af9d9..66b59255 100644 --- a/lona/html/parsing.py +++ b/lona/html/parsing.py @@ -246,15 +246,17 @@ def HTML( # html string elif '<' in node or '>' in node: + parsed_nodes = html_string_to_node_list( + html_string=node, + use_high_level_nodes=use_high_level_nodes, + node_classes=node_classes or {}, + ) + if len(nodes) > 1: - _nodes.append(HTML(node)) + _nodes.extend(parsed_nodes) else: - _nodes = html_string_to_node_list( - html_string=node, - use_high_level_nodes=use_high_level_nodes, - node_classes=node_classes or {}, - ) + _nodes = parsed_nodes else: _nodes.append(TextNode(node)) diff --git a/test_project/Makefile b/test_project/Makefile index 4af1a602..504229ad 100644 --- a/test_project/Makefile +++ b/test_project/Makefile @@ -3,7 +3,7 @@ PYTHON_VENV=env LONA_SHELL_SERVER_URL=file://socket LONA_DEFAULT_ARGS=--shell-server-url=$(LONA_SHELL_SERVER_URL) -CLIENT_VERSION=1 +CLIENT_VERSION=2 all: server diff --git a/test_project/routes.py b/test_project/routes.py index 06d264c2..c61bd1f3 100644 --- a/test_project/routes.py +++ b/test_project/routes.py @@ -241,6 +241,9 @@ Route('/frontend/custom-messages/', 'views/frontend/custom_messages.py::CustomMessagesView'), + Route('/frontend/redirects', + 'views/frontend/redirects.py::RedirectsView'), + # home Route('/', 'views/home.py::HomeView'), ] diff --git a/test_project/views/frontend/redirects.py b/test_project/views/frontend/redirects.py new file mode 100644 index 00000000..bfcb1ed4 --- /dev/null +++ b/test_project/views/frontend/redirects.py @@ -0,0 +1,59 @@ +from lona.html import TextInput, Button, Label, HTML, Div, H2, A +from lona import HttpRedirectResponse, RedirectResponse, View + + +class RedirectsView(View): + def handle_url_change(self, input_event): + self.link.set_href(self.text_input.value) + + def redirect(self, input_event): + return RedirectResponse( + self.text_input.value, + ) + + def http_redirect(self, input_event): + return HttpRedirectResponse( + self.text_input.value, + ) + + def handle_request(self, request): + self.link = A( + 'Link', + href='#', + id='link', + ignore=True, + ) + + self.text_input = TextInput( + placeholder='URL', + id='url', + handle_change=self.handle_url_change, + ) + + self.html = HTML( + H2('Redirects'), + Div( + Label( + 'URL: ', + self.text_input, + ), + Button( + 'Redirect', + id='redirect', + handle_click=self.redirect, + ), + Button( + 'HTTP Redirect', + id='http-redirect', + handle_click=self.http_redirect, + ), + ), + Div( + Label( + 'Link: ', + self.link, + ), + ), + ) + + return self.html diff --git a/test_project/views/frontend/rendering-test-widgets.js b/test_project/views/frontend/rendering-test-widgets.js index 975f30fc..96a81075 100644 --- a/test_project/views/frontend/rendering-test-widgets.js +++ b/test_project/views/frontend/rendering-test-widgets.js @@ -1,23 +1,40 @@ -class WidgetDataTestWidget { - constructor(lona_window) { - this.lona_window = lona_window; - } +class LegacyWidgetApiTestWidget { + // TODO: remove in 2.0 + // helper ----------------------------------------------------------------- render() { this.root_node.children[1].innerHTML = JSON.stringify(this.data); } + log_hook(name) { + const element = this.lona_window.root_node.querySelector( + '#widget-hooks', + ); + + if(!element) { + return; + } + + if(element.innerHTML != '') { + element.innerHTML = `${element.innerHTML},`; + } + + element.innerHTML = `${element.innerHTML}${name}`; + } + + // hooks ------------------------------------------------------------------ + constructor(lona_window) { + this.lona_window = lona_window; + this.log_hook('constructor'); + } + setup() { this.render(); - console.log('>> setup', this.nodes); + this.log_hook('setup'); } deconstruct() { - console.log('>> deconstruct', this.nodes); - } - - nodes_updated() { - console.log('>> nodes updated', this.nodes); + this.log_hook('deconstruct'); } data_updated() { @@ -26,6 +43,49 @@ class WidgetDataTestWidget { } +class WidgetApiTestWidget { + + // helper ----------------------------------------------------------------- + render(data) { + this.rootNode.children[1].innerHTML = JSON.stringify(data); + } + + log_hook(name) { + const element = this.lonaWindow.root_node.querySelector( + '#widget-hooks', + ); + + if(!element) { + return; + } + + if(element.innerHTML != '') { + element.innerHTML = `${element.innerHTML},`; + } + + element.innerHTML = `${element.innerHTML}${name}`; + } + + // hooks ------------------------------------------------------------------ + constructor(lonaWindow, rootNode, widgetData) { + this.lonaWindow = lonaWindow; + this.rootNode = rootNode; + this.widgetData = widgetData; + + this.render(this.widgetData.data); + this.log_hook('constructor'); + } + + destroy() { + this.log_hook('destroy'); + } + + onDataUpdated(widgetData) { + this.render(widgetData.data); + } +} + + class HTMLConsoleWidget { constructor(lona_window) { this.lona_window = lona_window; @@ -88,5 +148,10 @@ class HTMLConsoleWidget { } -Lona.register_widget_class('WidgetDataTestWidget', WidgetDataTestWidget); +Lona.register_widget_class( + 'LegacyWidgetApiTestWidget', + LegacyWidgetApiTestWidget, +); + +Lona.register_widget_class('WidgetApiTestWidget', WidgetApiTestWidget); Lona.register_widget_class('HTMLConsoleWidget', HTMLConsoleWidget); diff --git a/test_project/views/frontend/rendering.py b/test_project/views/frontend/rendering.py index 25c6a458..98ea4139 100644 --- a/test_project/views/frontend/rendering.py +++ b/test_project/views/frontend/rendering.py @@ -28,8 +28,8 @@ def decorator(step): return decorator -class WidgetDataTestComponent(Div): - WIDGET = 'WidgetDataTestWidget' +class LegacyWidgetApiTestComponent(Div): + WIDGET = 'LegacyWidgetApiTestWidget' def __init__(self, initial_state): super().__init__() @@ -50,6 +50,10 @@ def update_state(self): self.server_state.set_text(dumps(self.widget_data)) +class WidgetApiTestComponent(LegacyWidgetApiTestComponent): + WIDGET = 'WidgetApiTestWidget' + + class HTMLConsole(Div): WIDGET = 'HTMLConsoleWidget' @@ -452,55 +456,74 @@ def step_21(self): self.rendering_root.nodes[0].attributes.clear() - # style tests @client_version(1, 2) def step_22(self): - self.set_step_label(22, 'Set style') + self.set_step_label(22, 'HTML Symbols') self.rendering_root.nodes = [ - Div(_style='top: 1px; right: 2px;'), + '€', + '€', + '€', ] + # style tests @client_version(1, 2) def step_23(self): - self.set_step_label(23, 'Add style') + self.set_step_label(23, 'Empty style') - self.rendering_root.nodes[0].style['bottom'] = '3px' + self.rendering_root.nodes = [ + Div(), + ] @client_version(1, 2) def step_24(self): - self.set_step_label(24, 'Remove style') + self.set_step_label(24, 'Set style') - del self.rendering_root.nodes[0].style['top'] + self.rendering_root.nodes = [ + Div(_style='top: 1px; right: 2px;'), + ] @client_version(1, 2) def step_25(self): - self.set_step_label(25, 'Reset style') + self.set_step_label(25, 'Add style') + + self.rendering_root.nodes[0].style['bottom'] = '3px' + + @client_version(1, 2) + def step_26(self): + self.set_step_label(26, 'Remove style') + + del self.rendering_root.nodes[0].style['top'] + + @client_version(1, 2) + def step_27(self): + self.set_step_label(27, 'Reset style') self.rendering_root.nodes[0].style = { 'left': '4px', } @client_version(1, 2) - def step_26(self): - self.set_step_label(26, 'Clear style') + def step_28(self): + self.set_step_label(28, 'Clear style') self.rendering_root.nodes[0].style.clear() - # widget data tests + # legacy widget api tests @client_version(1, 2) - def step_27(self): - self.set_step_label(27, 'Widget Data: list: setup') + def step_29(self): + self.set_step_label(29, 'Legacy Widget API: setup') self.rendering_root.nodes = [ - WidgetDataTestComponent( + LegacyWidgetApiTestComponent( initial_state={'list': []}, ), + Div(id='widget-hooks'), ] @client_version(1, 2) - def step_28(self): - self.set_step_label(28, 'Widget Data: list: append') + def step_30(self): + self.set_step_label(30, 'Legacy Widget API: data: list: append') component = self.rendering_root.nodes[0] @@ -510,8 +533,8 @@ def step_28(self): component.update_state() @client_version(1, 2) - def step_29(self): - self.set_step_label(29, 'Widget Data: list: remove') + def step_31(self): + self.set_step_label(31, 'Legacy Widget API: data: list: remove') component = self.rendering_root.nodes[0] @@ -519,8 +542,8 @@ def step_29(self): component.update_state() @client_version(1, 2) - def step_30(self): - self.set_step_label(30, 'Widget Data: list: insert') + def step_32(self): + self.set_step_label(32, 'Legacy Widget API: data: list: insert') component = self.rendering_root.nodes[0] @@ -528,8 +551,8 @@ def step_30(self): component.update_state() @client_version(1, 2) - def step_31(self): - self.set_step_label(31, 'Widget Data: list: clear') + def step_33(self): + self.set_step_label(33, 'Legacy Widget API: data: list: clear') component = self.rendering_root.nodes[0] @@ -537,8 +560,8 @@ def step_31(self): component.update_state() @client_version(1, 2) - def step_32(self): - self.set_step_label(32, 'Widget Data: list: reset') + def step_34(self): + self.set_step_label(34, 'Legacy Widget API: data: list: reset') component = self.rendering_root.nodes[0] @@ -546,8 +569,8 @@ def step_32(self): component.update_state() @client_version(1, 2) - def step_33(self): - self.set_step_label(33, 'Widget Data: dict: setup') + def step_35(self): + self.set_step_label(35, 'Legacy Widget API: data: dict: setup') component = self.rendering_root.nodes[0] @@ -555,8 +578,8 @@ def step_33(self): component.update_state() @client_version(1, 2) - def step_34(self): - self.set_step_label(34, 'Widget Data: dict: set') + def step_36(self): + self.set_step_label(36, 'Legacy Widget API: data: dict: set') component = self.rendering_root.nodes[0] @@ -566,8 +589,8 @@ def step_34(self): component.update_state() @client_version(1, 2) - def step_35(self): - self.set_step_label(35, 'Widget Data: dict: del') + def step_37(self): + self.set_step_label(37, 'Legacy Widget API: data: dict: del') component = self.rendering_root.nodes[0] @@ -575,8 +598,8 @@ def step_35(self): component.update_state() @client_version(1, 2) - def step_36(self): - self.set_step_label(36, 'Widget Data: dict: pop') + def step_38(self): + self.set_step_label(38, 'Legacy Widget API: data: dict: pop') component = self.rendering_root.nodes[0] @@ -584,8 +607,8 @@ def step_36(self): component.update_state() @client_version(1, 2) - def step_37(self): - self.set_step_label(37, 'Widget Data: dict: clear') + def step_39(self): + self.set_step_label(39, 'Legacy Widget API: data: dict: clear') component = self.rendering_root.nodes[0] @@ -593,20 +616,147 @@ def step_37(self): component.update_state() @client_version(1, 2) - def step_38(self): - self.set_step_label(38, 'Widget Data: dict: reset') + def step_40(self): + self.set_step_label(40, 'Legacy Widget API: data: dict: reset') + + component = self.rendering_root.nodes[0] + + component.widget_data['dict'] = {0: 0, 1: 1, 2: 2, 3: 3, 4: 4} + component.update_state() + + @client_version(1, 2) + def step_41(self): + self.set_step_label(41, 'Legacy Widget API: destroy') + + self.rendering_root.nodes.pop(0) + + # widget api tests + @client_version(1, 2) + def step_42(self): + self.set_step_label(42, 'Widget API: setup') + + self.rendering_root.nodes = [ + WidgetApiTestComponent( + initial_state={'list': []}, + ), + Div(id='widget-hooks'), + ] + + @client_version(1, 2) + def step_43(self): + self.set_step_label(43, 'Widget API: data: list: append') + + component = self.rendering_root.nodes[0] + + component.widget_data['list'].append(1) + component.widget_data['list'].append(2) + component.widget_data['list'].append(3) + component.update_state() + + @client_version(1, 2) + def step_44(self): + self.set_step_label(44, 'Widget API: data: list: remove') + + component = self.rendering_root.nodes[0] + + component.widget_data['list'].remove(2) + component.update_state() + + @client_version(1, 2) + def step_45(self): + self.set_step_label(45, 'Widget API: data: list: insert') + + component = self.rendering_root.nodes[0] + + component.widget_data['list'].insert(0, 0) + component.update_state() + + @client_version(1, 2) + def step_46(self): + self.set_step_label(46, 'Widget API: data: list: clear') + + component = self.rendering_root.nodes[0] + + component.widget_data['list'].clear() + component.update_state() + + @client_version(1, 2) + def step_47(self): + self.set_step_label(47, 'Widget API: data: list: reset') + + component = self.rendering_root.nodes[0] + + component.widget_data['list'] = [5, 4, 3, 2, 1] + component.update_state() + + @client_version(1, 2) + def step_48(self): + self.set_step_label(48, 'Widget API: data: dict: setup') + + component = self.rendering_root.nodes[0] + + component.widget_data = {'dict': {}} + component.update_state() + + @client_version(1, 2) + def step_49(self): + self.set_step_label(49, 'Widget API: data: dict: set') + + component = self.rendering_root.nodes[0] + + component.widget_data['dict'][1] = 1 + component.widget_data['dict'][2] = 2 + component.widget_data['dict'][3] = 3 + component.update_state() + + @client_version(1, 2) + def step_50(self): + self.set_step_label(50, 'Widget API: data: dict: del') + + component = self.rendering_root.nodes[0] + + del component.widget_data['dict'][2] + component.update_state() + + @client_version(1, 2) + def step_51(self): + self.set_step_label(51, 'Widget API: data: dict: pop') + + component = self.rendering_root.nodes[0] + + component.widget_data['dict'].pop(3) + component.update_state() + + @client_version(1, 2) + def step_52(self): + self.set_step_label(52, 'Widget API: data: dict: clear') + + component = self.rendering_root.nodes[0] + + component.widget_data['dict'].clear() + component.update_state() + + @client_version(1, 2) + def step_53(self): + self.set_step_label(53, 'Widget API: data: dict: reset') component = self.rendering_root.nodes[0] component.widget_data['dict'] = {0: 0, 1: 1, 2: 2, 3: 3, 4: 4} component.update_state() + @client_version(1, 2) + def step_54(self): + self.set_step_label(54, 'Widget API: destroy') + + self.rendering_root.nodes.pop(0) + # legacy widgets ########################################################## # TODO: remove in 2.0 @client_version(1) - def step_39(self): - self.set_step_label(39, 'Legacy Widgets: Setup') + def step_55(self): + self.set_step_label(55, 'Legacy Widgets: Setup') self.rendering_root.clear() @@ -623,8 +773,8 @@ def step_39(self): ] @client_version(1) - def step_40(self): - self.set_step_label(40, 'Legacy Widgets: Append Nodes') + def step_56(self): + self.set_step_label(56, 'Legacy Widgets: Append Nodes') widget1 = self.rendering_root.nodes[0] widget1.append(Div('1.3')) @@ -635,8 +785,8 @@ def step_40(self): self.rendering_root.append(Div('4.1')) @client_version(1) - def step_41(self): - self.set_step_label(41, 'Legacy Widgets: Set Nodes') + def step_57(self): + self.set_step_label(57, 'Legacy Widgets: Set Nodes') widget1 = self.rendering_root.nodes[0] widget1.nodes[1] = Div('1.2.1') @@ -645,8 +795,8 @@ def step_41(self): widget1.nodes[1] = Div('3.2.1') @client_version(1) - def step_42(self): - self.set_step_label(42, 'Legacy Widgets: Reset Nodes') + def step_58(self): + self.set_step_label(58, 'Legacy Widgets: Reset Nodes') widget1 = self.rendering_root.nodes[0] @@ -669,8 +819,8 @@ def step_42(self): self.rendering_root[3] = Div('4.1.1') @client_version(1) - def step_43(self): - self.set_step_label(43, 'Legacy Widgets: Insert Nodes') + def step_59(self): + self.set_step_label(59, 'Legacy Widgets: Insert Nodes') widget1 = self.rendering_root[0] widget1.nodes.insert(2, Div('1.2.1.1')) @@ -681,8 +831,8 @@ def step_43(self): widget2.nodes.insert(2, Div('3.2.1.1')) @client_version(1) - def step_44(self): - self.set_step_label(44, 'Legacy Widgets: Remove Nodes') + def step_60(self): + self.set_step_label(60, 'Legacy Widgets: Remove Nodes') widget1 = self.rendering_root[0] widget1.nodes.pop(2) diff --git a/test_project/views/home.py b/test_project/views/home.py index 6b9c22da..126342eb 100644 --- a/test_project/views/home.py +++ b/test_project/views/home.py @@ -91,5 +91,6 @@ def handle_request(self, request):
  • Rendering
  • Custom Event
  • Custom Messages
  • +
  • Redirects
  • """ # NOQA: LN002 diff --git a/tests/test_0001_html.py b/tests/test_0001_html.py index e1c4538a..41c0ce20 100644 --- a/tests/test_0001_html.py +++ b/tests/test_0001_html.py @@ -13,9 +13,10 @@ Select, Option, Button, + HTML2, + HTML1, Span, Node, - HTML, Div, H1, ) @@ -389,9 +390,9 @@ def test_list(self): @pytest.mark.incremental() -class TestHTMLFromStr: +class TestLegacyHtmlParsing: def test_empty_node(self): - node = HTML('
    ')[0] + node = HTML1('
    ')[0] assert node.tag_name == 'div' assert node.id_list == [] @@ -401,7 +402,7 @@ def test_empty_node(self): assert node.nodes == [] def test_node_with_attributes(self): - node = HTML(""" + node = HTML1("""
    """)[0] @@ -414,7 +415,7 @@ def test_node_with_attributes(self): assert node.nodes == [] def test_sub_nodes(self): - node = HTML(""" + node = HTML1("""
    @@ -429,17 +430,17 @@ def test_sub_nodes(self): assert node.nodes[2].tag_name == 'h1' def test_multiple_ids(self): - node = HTML('
    ')[0] + node = HTML1('
    ')[0] assert node.id_list == ['foo', 'bar', 'baz'] def test_multiple_classes(self): - node = HTML('
    ')[0] + node = HTML1('
    ')[0] assert node.class_list == ['foo', 'bar', 'baz'] def test_multiple_styles(self): - node = HTML('
    ')[0] + node = HTML1('
    ')[0] assert node.style == { 'color': 'black', @@ -447,7 +448,7 @@ def test_multiple_styles(self): } def test_multiple_attributes(self): - node = HTML('
    ')[0] + node = HTML1('
    ')[0] assert node.attributes == { 'foo': 'bar', @@ -455,17 +456,17 @@ def test_multiple_attributes(self): } def test_high_level_nodes(self): - node = HTML('')[0] + node = HTML1('')[0] assert type(node) is Button def test_boolean_property_without_value(self): - node = HTML('')[0] + node = HTML1('')[0] assert node.disabled def test_boolean_property_with_value(self): - node = HTML('')[0] + node = HTML1('')[0] assert node.disabled @@ -474,69 +475,69 @@ def test_missing_end_tag(self): ValueError, match='Invalid html: missing end tag ', ): - HTML('') + HTML1('') def test_wrong_end_tag(self): with pytest.raises( ValueError, match='Invalid html:
    expected, received', ): - HTML('

    abc

    ') + HTML1('

    abc

    ') def test_end_tag_without_start_tag(self): with pytest.raises( ValueError, match='Invalid html: missing start tag for ', ): - HTML('
    abc
    ') + HTML1('
    abc
    ') def test_missing_start_tag(self): with pytest.raises( ValueError, match='Invalid html: missing start tag for ', ): - HTML('') + HTML1('') def test_self_closing_tag_with_slash(self): - img = HTML('
    ')[0].nodes[0] + img = HTML1('
    ')[0].nodes[0] assert img.tag_name == 'img' assert img.self_closing_tag is True def test_self_closing_tag_without_slash(self): - img = HTML('
    ')[0].nodes[0] + img = HTML1('
    ')[0].nodes[0] assert img.tag_name == 'img' assert img.self_closing_tag is True def test_non_self_closing_tag(self): - div = HTML('
    ')[0] + div = HTML1('
    ')[0] assert div.tag_name == 'div' assert div.self_closing_tag is False def test_non_self_closing_tag_with_slash(self): - span = HTML('
    ')[0].nodes[0] + span = HTML1('
    ')[0].nodes[0] assert span.tag_name == 'span' assert span.self_closing_tag is True def test_default_input_type_is_text(self): - node = HTML('')[0] + node = HTML1('')[0] assert type(node) is TextInput assert node.value == 'abc' assert node.disabled is False def test_input_type_text(self): - node = HTML('')[0] + node = HTML1('')[0] assert type(node) is TextInput assert node.value == 'xyz' assert node.disabled is True def test_input_type_unknown(self): - node = HTML('')[0] + node = HTML1('')[0] assert type(node) is Node @@ -564,51 +565,51 @@ def test_input_type_unknown(self): ], ) def test_not_implemented_input_types(self, tp): - node = HTML(f'')[0] + node = HTML1(f'')[0] assert type(node) is Node def test_input_type_number(self): - node = HTML('')[0] + node = HTML1('')[0] assert type(node) is NumberInput assert node.value == 123.5 def test_input_type_checkbox(self): - node = HTML('')[0] + node = HTML1('')[0] assert type(node) is CheckBox assert node.value is False def test_input_type_checkbox_checked(self): - node = HTML('')[0] + node = HTML1('')[0] assert type(node) is CheckBox assert node.value is True def test_input_type_submit(self): - node = HTML('')[0] + node = HTML1('')[0] assert type(node) is Submit def test_textarea(self): - node = HTML('')[0] + node = HTML1('')[0] assert type(node) is TextArea assert node.value == 'abc' def test_textarea_with_self_closing_tag_inside(self): - textarea = HTML('')[0] + textarea = HTML1('')[0] assert textarea.value == 'abc
    xyz' def test_textarea_with_pair_tag_inside(self): - textarea = HTML('')[0] + textarea = HTML1('')[0] assert textarea.value == 'aaa bbb ccc' def test_select(self): - node = HTML(""" + node = HTML1(""" @@ -638,6 +639,51 @@ def test_select2(self): set_use_future_node_classes(False) +@pytest.mark.incremental() +class TestHtmlParsing: + def test_sub_nodes(self): + node = HTML2(""" +
    + +
    +

    +
    + """) + + assert node.tag_name == 'div' + assert len(node.nodes) == 3 + assert node.nodes[0].tag_name == 'span' + assert node.nodes[1].tag_name == 'div' + assert node.nodes[2].tag_name == 'h1' + + def test_wrapping(self): + node = HTML2(""" + +
    +

    + """) + + assert node.tag_name == 'div' + assert len(node.nodes) == 3 + assert node.nodes[0].tag_name == 'span' + assert node.nodes[1].tag_name == 'div' + assert node.nodes[2].tag_name == 'h1' + + def test_multiple_strings(self): + node = HTML2( + '', + '
    ', + '

    ', + ) + + assert node.tag_name == 'div' + assert len(node.nodes) == 4 + assert node.nodes[0].tag_name == 'span' + assert node.nodes[1].tag_name == 'span' + assert node.nodes[2].tag_name == 'div' + assert node.nodes[3].tag_name == 'h1' + + @pytest.mark.incremental() class TestNumberInput: def test_default_properties(self): @@ -660,7 +706,7 @@ def test_change_value(self): assert node.value == 12.3 def test_parsing_no_attributes(self): - node = HTML('')[0] + node = HTML1('')[0] assert node.value is None assert node.min is None @@ -668,41 +714,41 @@ def test_parsing_no_attributes(self): assert node.step is None def test_parsing_int_value(self): - node = HTML('')[0] + node = HTML1('')[0] assert node.value == 123 def test_parsing_float_value(self): - node = HTML('')[0] + node = HTML1('')[0] assert node.value == 12.3 def test_parsing_broken_step(self): - node = HTML('')[0] + node = HTML1('')[0] assert node.value == 123 assert node.step is None def test_parsing_int_step(self): - node = HTML('')[0] + node = HTML1('')[0] assert node.value == 12.3 assert node.step == 3 def test_parsing_float_step(self): - node = HTML('')[0] + node = HTML1('')[0] assert node.value == 12.3 assert node.step == 0.1 def test_parsing_broken_value(self): - node = HTML('')[0] + node = HTML1('')[0] assert node.raw_value == 'abc' assert node.value is None def test_parsing_all_attributes(self): - node = HTML( + node = HTML1( '', )[0] @@ -717,19 +763,19 @@ def test_attribute_escaping(self): assert node.style['font-family'] == '"Times New Roman"' assert node.style.to_sub_attribute_string() == 'font-family: "Times New Roman"' # NOQA: E501 - node = HTML(str(node))[0] + node = HTML1(str(node))[0] assert node.style['font-family'] == '"Times New Roman"' # selectors ############################################################### def test_unsupported_selector(self): with pytest.raises(ValueError, match='unsupported selector feature:*'): - HTML().query_selector('div > div') + HTML1().query_selector('div > div') def test_query_selector(self): # test selectors by tag name html = Div( - HTML(), + HTML1(), 'foo', Div( H1(), @@ -743,7 +789,7 @@ def test_query_selector(self): # test selectors by id html = Div( - HTML(), + HTML1(), 'foo', Div( Div(_id='foo'), @@ -757,7 +803,7 @@ def test_query_selector(self): # test selectors by class html = Div( - HTML(), + HTML1(), 'foo', Div( Div(_class='foo'), @@ -771,7 +817,7 @@ def test_query_selector(self): # test selectors by attribute html = Div( - HTML(), + HTML1(), 'foo', Div( Div(_type='foo'), diff --git a/tests/test_redirects.py b/tests/test_redirects.py index edfc8387..1c365b1a 100644 --- a/tests/test_redirects.py +++ b/tests/test_redirects.py @@ -1,106 +1,77 @@ -def setup_app(app): - from lona import View - - # root - @app.route('/') - class RootView(View): - def handle_request(self, request): - return 'ROOT' - - @app.route('/redirect-to-root/') - class RedirectToRoot(View): - def handle_request(self, request): - return { - 'redirect': '/', - } - - # absolute url - @app.route('/absolute-url/') - class AbsoluteUrlView(View): - def handle_request(self, request): - return 'ABSOLUTE-URL' - - @app.route('/redirect-to-absolute-url/') - class RedirectToAbsoluteUrlView(View): - def handle_request(self, request): - return { - 'redirect': '/absolute-url/', - } - - # relative urls - @app.route('/redirect-to-relative-url/foo/') - class RelativeUrlView(View): - def handle_request(self, request): - return 'relative-URL' - - @app.route('/redirect-to-relative-url/') - class RelativeRedirectUrlView(View): - def handle_request(self, request): - return { - 'redirect': 'foo/', - } - - @app.route('/redirect-to-root-relatively/') - class RedirectToRootRelativeView(View): - def handle_request(self, request): - return { - 'redirect': '..', - } - - # refresh - refreshed = False - - @app.route('/refresh/') - class RefreshView(View): - def handle_request(self, request): - nonlocal refreshed - - if not refreshed: - refreshed = True - - return { - 'redirect': '.', - } - - else: - return 'REFRESH' - - -async def test_redirects(lona_app_context): - """ - This test tests redirects by creating multiple views and redirecting from - one to another. Absolute and relative URLs are tested. - """ +import pytest + + +@pytest.mark.parametrize('client_version', [1, 2]) +@pytest.mark.parametrize('browser_name', ['chromium', 'firefox', 'webkit']) +@pytest.mark.parametrize('method', ['link', 'redirect', 'http-redirect']) +async def test_redirects( + method, + browser_name, + client_version, + lona_project_context, +): + + import os from playwright.async_api import async_playwright - context = await lona_app_context(setup_app) + TEST_PROJECT_PATH = os.path.join(__file__, '../../test_project') + BASE_URL = '/frontend/redirects' + + context = await lona_project_context( + project_root=TEST_PROJECT_PATH, + settings=['settings.py'], + settings_post_overrides={ + 'CLIENT_VERSION': client_version, + }, + ) + + datasets = [ + + # absolute url + (f'{BASE_URL}/foo/bar/baz/', '/foo', '/foo'), + + # relative urls + (f'{BASE_URL}/foo/bar/baz', '.', f'{BASE_URL}/foo/bar/'), + (f'{BASE_URL}/foo/bar/baz/', '.', f'{BASE_URL}/foo/bar/baz/'), + + # relative forward urls + (f'{BASE_URL}/foo/bar/baz', './foo', f'{BASE_URL}/foo/bar/foo'), + (f'{BASE_URL}/foo/bar/baz/', './foo', f'{BASE_URL}/foo/bar/baz/foo'), + + # relative backward urls + (f'{BASE_URL}/foo/bar/baz', '..', f'{BASE_URL}/foo/'), + (f'{BASE_URL}/foo/bar/baz/', '..', f'{BASE_URL}/foo/bar/'), + ] async with async_playwright() as p: - browser = await p.chromium.launch() + browser = await getattr(p, browser_name).launch() browser_context = await browser.new_context() - page = await browser_context.new_page() - - # test redirect to root - await page.goto(context.make_url('/redirect-to-root/')) - await page.wait_for_url('/') - await page.wait_for_selector('#lona:has-text("ROOT")') - - # test redirect to absolute url - await page.goto(context.make_url('/redirect-to-absolute-url/')) - await page.wait_for_url('/absolute-url/') - await page.wait_for_selector('#lona:has-text("ABSOLUTE-URL")') - - # relative url - await page.goto(context.make_url('/redirect-to-relative-url/')) - await page.wait_for_url('/redirect-to-relative-url/foo/') - await page.wait_for_selector('#lona:has-text("RELATIVE-URL")') - - await page.goto(context.make_url('/redirect-to-root-relatively/')) - await page.wait_for_url('/') - await page.wait_for_selector('#lona:has-text("ROOT")') - - # test refresh - await page.goto(context.make_url('/refresh/')) - await page.wait_for_url('/refresh/') - await page.wait_for_selector('#lona:has-text("REFRESH")') + + for raw_initial_url, redirect_url, success_url in datasets: + page = await browser_context.new_page() + + # initial load + initial_url = context.make_url(raw_initial_url) + + await page.goto(initial_url) + await page.wait_for_url(initial_url) + + # trigger redirect + await page.fill('input#url', redirect_url) + await page.wait_for_selector(f'a#link[href="{redirect_url}"]') + + if method == 'link': + await page.click('a#link') + + elif method == 'redirect': + await page.click('button#redirect') + + elif method == 'http-redirect': + await page.click('button#http-redirect') + + # wait for success url + await page.wait_for_url(success_url) + + # close page + await page.close() diff --git a/tests/test_rendering.py b/tests/test_rendering.py index 4f479165..9a48e1cf 100644 --- a/tests/test_rendering.py +++ b/tests/test_rendering.py @@ -97,6 +97,12 @@ async def parse_json(page, locator): return json.loads(json_string) + async def get_widget_hooks(page): + element = page.locator('#lona #rendering-root #widget-hooks') + widget_hooks = await element.inner_html() + + return widget_hooks.strip() + async with async_playwright() as p: browser = await getattr(p, browser_name).launch() browser_context = await browser.new_context() @@ -123,11 +129,21 @@ async def parse_json(page, locator): assert html.nodes == context.server.state['rendering-root'].nodes + # html symbols + await next_step(page, 22) + + html_string = await rendering_root_element.inner_html() + + assert html_string == '€€€' + # CSS tests ########################################################### + + # 23 Empty style + await next_step(page, 23) await check_default_styles(page) - # 22 Set Style - await next_step(page, 22) + # 24 Set Style + await next_step(page, 24) computed_style = await get_computed_style(page) @@ -136,8 +152,8 @@ async def parse_json(page, locator): assert computed_style['bottom'] == 'auto' assert computed_style['left'] == 'auto' - # 23 Add Style - await next_step(page, 23) + # 25 Add Style + await next_step(page, 25) computed_style = await get_computed_style(page) style = get_style() @@ -147,8 +163,8 @@ async def parse_json(page, locator): assert computed_style['bottom'] == '3px' assert computed_style['left'] == 'auto' - # 24 Remove Style - await next_step(page, 24) + # 26 Remove Style + await next_step(page, 26) computed_style = await get_computed_style(page) style = get_style() @@ -160,8 +176,8 @@ async def parse_json(page, locator): assert computed_style['bottom'] == '3px' assert computed_style['left'] == 'auto' - # 25 Reset Style - await next_step(page, 25) + # 27 Reset Style + await next_step(page, 27) computed_style = await get_computed_style(page) style = get_style() @@ -174,33 +190,68 @@ async def parse_json(page, locator): assert computed_style['bottom'] == 'auto' assert computed_style['left'] == '4px' - # 26 Clear Style - await next_step(page, 26) + # 28 Clear Style + await next_step(page, 28) await check_default_styles(page) - # widget data tests ################################################### - for step in range(27, 39): + # legacy widget API ################################################### + # TODO: remove in 2.0 + for step in range(29, 41): await next_step(page, step) + # widget hooks + assert (await get_widget_hooks(page)) == 'constructor,setup' + + # widget data server_widget_data = await parse_json( page, - '#lona #server-widget-data', + '#lona #rendering-root #server-widget-data', ) client_widget_data = await parse_json( page, - '#lona #client-widget-data', + '#lona #rendering-root #server-widget-data', ) assert server_widget_data == client_widget_data - # legacy widgets tests ################################################ + # destroy + await next_step(page, 41) + + assert (await get_widget_hooks(page)) == 'constructor,setup,deconstruct' + + # widget API ########################################################## + for step in range(42, 54): + await next_step(page, step) + + # widget hooks + assert (await get_widget_hooks(page)) == 'constructor' + + # widget data + server_widget_data = await parse_json( + page, + '#lona #rendering-root #server-widget-data', + ) + + client_widget_data = await parse_json( + page, + '#lona #rendering-root #server-widget-data', + ) + + assert server_widget_data == client_widget_data + + # destroy + await next_step(page, 54) + + assert (await get_widget_hooks(page)) == 'constructor,destroy' + + # legacy frontend widgets tests ####################################### # TODO: remove in 2.0 if get_client_version() != 1: return - for step in range(39, 45): + for step in range(55, 61): await next_step(page, step) client_html_string = await rendering_root_element.inner_html()