Source rapidcontext/ui/msg.mjs

1/*
2 * RapidContext <https://www.rapidcontext.com/>
3 * Copyright (c) 2007-2026 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 common message dialogs.
17 * @namespace RapidContext.UI.Msg
18 */
19
20/**
21 * The message actions (i.e. buttons) to show in the message dialog. Each
22 * action corresponds to a result value returned by the message promise.
23 * The action values `cancel` and `reload` are handled specially, as they
24 * either reject the promise or reload the page altogether.
25 *
26 * Each action button can either use default styles, or be configured with
27 * an options object. By default, the action key is used as both the
28 * default button type (e.g. `primary`, `danger`, `info` or `success`), and
29 * the returned action value.
30 *
31 * @param {string|Object} * the action text or options object
32 * @param {string} [*.text] the button text to show
33 * @param {string} [*.icon] the button icon to show
34 * @param {string} [*.css] the button CSS classes or styles, uses action key by default
35 * @param {string} [*.action] the action return value, uses action key by default
36 * @param {string} [*.href] the URL to follow if selected
37 * @param {string} [*.target] the link target (e.g. `_blank`)
38 * @typedef {Object} Actions
39 * @memberof RapidContext.UI.Msg
40 *
41 * @example
42 * {
43 *    // Actions can use either default styles or configuration
44 *    danger: "Yes, I know what I'm doing",
45 *    custom: { text: "Ok", icon: "fa fa-lg fa-check", css: 'primary' },
46 *
47 *    // The special 'cancel' action rejects the returned promise
48 *    cancel: { text: "Cancel", icon: "fa fa-lg fa-times" },
49 *
50 *    // The 'reload' action triggers a reload of the current page
51 *    reload: { text: "Reload page" }
52 * }
53 */
54const TYPES = {
55    'error': {
56        title: 'Error',
57        css: 'danger',
58        text: 'An error message',
59        icon: 'fa fa-minus-circle color-danger',
60        actions: {
61            danger: { icon: 'fa fa-lg fa-bolt', text: 'Ok, I understand' }
62        }
63    },
64    'warning': {
65        title: 'Warning',
66        css: 'warning',
67        text: 'A warning message',
68        icon: 'fa fa-exclamation-triangle color-warning',
69        actions: {
70            warning: { icon: 'fa fa-lg fa-check', text: 'Ok, I understand' }
71        }
72    },
73    'success': {
74        title: 'Success',
75        css: 'success',
76        text: 'A success message',
77        icon: 'fa fa-check color-success',
78        actions: {
79            success: { icon: 'fa fa-lg fa-check', text: 'Ok, got it' }
80        }
81    },
82    'info': {
83        title: 'Information',
84        css: 'info',
85        text: 'An information message',
86        icon: 'fa fa-info-circle color-info',
87        actions: {
88            info: { icon: 'fa fa-lg fa-check', text: 'Ok, got it' }
89        }
90    }
91};
92
93const XML = `
94    <Dialog system='true' closeable='false' class='position-relative' style='width: 25rem;'>
95      <i class='ui-msg-icon position-absolute top-0 left-0 fa fa-fw fa-3x px-1 py-2'></i>
96      <div class='ui-msg-text' style='padding: 0.5em 0 1em 4.5em;'>
97      </div>
98      <div class='ui-msg-btns text-right mt-1'>
99      </div>
100    </Dialog>
101`.trim();
102
103function isObject(o) {
104    return Object.prototype.toString.call(o) === '[object Object]';
105}
106
107function show(type, cfg) {
108    cfg = { ...TYPES[type], ...(isObject(cfg) ? cfg : { text: String(cfg) }) };
109    const dlg = RapidContext.UI.create(XML);
110    dlg.setAttrs({ title: cfg.title });
111    if (isObject(cfg.css) || /:/.test(cfg.css)) {
112        dlg.setAttrs({ style: cfg.css });
113    } else {
114        dlg.classList.add(...cfg.css.split(/\s+/g));
115    }
116    dlg.querySelector('.ui-msg-icon').classList.add(...cfg.icon.split(/\s+/g));
117    if (cfg.html?.nodeType > 0) {
118        dlg.querySelector('.ui-msg-text').replaceChildren(cfg.html);
119    } else if (cfg.html) {
120        dlg.querySelector('.ui-msg-text').innerHTML = cfg.html;
121    } else {
122        dlg.querySelector('.ui-msg-text').innerText = cfg.text;
123    }
124    for (const key in cfg.actions) {
125        const act = normalizeAction(key, cfg.actions[key]);
126        const btn = createButton(act);
127        if (act.css.includes(':')) {
128            btn.style.cssText = act.css;
129        } else {
130            btn.classList.add(...act.css.split(/\s+/g));
131        }
132        btn.setAttribute('data-action', act.action);
133        if (act.icon || act.href) {
134            const icon = document.createElement('i');
135            icon.className = act.icon || 'fa fa-arrow-right';
136            btn.append(icon);
137        }
138        if (act.text) {
139            const span = document.createElement('span');
140            span.innerText = act.text;
141            btn.append(span);
142        }
143        dlg.querySelector('.ui-msg-btns').append(btn);
144    }
145    const promise = createEventHandler(dlg);
146    promise.dialog = dlg;
147    promise.content = dlg.querySelector('.ui-msg-text');
148    document.body.append(dlg);
149    dlg.show();
150    return promise;
151}
152
153function normalizeAction(key, val) {
154    val = isObject(val) ? val : { text: String(val) };
155    if (key === 'reload' || val.action === 'reload') {
156        const href = 'javascript:window.location.reload();';
157        const css = val.css || 'danger';
158        Object.assign(val, { href, target: null, css, icon: 'fa fa-refresh' });
159    }
160    val.css = val.css || key;
161    val.action = val.action || key;
162    return val;
163}
164
165function createButton(act) {
166    if (act.href) {
167        const el = document.createElement('a');
168        el.setAttribute('href', act.href);
169        if (act.target != null && act.target !== false) {
170            el.setAttribute('target', act.target || '_blank');
171        }
172        el.classList.add('btn');
173        return el;
174    } else {
175        return document.createElement('button');
176    }
177}
178
179function createEventHandler(dlg) {
180    return new Promise(function (resolve, reject) {
181        const handler = (evt) => {
182            const action = evt.delegateTarget.dataset.action;
183            if (action) {
184                dlg.hide();
185                RapidContext.Widget.destroyWidget(dlg);
186                if (action === 'cancel') {
187                    reject(new Error('operation cancelled'));
188                } else {
189                    resolve(action);
190                }
191            }
192        };
193        dlg.once('click', '.ui-msg-btns [data-action]', handler);
194    });
195}
196
197/**
198 * Displays a modal error dialog, similar to `alert()`. By default a
199 * single action is shown to close the dialog, but use the `actions`
200 * options to configure otherwise. The returned promise resolves with the
201 * action selected by the user.
202 *
203 * @param {string|Object} msg the message text or message configuration options
204 * @param {string} [msg.title="Error"] the dialog title
205 * @param {string} [msg.text="An error message"] the message text
206 * @param {string|Node} [msg.html] the message in HTML format
207 * @param {string} [msg.css] the dialog css
208 * @param {Actions} [msg.actions] the action buttons to show
209 * @return {Promise} a promise that resolves with the selected action
210 * @function RapidContext.UI.Msg.error
211 *
212 * @example
213 * try {
214 *     ...
215 * } catch (e) {
216 *     await RapidContext.UI.Msg.error(e.toString());
217 * }
218 */
219export const error = show.bind(null, 'error');
220
221/**
222 * Displays a modal warning dialog, similar to `alert()`. By default a
223 * single action is shown to close the dialog, but use the `actions`
224 * options to configure otherwise. The returned promise resolves with the
225 * action selected by the user.
226 *
227 * @param {string|Object} msg the message text or message configuration options
228 * @param {string} [msg.title="Warning"] the dialog title
229 * @param {string} [msg.text="A warning message"] the message text
230 * @param {string|Node} [msg.html] the message in HTML format
231 * @param {string} [msg.css] the dialog css
232 * @param {Actions} [msg.actions] the action buttons to show
233 * @return {Promise} a promise that resolves with the selected action
234 * @function RapidContext.UI.Msg.warning
235 *
236 * @example
237 * let text = "This will take some time to complete. Are you sure?";
238 * let actions = {
239 *     cancel: "No, I've changed my mind",
240 *     warning: "Yes, I understand"
241 * }
242 * RapidContext.UI.Msg.warning({ text, actions }).then(...);
243 */
244export const warning = show.bind(null, 'warning');
245
246/**
247 * Displays a modal success dialog, similar to `alert()`. By default a
248 * single action is shown to close the dialog, but use the `actions`
249 * options to configure otherwise. The returned promise resolves with the
250 * action selected by the user.
251 *
252 * @param {string|Object} msg the message text or message configuration options
253 * @param {string} [msg.title="Success"] the dialog title
254 * @param {string} [msg.text="A success message"] the message text
255 * @param {string|Node} [msg.html] the message in HTML format
256 * @param {string} [msg.css] the dialog css
257 * @param {Actions} [msg.actions] the action buttons to show
258 * @return {Promise} a promise that resolves with the selected action
259 * @function RapidContext.UI.Msg.success
260 *
261 * @example
262 * RapidContext.UI.Msg.success("Everything is proceeding as planned.");
263 */
264export const success = show.bind(null, 'success');
265
266/**
267 * Displays a modal info dialog, similar to `alert()`. By default a
268 * single action is shown to close the dialog, but use the `actions`
269 * options to configure otherwise. The returned promise resolves with the
270 * action selected by the user.
271 *
272 * @param {string|Object} msg the message text or message configuration options
273 * @param {string} [msg.title="Information"] the dialog title
274 * @param {string} [msg.text="An information message"] the message text
275 * @param {string|Node} [msg.html] the message in HTML format
276 * @param {string} [msg.css] the dialog css
277 * @param {Actions} [msg.actions] the action buttons to show
278 * @return {Promise} a promise that resolves with the selected action
279 * @function RapidContext.UI.Msg.info
280 *
281 * @example
282 * await RapidContext.UI.Msg.info("System will now reboot. Please wait.");
283 * ...
284 */
285export const info = show.bind(null, 'info');
286
287Object.assign(error, {
288    loggedOut() {
289        return error({
290            title: 'Logged out',
291            text: 'You\'ve been logged out. Please reload the page to login again.',
292            actions: {
293                reload: 'Reload page'
294            }
295        });
296    }
297});
298
299Object.assign(warning, {
300    /**
301     * Displays an object removal warning, similar to `confirm()`. This is
302     * a simplified preset that uses the generic `warning` function.
303     *
304     * @param {string} type the object type (e.g. "connection")
305     * @param {string} id the object name or id
306     * @return {Promise} a promise that resolves when confirmed, or
307     *                   rejects if cancelled
308     * @function RapidContext.UI.Msg.warning:remove
309     *
310     * @example
311     * try {
312     *     await RapidContext.UI.Msg.warning.remove("person", "123");
313     *     ...
314     * } catch (e) {
315     *     // cancelled
316     * }
317     */
318    remove(type, id) {
319        return warning({
320            title: `Remove ${type}`,
321            html: `Do you really want to remove the ${type} <code>${id}</code>?`,
322            actions: {
323                cancel: 'Cancel',
324                warning: {
325                    icon: 'fa fa-lg fa-minus-circle',
326                    text: `Yes, remove ${type}`
327                }
328            }
329        });
330    }
331});
332
333Object.assign(info, {
334    updateAvailable() {
335        return info({
336            title: 'Update available',
337            text: 'An updated version is available. Reload the page to access the latest version.',
338            actions: {
339                close: 'Maybe later',
340                reload: { text: 'Reload page', css: 'info', action: 'info' }
341            }
342        });
343    }
344});
345
346export default { error, warning, success, info };
347