Source RapidContext_UI.js

1/*
2 * RapidContext <https://www.rapidcontext.com/>
3 * Copyright (c) 2007-2023 Per Cederberg. All rights reserved.
4 *
5 * This program is free software: you can redistribute it and/or
6 * modify it under the terms of the BSD license.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
11 *
12 * See the RapidContext LICENSE for more details.
13 */
14
15/**
16 * Provides functions for managing the app user interface.
17 * @namespace RapidContext.UI
18 */
19(function (window) {
20
21    // The global error dialog
22    var errorDialog = null;
23
24    /**
25     * Displays an error message for the user. This operation may or may
26     * not block the user interface, while the message is being
27     * displayed (depending on implementation). All arguments will be
28     * concatenated and displayed.
29     *
30     * @param {...(String|Error)} [arg] the messages or errors to display
31     *
32     * @memberof RapidContext.UI
33     */
34    function showError() {
35        var msg = Array.from(arguments).map(function (arg) {
36            var isError = arg instanceof Error && arg.message;
37            return isError ? arg.message : arg;
38        }).join(", ");
39        console.warn(msg, ...arguments);
40        if (!errorDialog) {
41            var xml = [
42                "<Dialog title='Error' system='true' style='width: 25rem;'>",
43                "  <i class='fa fa-exclamation-circle fa-3x color-danger mr-3'></i>",
44                "  <div class='inline-block vertical-top' style='width: calc(100% - 4em);'>",
45                "    <h4>Error message:</h4>",
46                "    <div class='text-pre-wrap' data-message='error'></div>",
47                "  </div>",
48                "  <div class='text-right mt-1'>",
49                "    <Button icon='fa fa-lg fa-times' data-dialog='close'>",
50                "      Close",
51                "    </Button>",
52                "  </div>",
53                "</Dialog>"
54            ].join("");
55            errorDialog = buildUI(xml);
56            window.document.body.append(errorDialog);
57        }
58        if (errorDialog.isHidden()) {
59            errorDialog.querySelector("[data-message]").innerText = msg;
60            errorDialog.show();
61        } else {
62            var txt = errorDialog.querySelector("[data-message]").innerText;
63            if (!txt.includes(msg)) {
64                txt += "\n\n" + msg;
65            }
66            errorDialog.querySelector("[data-message]").innerText = txt;
67        }
68    }
69
70    /**
71     * Creates a tree of widgets from a parsed XML document. This
72     * function will call `createWidget()` for any XML element node found,
73     * performing some basic adjustments on the element attributes
74     * before sending them as attributes to the widget constructor. Text
75     * nodes with non-whitespace content will be mapped to HTML DOM text
76     * nodes.
77     *
78     * @param {Object} node the XML document or node
79     * @param {Object} [ids] the optional node id mappings
80     *
81     * @return {Array|Object} an array or an object with the root
82     *         widget(s) created
83     *
84     * @memberof RapidContext.UI
85     */
86    function buildUI(node, ids) {
87        if (typeof(node) === "string") {
88            node = new DOMParser().parseFromString(node, "text/xml");
89            return buildUI(node.documentElement, ids);
90        } else if (node.documentElement) {
91            return buildUI(node.documentElement.childNodes, ids);
92        } else if (typeof(node.item) != "undefined" && typeof(node.length) == "number") {
93            return Array.from(node).map((el) => buildUI(el, ids)).filter(Boolean);
94        } else if (node.nodeType === 1) { // Node.ELEMENT_NODE
95            try {
96                return _buildUIElem(node, ids);
97            } catch (e) {
98                console.error("Failed to build UI element", node, e);
99            }
100        } else if (node.nodeType === 3) { // Node.TEXT_NODE
101            let str = (node.nodeValue || "").replace(/\s+/g, " ");
102            return str.trim() ? document.createTextNode(str) : null;
103        } else if (node.nodeType === 4) { // Node.CDATA_SECTION_NODE
104            let str = node.nodeValue || "";
105            return str ? document.createTextNode(str) : null;
106        }
107        return null;
108    }
109
110    /**
111     * Creates a widget from a parsed XML element. This function will
112     * call `createWidget()`, performing some basic adjustments on the
113     * element attributes before sending them as attributes to the widget
114     * constructor.
115     *
116     * @param {Object} node the XML document or node
117     * @param {Object} [ids] the optional node id mappings
118     *
119     * @return {Object} an object with the widget created
120     */
121    function _buildUIElem(node, ids) {
122        let name = node.nodeName;
123        if (name == "style") {
124            _buildUIStylesheet(node.innerText);
125            node.parentNode.removeChild(node);
126            return null;
127        }
128        let specials = [ids && "id", "$id", "w", "h"].filter(Boolean);
129        let attrs = Array.from(node.attributes)
130            .filter((a) => !specials.includes(a.name))
131            .reduce((o, a) => Object.assign(o, { [a.name]: a.value }), {});
132        let children = buildUI(node.childNodes, ids);
133        let widget;
134        if (RapidContext.Widget.Classes[name]) {
135            widget = RapidContext.Widget.Classes[name](attrs, ...children);
136        } else {
137            widget = MochiKit.DOM.createDOM(name, attrs, children);
138        }
139        if ("id" in node.attributes && ids) {
140            ids[node.attributes.id.value] = widget;
141        }
142        if ("$id" in node.attributes) {
143            widget.id = node.attributes.$id.value;
144        }
145        if ("w" in node.attributes || "h" in node.attributes) {
146            let w = node.attributes.w && node.attributes.w.value;
147            let h = node.attributes.h && node.attributes.h.value;
148            RapidContext.Util.registerSizeConstraints(widget, w, h);
149        }
150        return widget;
151    }
152
153    /**
154     * Creates and injects a stylesheet element from a set of CSS rules.
155     *
156     * @param {string} css the CSS rules to inject
157     */
158    function _buildUIStylesheet(css) {
159        var style = document.createElement("style");
160        style.setAttribute("type", "text/css");
161        document.getElementsByTagName("head")[0].append(style);
162        try {
163            style.innerHTML = css;
164        } catch (e) {
165            var parts = css.split(/\s*[{}]\s*/);
166            for (var i = 0; i < parts.length; i += 2) {
167                var rules = parts[i].split(/\s*,\s*/);
168                var styles = parts[i + 1];
169                for (var j = 0; j < rules.length; j++) {
170                    var rule = rules[j].replace(/\s+/, " ").trim();
171                    style.styleSheet.addRule(rule, styles);
172                }
173            }
174        }
175    }
176
177    /**
178     * Connects the default UI signals for a procedure. This includes a default
179     * error handler, a loading icon with cancellation handler and a reload icon
180     * with the appropriate click handler.
181     *
182     * @param {Procedure} proc the `RapidContext.Procedure` instance
183     * @param {Icon} [loadingIcon] the loading icon, or `null`
184     * @param {Icon} [reloadIcon] the reload icon, or `null`
185     *
186     * @see RapidContext.Procedure
187     *
188     * @memberof RapidContext.UI
189     */
190    function connectProc(proc, loadingIcon, reloadIcon) {
191        // TODO: error signal not automatically cleaned up on stop()...
192        MochiKit.Signal.connect(proc, "onerror", showError);
193        if (loadingIcon) {
194            MochiKit.Signal.connect(proc, "oncall", loadingIcon, "show");
195            MochiKit.Signal.connect(proc, "onresponse", loadingIcon, "hide");
196            MochiKit.Signal.connect(loadingIcon, "onclick", proc, "cancel");
197        }
198        if (reloadIcon) {
199            MochiKit.Signal.connect(proc, "oncall", reloadIcon, "hide");
200            MochiKit.Signal.connect(proc, "onresponse", reloadIcon, "show");
201            MochiKit.Signal.connect(reloadIcon, "onclick", proc, "recall");
202        }
203    }
204
205    // Create namespaces
206    var RapidContext = window.RapidContext || (window.RapidContext = {});
207    var module = RapidContext.UI || (RapidContext.UI = {});
208
209    // Export namespace symbols
210    Object.assign(module, { showError, buildUI, connectProc });
211
212})(this);
213