From 3a3d59fee35e89d2dd0e3f95112a1d598c92262a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20J=C3=A4ckle?= Date: Fri, 13 Oct 2023 13:54:47 +0200 Subject: [PATCH] several small Ditto UI improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add a tab "Message to Thing" to send thing messages * add a loading spinner to the "Send" (message) button and deactivate it while sending * update a complete Thing using "PATCH" and with the new 3.4.0 header "if-equal: skip-minimizing-merge" * only send eTag if it could be retrieved when updating complete thing * added missing "ilike" predicate to the search slot Signed-off-by: Thomas Jäckle --- ui/main.scss | 10 +- ui/main.ts | 23 ++-- ui/modules/api.ts | 6 +- ui/modules/things/featureMessages.html | 1 + ui/modules/things/featureMessages.ts | 26 ++-- ui/modules/things/thingMessages.html | 52 ++++++++ ui/modules/things/thingMessages.ts | 159 +++++++++++++++++++++++++ ui/modules/things/thingsCRUD.ts | 29 ++--- ui/modules/things/thingsSearch.ts | 6 +- 9 files changed, 272 insertions(+), 40 deletions(-) create mode 100644 ui/modules/things/thingMessages.html create mode 100644 ui/modules/things/thingMessages.ts diff --git a/ui/main.scss b/ui/main.scss index 7628548241..40b03164f3 100644 --- a/ui/main.scss +++ b/ui/main.scss @@ -195,4 +195,12 @@ h5>.badge { .autoComplete_wrapper ul > li[aria-selected="true"] { background-color: rgba(123, 123, 123, 0.1); -} \ No newline at end of file +} + +.spinner-border { + visibility:hidden; +} + +button.busy .spinner-border { + visibility:visible !important; +} diff --git a/ui/main.ts b/ui/main.ts index f1b2c43a1e..c37729357f 100644 --- a/ui/main.ts +++ b/ui/main.ts @@ -10,31 +10,33 @@ * * SPDX-License-Identifier: EPL-2.0 */ +import { Dropdown } from 'bootstrap'; /* eslint-disable new-cap */ import 'bootstrap/dist/css/bootstrap.min.css'; import './main.scss'; -import {Dropdown} from 'bootstrap'; +import * as Connections from './modules/connections/connections.js'; +import * as ConnectionsCRUD from './modules/connections/connectionsCRUD.js'; +import * as ConnectionsMonitor from './modules/connections/connectionsMonitor.js'; import * as Authorization from './modules/environments/authorization.js'; import * as Environments from './modules/environments/environments.js'; +import * as Operations from './modules/operations/operations.js'; +import * as Policies from './modules/policies/policies.js'; import * as Attributes from './modules/things/attributes.js'; -import * as Features from './modules/things/features.js'; import * as FeatureMessages from './modules/things/featureMessages.js'; +import * as Features from './modules/things/features.js'; import * as Fields from './modules/things/fields.js'; +import * as MessagesIncoming from './modules/things/messagesIncoming.js'; import * as SearchFilter from './modules/things/searchFilter.js'; +import * as ThingMessages from './modules/things/thingMessages.js'; import * as Things from './modules/things/things.js'; -import * as ThingsSearch from './modules/things/thingsSearch.js'; import * as ThingsCRUD from './modules/things/thingsCRUD.js'; +import * as ThingsSearch from './modules/things/thingsSearch.js'; import * as ThingsSSE from './modules/things/thingsSSE.js'; -import * as MessagesIncoming from './modules/things/messagesIncoming.js'; -import * as Connections from './modules/connections/connections.js'; -import * as ConnectionsCRUD from './modules/connections/connectionsCRUD.js'; -import * as ConnectionsMonitor from './modules/connections/connectionsMonitor.js'; -import * as Operations from './modules/operations/operations.js'; -import * as Policies from './modules/policies/policies.js'; +import { WoTDescription } from './modules/things/wotDescription.js'; import * as Utils from './modules/utils.js'; -import {WoTDescription} from './modules/things/wotDescription.js'; import './modules/utils/crudToolbar.js'; + let resized = false; let mainNavbar; @@ -43,6 +45,7 @@ document.addEventListener('DOMContentLoaded', async function() { await Things.ready(); ThingsSearch.ready(); ThingsCRUD.ready(); + await ThingMessages.ready(); ThingsSSE.ready(); MessagesIncoming.ready(); Attributes.ready(); diff --git a/ui/modules/api.ts b/ui/modules/api.ts index f13602c222..37144031b1 100644 --- a/ui/modules/api.ts +++ b/ui/modules/api.ts @@ -12,9 +12,9 @@ * SPDX-License-Identifier: EPL-2.0 */ +import { EventSourcePolyfill } from 'event-source-polyfill'; import * as Environments from './environments/environments.js'; import * as Utils from './utils.js'; -import {EventSourcePolyfill} from 'event-source-polyfill'; const config = { @@ -320,11 +320,12 @@ export function setAuthHeader(forDevOps) { export async function callDittoREST(method, path, body = null, additionalHeaders = null, returnHeaders = false, devOps = false) { let response; + const contentType = method === 'PATCH' ? 'application/merge-patch+json' : 'application/json'; try { response = await fetch(Environments.current().api_uri + (devOps ? '' : '/api/2') + path, { method: method, headers: { - 'Content-Type': 'application/json', + 'Content-Type': contentType, [authHeaderKey]: authHeaderValue, ...additionalHeaders, }, @@ -345,6 +346,7 @@ export async function callDittoREST(method, path, body = null, throw new Error('An error occurred: ' + response.status); } if (response.status !== 204) { + console.log(...response.headers); if (returnHeaders) { return response; } else { diff --git a/ui/modules/things/featureMessages.html b/ui/modules/things/featureMessages.html index c35f30fd50..1f4d54cd1f 100644 --- a/ui/modules/things/featureMessages.html +++ b/ui/modules/things/featureMessages.html @@ -19,6 +19,7 @@
diff --git a/ui/modules/things/featureMessages.ts b/ui/modules/things/featureMessages.ts index 39d75ca854..cc748e8054 100644 --- a/ui/modules/things/featureMessages.ts +++ b/ui/modules/things/featureMessages.ts @@ -1,23 +1,23 @@ /* -* Copyright (c) 2022 Contributors to the Eclipse Foundation -* -* See the NOTICE file(s) distributed with this work for additional + * Copyright (c) 2022 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional * information regarding copyright ownership. * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License 2.0 which is available at * http://www.eclipse.org/legal/epl-2.0 -* -* SPDX-License-Identifier: EPL-2.0 -*/ + * + * SPDX-License-Identifier: EPL-2.0 + */ /* eslint-disable require-jsdoc */ import * as API from '../api.js'; import * as Environments from '../environments/environments.js'; import * as Utils from '../utils.js'; -import * as Things from './things.js'; -import * as Features from './features.js'; import featureMessagesHTML from './featureMessages.html'; +import * as Features from './features.js'; +import * as Things from './things.js'; let theFeatureId; @@ -59,6 +59,8 @@ export async function ready() { Utils.assert(theFeatureId, 'Please select a Feature', dom.tableValidationFeature); Utils.assert(dom.inputMessageSubject.value, 'Please give a Subject', dom.inputMessageSubject); Utils.assert(dom.inputMessageTimeout.value, 'Please give a timeout', dom.inputMessageTimeout); + dom.buttonMessageSend.classList.add('busy'); + dom.buttonMessageSend.disabled = true; messageFeature(); }; @@ -113,7 +115,7 @@ export async function ready() { * Calls Ditto to send a message with the parameters of the fields in the UI */ function messageFeature() { - const payload = JSON.parse(acePayload.getValue()); + const payload = acePayload && acePayload.getValue().length > 0 && JSON.parse(acePayload.getValue()); aceResponse.setValue(''); API.callDittoREST('POST', '/things/' + Things.theThing.thingId + '/features/' + theFeatureId + @@ -121,10 +123,14 @@ function messageFeature() { '?timeout=' + dom.inputMessageTimeout.value, payload, ).then((data) => { + dom.buttonMessageSend.classList.remove('busy'); + dom.buttonMessageSend.disabled = false; if (dom.inputMessageTimeout.value > 0) { aceResponse.setValue(JSON.stringify(data, null, 2), -1); } }).catch((err) => { + dom.buttonMessageSend.classList.remove('busy'); + dom.buttonMessageSend.disabled = false; aceResponse.setValue(''); }); } @@ -145,7 +151,7 @@ function clearAllFields() { dom.inputMessageTemplate.value = null; dom.inputMessageSubject.value = null; dom.inputMessageTimeout.value = '10'; - acePayload.setValue('{}'); + acePayload.setValue(''); aceResponse.setValue(''); dom.ulMessageTemplates.innerHTML = ''; } diff --git a/ui/modules/things/thingMessages.html b/ui/modules/things/thingMessages.html new file mode 100644 index 0000000000..d812150fd5 --- /dev/null +++ b/ui/modules/things/thingMessages.html @@ -0,0 +1,52 @@ + +
+
+
+ + + + +
+
+
+ +
+ + + +
+ +
+
+
+ +
+
+
+
+
+
+
+ +
+
\ No newline at end of file diff --git a/ui/modules/things/thingMessages.ts b/ui/modules/things/thingMessages.ts new file mode 100644 index 0000000000..fa3a611e46 --- /dev/null +++ b/ui/modules/things/thingMessages.ts @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2023 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ + +/* eslint-disable require-jsdoc */ +import * as API from '../api.js'; +import * as Environments from '../environments/environments.js'; +import * as Utils from '../utils.js'; +import messagesHTML from './thingMessages.html'; +import * as Things from './things.js'; + +const dom = { + inputThingMessageSubject: null, + inputThingMessageTimeout: null, + inputThingMessageTemplate: null, + buttonThingMessageSend: null, + buttonThingMessageFavorite: null, + ulThingMessageTemplates: null, + favIconThingMessage: null, +}; + +let acePayload; +let aceResponse; + +/** + * Initializes components. Should be called after DOMContentLoaded event + */ +export async function ready() { + Environments.addChangeListener(onEnvironmentChanged); + + Utils.addTab( + document.getElementById('tabItemsThing'), + document.getElementById('tabContentThing'), + 'Message to Thing', + messagesHTML, + ); + + Utils.getAllElementsById(dom); + + acePayload = Utils.createAceEditor('acePayloadThingMessage', 'ace/mode/json'); + aceResponse = Utils.createAceEditor('aceResponseThingMessage', 'ace/mode/json', true); + + + dom.buttonThingMessageSend.onclick = () => { + Utils.assert(dom.inputThingMessageSubject.value, 'Please give a Subject', dom.inputThingMessageSubject); + Utils.assert(dom.inputThingMessageTimeout.value, 'Please give a timeout', dom.inputThingMessageTimeout); + dom.buttonThingMessageSend.classList.add('busy'); + dom.buttonThingMessageSend.disabled = true; + messageThing(); + }; + + dom.buttonThingMessageFavorite.onclick = () => { + const templateName = dom.inputThingMessageTemplate.value; + Utils.assert(templateName, 'Please give a name for the template', dom.inputThingMessageTemplate); + Environments.current().messageTemplates['/'] = Environments.current().messageTemplates['/'] || {}; + if (Object.keys(Environments.current().messageTemplates['/']).includes(templateName) && + dom.favIconThingMessage.classList.contains('bi-star-fill')) { + dom.favIconThingMessage.classList.replace('bi-star-fill', 'bi-star'); + delete Environments.current().messageTemplates['/'][templateName]; + } else { + dom.favIconThingMessage.classList.replace('bi-star', 'bi-star-fill'); + Environments.current().messageTemplates['/'][templateName] = { + subject: dom.inputThingMessageSubject.value, + timeout: dom.inputThingMessageTimeout.value, + payload: JSON.parse(acePayload.getValue()), + }; + acePayload.session.getUndoManager().markClean(); + } + Environments.environmentsJsonChanged('messageTemplates'); + }; + + dom.ulThingMessageTemplates.addEventListener('click', (event) => { + if (event.target && event.target.classList.contains('dropdown-item')) { + dom.favIconThingMessage.classList.replace('bi-star', 'bi-star-fill'); + const template = Environments.current().messageTemplates['/'][event.target.textContent]; + dom.inputThingMessageTemplate.value = event.target.textContent; + dom.inputThingMessageSubject.value = template.subject; + dom.inputThingMessageTimeout.value = template.timeout; + acePayload.setValue(JSON.stringify(template.payload, null, 2), -1); + acePayload.session.getUndoManager().markClean(); + } + }); + + [dom.inputThingMessageTemplate, dom.inputThingMessageSubject, dom.inputThingMessageTimeout].forEach((e) => { + e.addEventListener('change', () => { + dom.favIconThingMessage.classList.replace('bi-star-fill', 'bi-star'); + }); + }); + + acePayload.on('input', () => { + if (!acePayload.session.getUndoManager().isClean()) { + dom.favIconThingMessage.classList.replace('bi-star-fill', 'bi-star'); + } + }); +} + +/** + * Calls Ditto to send a message with the parameters of the fields in the UI + */ +function messageThing() { + const payload = acePayload && acePayload.getValue().length > 0 && JSON.parse(acePayload.getValue()); + aceResponse.setValue(''); + API.callDittoREST('POST', '/things/' + Things.theThing.thingId + + '/inbox/messages/' + dom.inputThingMessageSubject.value + + '?timeout=' + dom.inputThingMessageTimeout.value, + payload, + ).then((data) => { + dom.buttonThingMessageSend.classList.remove('busy'); + dom.buttonThingMessageSend.disabled = false; + if (dom.inputThingMessageTimeout.value > 0) { + aceResponse.setValue(JSON.stringify(data, null, 2), -1); + } + }).catch((err) => { + dom.buttonThingMessageSend.classList.remove('busy'); + dom.buttonThingMessageSend.disabled = false; + aceResponse.setValue(''); + }); +} + +function onEnvironmentChanged(modifiedField) { + Environments.current()['messageTemplates'] = Environments.current()['messageTemplates'] || {}; + + if (!modifiedField) { + clearAllFields(); + } + if (modifiedField === 'messageTemplates') { + refillTemplates(); + } +} + +function clearAllFields() { + dom.favIconThingMessage.classList.replace('bi-star-fill', 'bi-star'); + dom.inputThingMessageTemplate.value = null; + dom.inputThingMessageSubject.value = null; + dom.inputThingMessageTimeout.value = '10'; + acePayload.setValue(''); + aceResponse.setValue(''); + dom.ulThingMessageTemplates.innerHTML = ''; +} + +function refillTemplates() { + dom.ulThingMessageTemplates.innerHTML = ''; + Utils.addDropDownEntries(dom.ulThingMessageTemplates, ['Saved message templates'], true); + if (Environments.current().messageTemplates['/']) { + Utils.addDropDownEntries( + dom.ulThingMessageTemplates, + Object.keys(Environments.current().messageTemplates['/']), + ); + } +} diff --git a/ui/modules/things/thingsCRUD.ts b/ui/modules/things/thingsCRUD.ts index 3ea9e4f0e4..4cca929bf9 100644 --- a/ui/modules/things/thingsCRUD.ts +++ b/ui/modules/things/thingsCRUD.ts @@ -1,22 +1,22 @@ /* -* Copyright (c) 2022 Contributors to the Eclipse Foundation -* -* See the NOTICE file(s) distributed with this work for additional -* information regarding copyright ownership. -* -* This program and the accompanying materials are made available under the -* terms of the Eclipse Public License 2.0 which is available at -* http://www.eclipse.org/legal/epl-2.0 -* -* SPDX-License-Identifier: EPL-2.0 -*/ + * Copyright (c) 2022 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ /* eslint-disable require-jsdoc */ // @ts-check import * as API from '../api.js'; import * as Utils from '../utils.js'; -import * as ThingsSearch from './thingsSearch.js'; import * as Things from './things.js'; +import * as ThingsSearch from './thingsSearch.js'; import thingTemplates from './thingTemplates.json'; let thingJsonEditor; @@ -67,9 +67,10 @@ function onDeleteThingClick() { } function onUpdateThingClick() { - API.callDittoREST('PUT', `/things/${dom.crudThings.idValue}`, JSON.parse(thingJsonEditor.getValue()), + API.callDittoREST('PATCH', `/things/${dom.crudThings.idValue}`, JSON.parse(thingJsonEditor.getValue()), { - 'if-match': eTag, + 'if-match': eTag || "*", + 'if-equal': 'skip-minimizing-merge' }, ).then(() => { dom.crudThings.toggleEdit(); diff --git a/ui/modules/things/thingsSearch.ts b/ui/modules/things/thingsSearch.ts index d1f8f53724..a2c8dcb460 100644 --- a/ui/modules/things/thingsSearch.ts +++ b/ui/modules/things/thingsSearch.ts @@ -16,15 +16,15 @@ /* eslint-disable no-invalid-this */ /* eslint-disable arrow-parens */ -import {JSONPath} from 'jsonpath-plus'; +import { JSONPath } from 'jsonpath-plus'; import * as API from '../api.js'; +import * as Environments from '../environments/environments.js'; import * as Utils from '../utils.js'; import * as Fields from './fields.js'; import * as Things from './things.js'; import * as ThingsSSE from './thingsSSE.js'; -import * as Environments from '../environments/environments.js'; let lastSearch = ''; let theSearchCursor; @@ -78,7 +78,7 @@ function onThingsTableClicked(event) { */ export function searchTriggered(filter) { lastSearch = filter; - const regex = /^(eq\(|ne\(|gt\(|ge\(|lt\(|le\(|in\(|like\(|exists\(|and\(|or\(|not\().*/; + const regex = /^(eq\(|ne\(|gt\(|ge\(|lt\(|le\(|in\(|like\(|ilike\(|exists\(|and\(|or\(|not\().*/; if (filter === '' || regex.test(filter)) { searchThings(filter); } else {