From f079563daf486e0be4ef25998540dcb02f2be092 Mon Sep 17 00:00:00 2001 From: Philip Magyar Date: Thu, 22 Feb 2024 18:10:06 +0100 Subject: [PATCH 001/442] UI: sync country list & add flags to tb-contact country selector --- .../app/modules/home/models/contact.models.ts | 252 ------------------ .../shared/components/contact.component.html | 7 +- .../shared/components/contact.component.ts | 11 +- .../src/app/shared/models/country.models.ts | 15 ++ 4 files changed, 26 insertions(+), 259 deletions(-) diff --git a/ui-ngx/src/app/modules/home/models/contact.models.ts b/ui-ngx/src/app/modules/home/models/contact.models.ts index 41ebecc865..f1b23a0839 100644 --- a/ui-ngx/src/app/modules/home/models/contact.models.ts +++ b/ui-ngx/src/app/modules/home/models/contact.models.ts @@ -14,258 +14,6 @@ /// limitations under the License. /// -export const COUNTRIES = [ - 'Afghanistan', - 'ร…land Islands', - 'Albania', - 'Algeria', - 'American Samoa', - 'Andorra', - 'Angola', - 'Anguilla', - 'Antarctica', - 'Antigua and Barbuda', - 'Argentina', - 'Armenia', - 'Aruba', - 'Australia', - 'Austria', - 'Azerbaijan', - 'Bahamas', - 'Bahrain', - 'Bangladesh', - 'Barbados', - 'Belarus', - 'Belgium', - 'Belize', - 'Benin', - 'Bermuda', - 'Bhutan', - 'Bolivia', - 'Bonaire, Sint Eustatius and Saba', - 'Bosnia and Herzegovina', - 'Botswana', - 'Bouvet Island', - 'Brazil', - 'British Indian Ocean Territory', - 'Brunei Darussalam', - 'Bulgaria', - 'Burkina Faso', - 'Burundi', - 'Cambodia', - 'Cameroon', - 'Canada', - 'Cape Verde', - 'Cayman Islands', - 'Central African Republic', - 'Chad', - 'Chile', - 'China', - 'Christmas Island', - 'Cocos (Keeling) Islands', - 'Colombia', - 'Comoros', - 'Congo', - 'Congo, The Democratic Republic of the', - 'Cook Islands', - 'Costa Rica', - 'Cรดte d\'Ivoire', - 'Croatia', - 'Cuba', - 'Curaรงao', - 'Cyprus', - 'Czech Republic', - 'Denmark', - 'Djibouti', - 'Dominica', - 'Dominican Republic', - 'Ecuador', - 'Egypt', - 'El Salvador', - 'Equatorial Guinea', - 'Eritrea', - 'Estonia', - 'Ethiopia', - 'Falkland Islands (Malvinas)', - 'Faroe Islands', - 'Fiji', - 'Finland', - 'France', - 'French Guiana', - 'French Polynesia', - 'French Southern Territories', - 'Gabon', - 'Gambia', - 'Georgia', - 'Germany', - 'Ghana', - 'Gibraltar', - 'Greece', - 'Greenland', - 'Grenada', - 'Guadeloupe', - 'Guam', - 'Guatemala', - 'Guernsey', - 'Guinea', - 'Guinea-Bissau', - 'Guyana', - 'Haiti', - 'Heard Island and McDonald Islands', - 'Holy See (Vatican City State)', - 'Honduras', - 'Hong Kong', - 'Hungary', - 'Iceland', - 'India', - 'Indonesia', - 'Iran, Islamic Republic of', - 'Iraq', - 'Ireland', - 'Isle of Man', - 'Israel', - 'Italy', - 'Jamaica', - 'Japan', - 'Jersey', - 'Jordan', - 'Kazakhstan', - 'Kenya', - 'Kiribati', - 'Korea, Democratic People\'s Republic of', - 'Korea, Republic of', - 'Kuwait', - 'Kyrgyzstan', - 'Lao People\'s Democratic Republic', - 'Latvia', - 'Lebanon', - 'Lesotho', - 'Liberia', - 'Libya', - 'Liechtenstein', - 'Lithuania', - 'Luxembourg', - 'Macao', - 'Macedonia, Republic Of', - 'Madagascar', - 'Malawi', - 'Malaysia', - 'Maldives', - 'Mali', - 'Malta', - 'Marshall Islands', - 'Martinique', - 'Mauritania', - 'Mauritius', - 'Mayotte', - 'Mexico', - 'Micronesia, Federated States of', - 'Moldova, Republic of', - 'Monaco', - 'Mongolia', - 'Montenegro', - 'Montserrat', - 'Morocco', - 'Mozambique', - 'Myanmar', - 'Namibia', - 'Nauru', - 'Nepal', - 'Netherlands', - 'New Caledonia', - 'New Zealand', - 'Nicaragua', - 'Niger', - 'Nigeria', - 'Niue', - 'Norfolk Island', - 'Northern Mariana Islands', - 'Norway', - 'Oman', - 'Pakistan', - 'Palau', - 'Palestinian Territory, Occupied', - 'Panama', - 'Papua New Guinea', - 'Paraguay', - 'Peru', - 'Philippines', - 'Pitcairn', - 'Poland', - 'Portugal', - 'Puerto Rico', - 'Qatar', - 'Reunion', - 'Romania', - 'Russian Federation', - 'Rwanda', - 'Saint Barthรฉlemy', - 'Saint Helena, Ascension and Tristan da Cunha', - 'Saint Kitts and Nevis', - 'Saint Lucia', - 'Saint Martin (French Part)', - 'Saint Pierre and Miquelon', - 'Saint Vincent and the Grenadines', - 'Samoa', - 'San Marino', - 'Sao Tome and Principe', - 'Saudi Arabia', - 'Senegal', - 'Serbia', - 'Seychelles', - 'Sierra Leone', - 'Singapore', - 'Sint Maarten (Dutch Part)', - 'Slovakia', - 'Slovenia', - 'Solomon Islands', - 'Somalia', - 'South Africa', - 'South Georgia and the South Sandwich Islands', - 'South Sudan', - 'Spain', - 'Sri Lanka', - 'Sudan', - 'Suriname', - 'Svalbard and Jan Mayen', - 'Swaziland', - 'Sweden', - 'Switzerland', - 'Syrian Arab Republic', - 'Taiwan', - 'Tajikistan', - 'Tanzania, United Republic of', - 'Thailand', - 'Timor-Leste', - 'Togo', - 'Tokelau', - 'Tonga', - 'Trinidad and Tobago', - 'Tunisia', - 'Turkey', - 'Turkmenistan', - 'Turks and Caicos Islands', - 'Tuvalu', - 'Uganda', - 'Ukraine', - 'United Arab Emirates', - 'United Kingdom', - 'United States', - 'United States Minor Outlying Islands', - 'Uruguay', - 'Uzbekistan', - 'Vanuatu', - 'Venezuela', - 'Viet Nam', - 'Virgin Islands, British', - 'Virgin Islands, U.S.', - 'Wallis and Futuna', - 'Western Sahara', - 'Yemen', - 'Zambia', - 'Zimbabwe' -]; - /* eslint-disable */ export const POSTAL_CODE_PATTERNS = { 'United States': '(\\d{5}([\\-]\\d{4})?)', diff --git a/ui-ngx/src/app/shared/components/contact.component.html b/ui-ngx/src/app/shared/components/contact.component.html index 059fd09c80..c0688be71a 100644 --- a/ui-ngx/src/app/shared/components/contact.component.html +++ b/ui-ngx/src/app/shared/components/contact.component.html @@ -18,9 +18,10 @@
contact.country - - - {{ country }} + + + {{country.flag}} + {{country.name }} diff --git a/ui-ngx/src/app/shared/components/contact.component.ts b/ui-ngx/src/app/shared/components/contact.component.ts index c5a3daecf8..dae5f2ac0d 100644 --- a/ui-ngx/src/app/shared/components/contact.component.ts +++ b/ui-ngx/src/app/shared/components/contact.component.ts @@ -16,11 +16,13 @@ import { Component, Input } from '@angular/core'; import { UntypedFormGroup } from '@angular/forms'; -import { COUNTRIES } from '@home/models/contact.models'; - +import { CountryData } from '../models/country.models'; @Component({ selector: 'tb-contact', - templateUrl: './contact.component.html' + templateUrl: './contact.component.html', + providers: [ + CountryData + ] }) export class ContactComponent { @@ -29,6 +31,7 @@ export class ContactComponent { @Input() isEdit: boolean; - countries = COUNTRIES; + countries = this.countryData.allCountries; + constructor(private countryData: CountryData) {} } diff --git a/ui-ngx/src/app/shared/models/country.models.ts b/ui-ngx/src/app/shared/models/country.models.ts index 814cb81147..919d362067 100644 --- a/ui-ngx/src/app/shared/models/country.models.ts +++ b/ui-ngx/src/app/shared/models/country.models.ts @@ -32,6 +32,7 @@ export enum CountryISO { Andorra = 'AD', Angola = 'AO', Anguilla = 'AI', + Antarctica = 'AQ', AntiguaAndBarbuda = 'AG', Argentina = 'AR', Armenia = 'AM', @@ -50,6 +51,8 @@ export enum CountryISO { Bermuda = 'BM', Bhutan = 'BT', Bolivia = 'BO', + BonaireAndSintEustatiusAndSaba = 'BQ', + BouvetIsland = 'BV', BosniaAndHerzegovina = 'BA', Botswana = 'BW', Brazil = 'BR', @@ -101,6 +104,7 @@ export enum CountryISO { France = 'FR', FrenchGuiana = 'GF', FrenchPolynesia = 'PF', + FrenchSouthernTerritories = 'TF', Gabon = 'GA', Gambia = 'GM', Georgia = 'GE', @@ -118,6 +122,7 @@ export enum CountryISO { GuineaBissau = 'GW', Guyana = 'GY', Haiti = 'HT', + HeardIslandandMcDonaldIslands = 'HM', Honduras = 'HN', HongKong = 'HK', Hungary = 'HU', @@ -195,6 +200,7 @@ export enum CountryISO { Paraguay = 'PY', Peru = 'PE', Philippines = 'PH', + PitcairnIslands = 'PN', Poland = 'PL', Portugal = 'PT', PuertoRico = 'PR', @@ -225,6 +231,7 @@ export enum CountryISO { SolomonIslands = 'SB', Somalia = 'SO', SouthAfrica = 'ZA', + SouthGeorgiaAndTheSouthSandwichIslands = 'GS', SouthKorea = 'KR', SouthSudan = 'SS', Spain = 'ES', @@ -280,6 +287,7 @@ export class CountryData { {name: 'Andorra', iso2: CountryISO.Andorra, dialCode: '376', flag: '๐Ÿ‡ฆ๐Ÿ‡ฉ'}, {name: 'Angola', iso2: CountryISO.Angola, dialCode: '244', flag: '๐Ÿ‡ฆ๐Ÿ‡ด'}, {name: 'Anguilla', iso2: CountryISO.Anguilla, dialCode: '1', flag: '๐Ÿ‡ฆ๐Ÿ‡ฎ'}, + {name: 'Antarctica', iso2: CountryISO.Antarctica, dialCode: '672', flag: '๐Ÿ‡ฆ๐Ÿ‡ถ'}, {name: 'Antigua and Barbuda', iso2: CountryISO.AntiguaAndBarbuda, dialCode: '1', flag: '๐Ÿ‡ฆ๐Ÿ‡ฌ'}, {name: 'Argentina', iso2: CountryISO.Argentina, dialCode: '54', flag: '๐Ÿ‡ฆ๐Ÿ‡ท'}, {name: 'Armenia', iso2: CountryISO.Armenia, dialCode: '374', flag: '๐Ÿ‡ฆ๐Ÿ‡ฒ'}, @@ -298,6 +306,8 @@ export class CountryData { {name: 'Bermuda', iso2: CountryISO.Bermuda, dialCode: '1', flag: '๐Ÿ‡ง๐Ÿ‡ฒ'}, {name: 'Bhutan', iso2: CountryISO.Bhutan, dialCode: '975', flag: '๐Ÿ‡ง๐Ÿ‡น'}, {name: 'Bolivia', iso2: CountryISO.Bolivia, dialCode: '591', flag: '๐Ÿ‡ง๐Ÿ‡ด'}, + {name: 'Bonaire, Sint Eustatius and Saba', iso2: CountryISO.BonaireAndSintEustatiusAndSaba, dialCode: '599', flag: '๐Ÿ‡ง๐Ÿ‡ถ'}, + {name: 'Bouvet Island', iso2: CountryISO.BouvetIsland, dialCode: '47', flag: '๐Ÿ‡ง๐Ÿ‡ป'}, {name: 'Bosnia and Herzegovina', iso2: CountryISO.BosniaAndHerzegovina, dialCode: '387', flag: '๐Ÿ‡ง๐Ÿ‡ฆ'}, {name: 'Botswana', iso2: CountryISO.Botswana, dialCode: '267', flag: '๐Ÿ‡ง๐Ÿ‡ผ'}, {name: 'Brazil', iso2: CountryISO.Brazil, dialCode: '55', flag: '๐Ÿ‡ง๐Ÿ‡ท'}, @@ -349,6 +359,7 @@ export class CountryData { {name: 'France', iso2: CountryISO.France, dialCode: '33', flag: '๐Ÿ‡ซ๐Ÿ‡ท'}, {name: 'French Guiana', iso2: CountryISO.FrenchGuiana, dialCode: '594', flag: '๐Ÿ‡ฌ๐Ÿ‡ซ'}, {name: 'French Polynesia', iso2: CountryISO.FrenchPolynesia, dialCode: '689', flag: '๐Ÿ‡ต๐Ÿ‡ซ'}, + {name: 'French Southern Territories', iso2: CountryISO.FrenchSouthernTerritories, dialCode: '262', flag: '๐Ÿ‡น๐Ÿ‡ซ'}, {name: 'Gabon', iso2: CountryISO.Gabon, dialCode: '241', flag: '๐Ÿ‡ฌ๐Ÿ‡ฆ'}, {name: 'Gambia', iso2: CountryISO.Gambia, dialCode: '220', flag: '๐Ÿ‡ฌ๐Ÿ‡ฒ'}, {name: 'Georgia', iso2: CountryISO.Georgia, dialCode: '995', flag: '๐Ÿ‡ฌ๐Ÿ‡ช'}, @@ -366,6 +377,7 @@ export class CountryData { {name: 'Guinea-Bissau', iso2: CountryISO.GuineaBissau, dialCode: '245', flag: '๐Ÿ‡ฌ๐Ÿ‡ผ'}, {name: 'Guyana', iso2: CountryISO.Guyana, dialCode: '592', flag: '๐Ÿ‡ฌ๐Ÿ‡พ'}, {name: 'Haiti', iso2: CountryISO.Haiti, dialCode: '509', flag: '๐Ÿ‡ญ๐Ÿ‡น'}, + {name: 'Heard Island and McDonald Islands', iso2: CountryISO.HeardIslandandMcDonaldIslands, dialCode: '672', flag: '๐Ÿ‡ญ๐Ÿ‡ฒ'}, {name: 'Honduras', iso2: CountryISO.Honduras, dialCode: '504', flag: '๐Ÿ‡ญ๐Ÿ‡ณ'}, {name: 'Hong Kong', iso2: CountryISO.HongKong, dialCode: '852', flag: '๐Ÿ‡ญ๐Ÿ‡ฐ'}, {name: 'Hungary', iso2: CountryISO.Hungary, dialCode: '36', flag: '๐Ÿ‡ญ๐Ÿ‡บ'}, @@ -443,6 +455,7 @@ export class CountryData { {name: 'Paraguay', iso2: CountryISO.Paraguay, dialCode: '595', flag: '๐Ÿ‡ต๐Ÿ‡พ'}, {name: 'Peru', iso2: CountryISO.Peru, dialCode: '51', flag: '๐Ÿ‡ต๐Ÿ‡ช'}, {name: 'Philippines', iso2: CountryISO.Philippines, dialCode: '63', flag: '๐Ÿ‡ต๐Ÿ‡ญ'}, + {name: 'Pitcairn Islands', iso2: CountryISO.PitcairnIslands, dialCode: '649', flag: '๐Ÿ‡ต๐Ÿ‡ณ'}, {name: 'Poland', iso2: CountryISO.Poland, dialCode: '48', flag: '๐Ÿ‡ต๐Ÿ‡ฑ'}, {name: 'Portugal', iso2: CountryISO.Portugal, dialCode: '351', flag: '๐Ÿ‡ต๐Ÿ‡น'}, {name: 'Puerto Rico', iso2: CountryISO.PuertoRico, dialCode: '1', flag: '๐Ÿ‡ต๐Ÿ‡ท'}, @@ -473,6 +486,8 @@ export class CountryData { {name: 'Solomon Islands', iso2: CountryISO.SolomonIslands, dialCode: '677', flag: '๐Ÿ‡ธ๐Ÿ‡ง'}, {name: 'Somalia', iso2: CountryISO.Somalia, dialCode: '252', flag: '๐Ÿ‡ธ๐Ÿ‡ด'}, {name: 'South Africa', iso2: CountryISO.SouthAfrica, dialCode: '27', flag: '๐Ÿ‡ฟ๐Ÿ‡ฆ'}, + {name: 'South Georgia and the South Sandwich Islands', + iso2: CountryISO.SouthGeorgiaAndTheSouthSandwichIslands, dialCode: '500', flag: '๐Ÿ‡ฌ๐Ÿ‡ธ'}, {name: 'South Korea', iso2: CountryISO.SouthKorea, dialCode: '82', flag: '๐Ÿ‡ฐ๐Ÿ‡ท'}, {name: 'South Sudan', iso2: CountryISO.SouthSudan, dialCode: '211', flag: '๐Ÿ‡ธ๐Ÿ‡ธ'}, {name: 'Spain', iso2: CountryISO.Spain, dialCode: '34', flag: '๐Ÿ‡ช๐Ÿ‡ธ'}, From 4096a60612a7c3329830620764230e5fa27fb62a Mon Sep 17 00:00:00 2001 From: Philip Magyar Date: Thu, 29 Feb 2024 16:15:26 +0100 Subject: [PATCH 002/442] searchable countries --- .../shared/components/contact.component.html | 11 ++-- .../shared/components/contact.component.ts | 50 +++++++++++++++++-- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/ui-ngx/src/app/shared/components/contact.component.html b/ui-ngx/src/app/shared/components/contact.component.html index c0688be71a..6b997da07c 100644 --- a/ui-ngx/src/app/shared/components/contact.component.html +++ b/ui-ngx/src/app/shared/components/contact.component.html @@ -18,12 +18,13 @@
contact.country - - + + + {{country.flag}} {{country.name }} - +
@@ -56,7 +57,9 @@ contact.address2 - diff --git a/ui-ngx/src/app/shared/components/contact.component.ts b/ui-ngx/src/app/shared/components/contact.component.ts index dae5f2ac0d..410944f36a 100644 --- a/ui-ngx/src/app/shared/components/contact.component.ts +++ b/ui-ngx/src/app/shared/components/contact.component.ts @@ -14,9 +14,12 @@ /// limitations under the License. /// -import { Component, Input } from '@angular/core'; +import { Component, Input, OnInit, ViewChild } from '@angular/core'; import { UntypedFormGroup } from '@angular/forms'; -import { CountryData } from '../models/country.models'; +import { Country, CountryData } from '../models/country.models'; +import { Observable, map, startWith } from 'rxjs'; +import { PhoneInputComponent } from './phone-input.component'; +import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; @Component({ selector: 'tb-contact', templateUrl: './contact.component.html', @@ -24,14 +27,53 @@ import { CountryData } from '../models/country.models'; CountryData ] }) -export class ContactComponent { +export class ContactComponent implements OnInit { @Input() parentForm: UntypedFormGroup; @Input() isEdit: boolean; + @Input() phoneInputDefaultCountry = 'US'; + + @ViewChild('phoneInput') phoneInput: PhoneInputComponent | undefined; countries = this.countryData.allCountries; + countriesFiltered: Observable; + + constructor(private countryData: CountryData) { + this.displayCountryWithFlag = this.displayCountryWithFlag.bind(this); + } + + ngOnInit() { + this.countriesFiltered = this.parentForm.get('country').valueChanges + .pipe( + startWith(''), + map((countryName: string) => this._filterCountries(countryName)) + ); + } + + private _filterCountries(countryName: string): Country[] { + const filterValue = countryName.toLowerCase(); + return this.countries.filter(country => this.displayCountryWithFlag(country.name).toLowerCase().includes(filterValue)); + } + + displayCountryWithFlag(countryName: string): string { + const country = this.countries.find(c => c.name === countryName); + if (!country) { + return ''; + } + return country ? `${country.flag} ${country.name}` : ''; + } - constructor(private countryData: CountryData) {} + onCountryChange(select: MatAutocompleteSelectedEvent) { + // Get the selected country and check if the phone number is empty + // If it is, set the country code to the selected country + const countryName = select.option.value; + const country = this.countries.find(c => c.name === countryName); + if (country && this.phoneInput) { + if (!this.phoneInput.phoneFormGroup.get('phoneNumber').value) { + this.phoneInput.phoneFormGroup.get('country').setValue(country.iso2); + } + } + } } From d9b77251133551e4b8f933fe0b9a268d06730877 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Tue, 26 Mar 2024 14:24:18 +0100 Subject: [PATCH 003/442] WIP Redis junit5 test container --- dao/pom.xml | 5 ++ .../server/dao/RedisJUnit5Test.java | 64 +++++++++++++++++++ pom.xml | 12 ++++ 3 files changed, 81 insertions(+) create mode 100644 dao/src/test/java/org/thingsboard/server/dao/RedisJUnit5Test.java diff --git a/dao/pom.xml b/dao/pom.xml index ab62d8f013..700d2b0135 100644 --- a/dao/pom.xml +++ b/dao/pom.xml @@ -216,6 +216,11 @@ jdbc test + + org.testcontainers + junit-jupiter + test + org.springframework spring-context-support diff --git a/dao/src/test/java/org/thingsboard/server/dao/RedisJUnit5Test.java b/dao/src/test/java/org/thingsboard/server/dao/RedisJUnit5Test.java new file mode 100644 index 0000000000..43e788cccb --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/RedisJUnit5Test.java @@ -0,0 +1,64 @@ +/** + * Copyright ยฉ 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao; + +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.output.OutputFrame; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@Testcontainers +@Slf4j +public class RedisJUnit5Test { + + @Container + private static final GenericContainer REDIS = new GenericContainer("redis:7.2-bookworm") + .withLogConsumer(s -> log.error(((OutputFrame) s).getUtf8String().trim())) + .withExposedPorts(6379); + + @BeforeAll + static void beforeAll() { + log.warn("Starting redis..."); + REDIS.start(); + System.setProperty("cache.type", "redis"); + System.setProperty("redis.connection.type", "standalone"); + System.setProperty("redis.standalone.host", REDIS.getHost()); + System.setProperty("redis.standalone.port", String.valueOf(REDIS.getMappedPort(6379))); + + } + + @AfterAll + static void afterAll() { + List.of("cache.type", "redis.connection.type", "redis.standalone.host", "redis.standalone.port") + .forEach(System.getProperties()::remove); + REDIS.stop(); + log.warn("Redis is stopped"); + } + + @Test + void test() { + assertThat(REDIS.isRunning()).isTrue(); + } + +} diff --git a/pom.xml b/pom.xml index d2450e0a65..70a35d77d2 100755 --- a/pom.xml +++ b/pom.xml @@ -2021,6 +2021,18 @@ + + org.testcontainers + junit-jupiter + ${testcontainers.version} + test + + + junit + junit + + + org.zeroturnaround zt-exec From 3a86913e24068f020ed7f274c09bd483b6cd39d5 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Wed, 3 Apr 2024 10:05:01 +0200 Subject: [PATCH 004/442] RedisTbTransactionalCache refactored deprecated methods --- .../cache/RedisTbTransactionalCache.java | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/common/cache/src/main/java/org/thingsboard/server/cache/RedisTbTransactionalCache.java b/common/cache/src/main/java/org/thingsboard/server/cache/RedisTbTransactionalCache.java index abfbb398c9..cd5db13995 100644 --- a/common/cache/src/main/java/org/thingsboard/server/cache/RedisTbTransactionalCache.java +++ b/common/cache/src/main/java/org/thingsboard/server/cache/RedisTbTransactionalCache.java @@ -29,7 +29,6 @@ import org.springframework.data.redis.core.types.Expiration; import org.springframework.data.redis.serializer.RedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; import org.thingsboard.server.common.data.FstStatsService; -import redis.clients.jedis.Connection; import redis.clients.jedis.Jedis; import redis.clients.jedis.JedisPool; import redis.clients.jedis.util.JedisClusterCRC16; @@ -79,7 +78,7 @@ public abstract class RedisTbTransactionalCache get(K key) { try (var connection = connectionFactory.getConnection()) { byte[] rawKey = getRawKey(key); - byte[] rawValue = connection.get(rawKey); + byte[] rawValue = connection.stringCommands().get(rawKey); if (rawValue == null) { return null; } else if (Arrays.equals(rawValue, BINARY_NULL_VALUE)) { @@ -113,7 +112,7 @@ public abstract class RedisTbTransactionalCache(this, connection); } - private RedisConnection getConnection(byte[] rawKey) { + protected RedisConnection getConnection(byte[] rawKey) { if (!connectionFactory.isRedisClusterAware()) { return connectionFactory.getConnection(); } @@ -168,7 +167,7 @@ public abstract class RedisTbTransactionalCache Date: Wed, 3 Apr 2024 11:46:03 +0200 Subject: [PATCH 005/442] WIP CachedRedisSqlTimeseriesLatestDao.java --- .../src/main/resources/thingsboard.yml | 7 ++ build.sh | 57 ++++++++++ .../server/cache/TbJavaRedisSerializer.java | 35 ++++++ .../util/SqlTsLatestAnyDaoCachedRedis.java | 26 +++++ .../server/common/data/CacheConstants.java | 1 + .../CachedRedisSqlTimeseriesLatestDao.java | 104 ++++++++++++++++++ .../dao/timeseries/TsLatestCacheKey.java | 40 +++++++ .../dao/timeseries/TsLatestRedisCache.java | 35 ++++++ .../resources/application-test.properties | 4 + dao/src/test/resources/logback.xml | 1 + 10 files changed, 310 insertions(+) create mode 100755 build.sh create mode 100644 common/cache/src/main/java/org/thingsboard/server/cache/TbJavaRedisSerializer.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/util/SqlTsLatestAnyDaoCachedRedis.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sqlts/CachedRedisSqlTimeseriesLatestDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/timeseries/TsLatestCacheKey.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/timeseries/TsLatestRedisCache.java diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index daf9f5d1c1..535a396762 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -489,6 +489,10 @@ cache: attributes: # make sure that if cache.type is 'redis' and cache.attributes.enabled is 'true' if you change 'maxmemory-policy' Redis config property to 'allkeys-lru', 'allkeys-lfu' or 'allkeys-random' enabled: "${CACHE_ATTRIBUTES_ENABLED:true}" + ts_latest: + # Will enable cache-aside strategy for SQL timeseries latest DAO. + # make sure that if cache.type is 'redis' and cache.ts_latest.enabled is 'true' if you change 'maxmemory-policy' Redis config property to 'allkeys-lru', 'allkeys-lfu' or 'allkeys-random' + enabled: "${CACHE_TS_LATEST_ENABLED:true}" specs: relations: timeToLiveInMinutes: "${CACHE_SPECS_RELATIONS_TTL:1440}" # Relations cache TTL @@ -539,6 +543,9 @@ cache: attributes: timeToLiveInMinutes: "${CACHE_SPECS_ATTRIBUTES_TTL:1440}" # Attributes cache TTL maxSize: "${CACHE_SPECS_ATTRIBUTES_MAX_SIZE:100000}" # 0 means the cache is disabled + tsLatest: + timeToLiveInMinutes: "${CACHE_SPECS_TS_LATEST_TTL:1440}" # Timeseries latest cache TTL + maxSize: "${CACHE_SPECS_TS_LATEST_MAX_SIZE:100000}" # 0 means the cache is disabled userSessionsInvalidation: # The value of this TTL is ignored and replaced by the JWT refresh token expiration time timeToLiveInMinutes: "0" diff --git a/build.sh b/build.sh new file mode 100755 index 0000000000..2c6a7d23fa --- /dev/null +++ b/build.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# +# Copyright ยฉ 2016-2024 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +set -e # exit on any error + +#PROJECTS="msa/tb-node,msa/web-ui,rule-engine-pe/rule-node-twilio-sms" +PROJECTS="" + +if [ "$1" ]; then + PROJECTS="--projects $1" +fi + +echo "Building and pushing [amd64,arm64] projects '$PROJECTS' ..." +echo "HELP: usage ./build.sh [projects]" +echo "HELP: example ./build.sh msa/web-ui,msa/web-report" +java -version +#echo "Cleaning ui-ngx/node_modules" && rm -rf ui-ngx/node_modules + +MAVEN_OPTS="-Xmx1024m" NODE_OPTIONS="--max_old_space_size=4096" DOCKER_CLI_EXPERIMENTAL=enabled DOCKER_BUILDKIT=0 \ +mvn -T2 license:format clean install -DskipTests \ + $PROJECTS --also-make +# \ +# -Dpush-docker-amd-arm-images +# -Ddockerfile.skip=false -Dpush-docker-image=true +# --offline +# --projects '!msa/web-report' --also-make + +# push all +# mvn -T 1C license:format clean install -DskipTests -Ddockerfile.skip=false -Dpush-docker-image=true + + +## Build and push AMD and ARM docker images using docker buildx +## Reference to article how to setup docker miltiplatform build environment: https://medium.com/@artur.klauser/building-multi-architecture-docker-images-with-buildx-27d80f7e2408 +## install docker-ce from docker repo https://docs.docker.com/engine/install/ubuntu/ +# sudo apt install -y qemu-user-static binfmt-support +# export DOCKER_CLI_EXPERIMENTAL=enabled +# docker version +# docker run --rm --privileged multiarch/qemu-user-static --reset -p yes +# docker buildx create --name mybuilder +# docker buildx use mybuilder +# docker buildx inspect --bootstrap +# docker buildx ls +# mvn clean install -P push-docker-amd-arm-images \ No newline at end of file diff --git a/common/cache/src/main/java/org/thingsboard/server/cache/TbJavaRedisSerializer.java b/common/cache/src/main/java/org/thingsboard/server/cache/TbJavaRedisSerializer.java new file mode 100644 index 0000000000..92c9f37900 --- /dev/null +++ b/common/cache/src/main/java/org/thingsboard/server/cache/TbJavaRedisSerializer.java @@ -0,0 +1,35 @@ +/** + * Copyright ยฉ 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.cache; + +import org.springframework.data.redis.serializer.RedisSerializer; +import org.springframework.data.redis.serializer.SerializationException; + +public class TbJavaRedisSerializer implements TbRedisSerializer { + + final RedisSerializer serializer = RedisSerializer.java(); + + @Override + public byte[] serialize(V value) throws SerializationException { + return serializer.serialize(value); + } + + @Override + public V deserialize(K key, byte[] bytes) throws SerializationException { + return (V) serializer.deserialize(bytes); + } + +} diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/util/SqlTsLatestAnyDaoCachedRedis.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/util/SqlTsLatestAnyDaoCachedRedis.java new file mode 100644 index 0000000000..634302a1f3 --- /dev/null +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/util/SqlTsLatestAnyDaoCachedRedis.java @@ -0,0 +1,26 @@ +/** + * Copyright ยฉ 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.util; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@Retention(RetentionPolicy.RUNTIME) +@ConditionalOnExpression("('${database.ts_latest.type}'=='sql' || '${database.ts_latest.type}'=='timescale') && '${cache.ts_latest.enabled:false}'=='true' && '${cache.type:caffeine}'=='redis' ") +public @interface SqlTsLatestAnyDaoCachedRedis { +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java index 9fda1c6bb5..50e02fca0a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java @@ -34,6 +34,7 @@ public class CacheConstants { public static final String ASSET_PROFILE_CACHE = "assetProfiles"; public static final String ATTRIBUTES_CACHE = "attributes"; + public static final String TS_LATEST_CACHE = "tsLatest"; public static final String USERS_SESSION_INVALIDATION_CACHE = "userSessionsInvalidation"; public static final String OTA_PACKAGE_CACHE = "otaPackages"; public static final String OTA_PACKAGE_DATA_CACHE = "otaPackagesData"; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sqlts/CachedRedisSqlTimeseriesLatestDao.java b/dao/src/main/java/org/thingsboard/server/dao/sqlts/CachedRedisSqlTimeseriesLatestDao.java new file mode 100644 index 0000000000..6b32808f7d --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sqlts/CachedRedisSqlTimeseriesLatestDao.java @@ -0,0 +1,104 @@ +/** + * Copyright ยฉ 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.sqlts; + +import com.google.common.util.concurrent.ListenableFuture; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Component; +import org.thingsboard.server.cache.TbTransactionalCache; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.DeleteTsKvQuery; +import org.thingsboard.server.common.data.kv.TsKvEntry; +import org.thingsboard.server.common.data.kv.TsKvLatestRemovingResult; +import org.thingsboard.server.common.stats.DefaultCounter; +import org.thingsboard.server.common.stats.StatsFactory; +import org.thingsboard.server.dao.cache.CacheExecutorService; +import org.thingsboard.server.dao.timeseries.TimeseriesLatestDao; +import org.thingsboard.server.dao.timeseries.TsLatestCacheKey; +import org.thingsboard.server.dao.util.SqlTsLatestAnyDaoCachedRedis; + +import java.util.List; +import java.util.Optional; + +@Slf4j +@Component +@SqlTsLatestAnyDaoCachedRedis +@RequiredArgsConstructor +@Primary +public class CachedRedisSqlTimeseriesLatestDao extends BaseAbstractSqlTimeseriesDao implements TimeseriesLatestDao { + public static final String STATS_NAME = "ts_latest.cache"; + + DefaultCounter hitCounter; + DefaultCounter missCounter; + + final CacheExecutorService cacheExecutorService; + final SqlTimeseriesLatestDao sqlDao; + final StatsFactory statsFactory; + final TbTransactionalCache cache; + + @PostConstruct + public void init() { + log.info("Init Redis cache-aside SQL Timeseries Latest DAO"); + this.hitCounter = statsFactory.createDefaultCounter(STATS_NAME, "result", "hit"); + this.missCounter = statsFactory.createDefaultCounter(STATS_NAME, "result", "miss"); + } + + @Override + public ListenableFuture saveLatest(TenantId tenantId, EntityId entityId, TsKvEntry tsKvEntry) { + return sqlDao.saveLatest(tenantId, entityId, tsKvEntry); + } + + @Override + public ListenableFuture removeLatest(TenantId tenantId, EntityId entityId, DeleteTsKvQuery query) { + return sqlDao.removeLatest(tenantId, entityId, query); + } + + @Override + public ListenableFuture> findLatestOpt(TenantId tenantId, EntityId entityId, String key) { + return sqlDao.findLatestOpt(tenantId, entityId, key); + } + + @Override + public ListenableFuture findLatest(TenantId tenantId, EntityId entityId, String key) { + return sqlDao.findLatest(tenantId, entityId, key); + } + + @Override + public TsKvEntry findLatestSync(TenantId tenantId, EntityId entityId, String key) { + return sqlDao.findLatestSync(tenantId, entityId, key); + } + + @Override + public ListenableFuture> findAllLatest(TenantId tenantId, EntityId entityId) { + return sqlDao.findAllLatest(tenantId, entityId); + } + + @Override + public List findAllKeysByDeviceProfileId(TenantId tenantId, DeviceProfileId deviceProfileId) { + return sqlDao.findAllKeysByDeviceProfileId(tenantId, deviceProfileId); + } + + @Override + public List findAllKeysByEntityIds(TenantId tenantId, List entityIds) { + return sqlDao.findAllKeysByEntityIds(tenantId, entityIds); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/TsLatestCacheKey.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/TsLatestCacheKey.java new file mode 100644 index 0000000000..adb572922a --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/TsLatestCacheKey.java @@ -0,0 +1,40 @@ +/** + * Copyright ยฉ 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.timeseries; + +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.id.EntityId; + +import java.io.Serial; +import java.io.Serializable; + +@EqualsAndHashCode +@Getter +@AllArgsConstructor +public class TsLatestCacheKey implements Serializable { + private static final long serialVersionUID = 2024369077925351881L; + + private final EntityId entityId; + private final String key; + + @Override + public String toString() { + return "{" + entityId + "}" + key; + } +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/TsLatestRedisCache.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/TsLatestRedisCache.java new file mode 100644 index 0000000000..d67e9272e3 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/TsLatestRedisCache.java @@ -0,0 +1,35 @@ +/** + * Copyright ยฉ 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.timeseries; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.stereotype.Service; +import org.thingsboard.server.cache.CacheSpecsMap; +import org.thingsboard.server.cache.RedisTbTransactionalCache; +import org.thingsboard.server.cache.TBRedisCacheConfiguration; +import org.thingsboard.server.cache.TbJavaRedisSerializer; +import org.thingsboard.server.common.data.CacheConstants; +import org.thingsboard.server.common.data.kv.TsKvEntry; + +@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "redis") +@Service("TsLatestCache") +public class TsLatestRedisCache extends RedisTbTransactionalCache { + + public TsLatestRedisCache(TBRedisCacheConfiguration configuration, CacheSpecsMap cacheSpecsMap, RedisConnectionFactory connectionFactory) { + super(CacheConstants.TS_LATEST_CACHE, cacheSpecsMap, connectionFactory, configuration, new TbJavaRedisSerializer<>()); } + +} diff --git a/dao/src/test/resources/application-test.properties b/dao/src/test/resources/application-test.properties index 73c1495af8..c34d0ad592 100644 --- a/dao/src/test/resources/application-test.properties +++ b/dao/src/test/resources/application-test.properties @@ -10,6 +10,7 @@ audit-log.sink.type=none #cache.type=caffeine # will be injected redis by RedisContainer or will be default (caffeine) cache.maximumPoolSize=16 cache.attributes.enabled=true +cache.ts_latest.enabled=true cache.specs.relations.timeToLiveInMinutes=1440 cache.specs.relations.maxSize=100000 @@ -53,6 +54,9 @@ cache.specs.assetProfiles.maxSize=100000 cache.specs.attributes.timeToLiveInMinutes=1440 cache.specs.attributes.maxSize=100000 +cache.specs.tsLatest.timeToLiveInMinutes=1440 +cache.specs.tsLatest.maxSize=100000 + cache.specs.tokensOutdatageTime.timeToLiveInMinutes=1440 cache.specs.tokensOutdatageTime.maxSize=100000 diff --git a/dao/src/test/resources/logback.xml b/dao/src/test/resources/logback.xml index 5e293b2982..8d4951658e 100644 --- a/dao/src/test/resources/logback.xml +++ b/dao/src/test/resources/logback.xml @@ -9,6 +9,7 @@ + From 44096a7cb7059c1f3d847d5af736dbe4c4c4a7d5 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Mon, 8 Apr 2024 14:57:20 +0200 Subject: [PATCH 006/442] TS latest dao put to Redis WIP --- .../cache/RedisTbTransactionalCache.java | 6 ++++- .../CachedRedisSqlTimeseriesLatestDao.java | 22 ++++++++++++++++++- .../dao/timeseries/TsLatestRedisCache.java | 19 ++++++++++++++-- .../{logback.xml => logback-test.xml} | 2 ++ 4 files changed, 45 insertions(+), 4 deletions(-) rename dao/src/test/resources/{logback.xml => logback-test.xml} (87%) diff --git a/common/cache/src/main/java/org/thingsboard/server/cache/RedisTbTransactionalCache.java b/common/cache/src/main/java/org/thingsboard/server/cache/RedisTbTransactionalCache.java index cd5db13995..bb5af26c3e 100644 --- a/common/cache/src/main/java/org/thingsboard/server/cache/RedisTbTransactionalCache.java +++ b/common/cache/src/main/java/org/thingsboard/server/cache/RedisTbTransactionalCache.java @@ -214,8 +214,12 @@ public abstract class RedisTbTransactionalCache saveLatest(TenantId tenantId, EntityId entityId, TsKvEntry tsKvEntry) { - return sqlDao.saveLatest(tenantId, entityId, tsKvEntry); + ListenableFuture future = sqlDao.saveLatest(tenantId, entityId, tsKvEntry); + future = Futures.transform(future, x -> { + cache.put(new TsLatestCacheKey(entityId, tsKvEntry.getKey()), tsKvEntry); + return x; + }, + cacheExecutorService); + Futures.addCallback(future, new FutureCallback() { + @Override + public void onSuccess(Void result) { + log.trace("saveLatest onSuccess [{}][{}][{}]", entityId, tsKvEntry.getKey(), tsKvEntry); + } + + @Override + public void onFailure(Throwable t) { + log.trace("saveLatest onFailure [{}][{}][{}]", entityId, tsKvEntry.getKey(), tsKvEntry, t); + } + }, MoreExecutors.directExecutor()); + return future; } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/TsLatestRedisCache.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/TsLatestRedisCache.java index d67e9272e3..c0bca607c9 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/TsLatestRedisCache.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/TsLatestRedisCache.java @@ -15,8 +15,10 @@ */ package org.thingsboard.server.dao.timeseries; +import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStringCommands; import org.springframework.stereotype.Service; import org.thingsboard.server.cache.CacheSpecsMap; import org.thingsboard.server.cache.RedisTbTransactionalCache; @@ -25,11 +27,24 @@ import org.thingsboard.server.cache.TbJavaRedisSerializer; import org.thingsboard.server.common.data.CacheConstants; import org.thingsboard.server.common.data.kv.TsKvEntry; +import java.io.Serializable; + @ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "redis") @Service("TsLatestCache") -public class TsLatestRedisCache extends RedisTbTransactionalCache { +@Slf4j +public class TsLatestRedisCache extends RedisTbTransactionalCache { public TsLatestRedisCache(TBRedisCacheConfiguration configuration, CacheSpecsMap cacheSpecsMap, RedisConnectionFactory connectionFactory) { - super(CacheConstants.TS_LATEST_CACHE, cacheSpecsMap, connectionFactory, configuration, new TbJavaRedisSerializer<>()); } + super(CacheConstants.TS_LATEST_CACHE, cacheSpecsMap, connectionFactory, configuration, new TbJavaRedisSerializer<>()); + } + + @Override + public void put(TsLatestCacheKey key, TsKvEntry value) { + log.trace("put [{}][{}]", key, value); + final byte[] rawKey = getRawKey(key); + try (var connection = getConnection(rawKey)) { + put(connection, rawKey, value, RedisStringCommands.SetOption.UPSERT); + } + } } diff --git a/dao/src/test/resources/logback.xml b/dao/src/test/resources/logback-test.xml similarity index 87% rename from dao/src/test/resources/logback.xml rename to dao/src/test/resources/logback-test.xml index 8d4951658e..d7cff4b67b 100644 --- a/dao/src/test/resources/logback.xml +++ b/dao/src/test/resources/logback-test.xml @@ -10,6 +10,8 @@ + + From 3003fccf3b12f4f390b6e7860acbe1b5fc43db3b Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Tue, 16 Apr 2024 16:43:20 +0200 Subject: [PATCH 007/442] BaseTimeseriesServiceTest refactored --- .../timeseries/BaseTimeseriesServiceTest.java | 31 +++---------------- 1 file changed, 4 insertions(+), 27 deletions(-) diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java index 390cb9afac..58b28872be 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java @@ -17,6 +17,7 @@ package org.thingsboard.server.dao.service.timeseries; import com.datastax.oss.driver.api.core.uuid.Uuids; import lombok.extern.slf4j.Slf4j; +import org.assertj.core.data.Offset; import org.junit.After; import org.junit.Assert; import org.junit.Before; @@ -32,6 +33,7 @@ import org.thingsboard.server.common.data.kv.BaseDeleteTsKvQuery; import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery; import org.thingsboard.server.common.data.kv.BasicTsKvEntry; import org.thingsboard.server.common.data.kv.BooleanDataEntry; +import org.thingsboard.server.common.data.kv.DataType; import org.thingsboard.server.common.data.kv.DoubleDataEntry; import org.thingsboard.server.common.data.kv.JsonDataEntry; import org.thingsboard.server.common.data.kv.KvEntry; @@ -50,15 +52,14 @@ import java.util.Arrays; import java.util.Collections; import java.util.Comparator; import java.util.List; +import java.util.Optional; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; /** * @author Andrew Shvayka @@ -89,6 +90,7 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { KvEntry booleanKvEntry = new BooleanDataEntry(BOOLEAN_KEY, Boolean.TRUE); protected TenantId tenantId; + DeviceId deviceId = new DeviceId(Uuids.timeBased()); @Before public void before() { @@ -106,8 +108,6 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { @Test public void testFindAllLatest() throws Exception { - DeviceId deviceId = new DeviceId(Uuids.timeBased()); - saveEntries(deviceId, TS - 2); saveEntries(deviceId, TS - 1); saveEntries(deviceId, TS); @@ -150,8 +150,6 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { @Test public void testFindLatest() throws Exception { - DeviceId deviceId = new DeviceId(Uuids.timeBased()); - saveEntries(deviceId, TS - 2); saveEntries(deviceId, TS - 1); saveEntries(deviceId, TS); @@ -163,8 +161,6 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { @Test public void testFindLatestWithoutLatestUpdate() throws Exception { - DeviceId deviceId = new DeviceId(Uuids.timeBased()); - saveEntries(deviceId, TS - 2); saveEntries(deviceId, TS - 1); saveEntriesWithoutLatest(deviceId, TS); @@ -176,8 +172,6 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { @Test public void testFindByQueryAscOrder() throws Exception { - DeviceId deviceId = new DeviceId(Uuids.timeBased()); - saveEntries(deviceId, TS - 3); saveEntries(deviceId, TS - 2); saveEntries(deviceId, TS - 1); @@ -202,7 +196,6 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { @Test public void testFindByQuery_whenPeriodEqualsOneMilisecondPeriod() throws Exception { - DeviceId deviceId = new DeviceId(Uuids.timeBased()); saveEntries(deviceId, TS - 1L); saveEntries(deviceId, TS); saveEntries(deviceId, TS + 1L); @@ -222,7 +215,6 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { @Test public void testFindByQuery_whenPeriodEqualsInterval() throws Exception { - DeviceId deviceId = new DeviceId(Uuids.timeBased()); saveEntries(deviceId, TS - 1L); for (long i = TS; i <= TS + 100L; i += 10L) { saveEntries(deviceId, i); @@ -244,7 +236,6 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { @Test public void testFindByQuery_whenPeriodHaveTwoIntervalWithEqualsLength() throws Exception { - DeviceId deviceId = new DeviceId(Uuids.timeBased()); saveEntries(deviceId, TS - 1L); for (long i = TS; i <= TS + 100000L; i += 10000L) { saveEntries(deviceId, i); @@ -268,7 +259,6 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { @Test public void testFindByQuery_whenPeriodHaveTwoInterval_whereSecondShorterThanFirst() throws Exception { - DeviceId deviceId = new DeviceId(Uuids.timeBased()); saveEntries(deviceId, TS - 1L); for (long i = TS; i <= TS + 80000L; i += 10000L) { saveEntries(deviceId, i); @@ -292,7 +282,6 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { @Test public void testFindByQuery_whenPeriodHaveTwoIntervalWithEqualsLength_whereNotAllEntriesInRange() throws Exception { - DeviceId deviceId = new DeviceId(Uuids.timeBased()); for (long i = TS - 1L; i <= TS + 100000L + 1L; i += 10000) { saveEntries(deviceId, i); } @@ -314,7 +303,6 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { @Test public void testFindByQuery_whenPeriodHaveTwoInterval_whereSecondShorterThanFirst_andNotAllEntriesInRange() throws Exception { - DeviceId deviceId = new DeviceId(Uuids.timeBased()); for (long i = TS - 1L; i <= TS + 100000L + 1L; i += 10000L) { saveEntries(deviceId, i); } @@ -336,8 +324,6 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { @Test public void testFindByQueryDescOrder() throws Exception { - DeviceId deviceId = new DeviceId(Uuids.timeBased()); - saveEntries(deviceId, TS - 3); saveEntries(deviceId, TS - 2); saveEntries(deviceId, TS - 1); @@ -362,7 +348,6 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { @Test public void testFindAllByQueries_verifyQueryId() throws Exception { - DeviceId deviceId = new DeviceId(Uuids.timeBased()); saveEntries(deviceId, TS); saveEntries(deviceId, TS - 2); saveEntries(deviceId, TS - 10); @@ -373,7 +358,6 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { @Test public void testFindAllByQueries_verifyQueryId_forEntityView() throws Exception { - DeviceId deviceId = new DeviceId(Uuids.timeBased()); saveEntries(deviceId, TS); saveEntries(deviceId, TS - 2); saveEntries(deviceId, TS - 12); @@ -392,8 +376,6 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { @Test public void testDeleteDeviceTsDataWithOverwritingLatest() throws Exception { - DeviceId deviceId = new DeviceId(Uuids.timeBased()); - saveEntries(deviceId, 10000); saveEntries(deviceId, 20000); saveEntries(deviceId, 30000); @@ -412,7 +394,6 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { @Test public void testFindDeviceTsData() throws Exception { - DeviceId deviceId = new DeviceId(Uuids.timeBased()); List entries = new ArrayList<>(); entries.add(save(deviceId, 5000, 100)); @@ -563,7 +544,6 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { @Test public void testFindDeviceLongAndDoubleTsData() throws Exception { - DeviceId deviceId = new DeviceId(Uuids.timeBased()); List entries = new ArrayList<>(); entries.add(save(deviceId, 5000, 100)); @@ -654,8 +634,6 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { @Test public void testSaveTs_RemoveTs_AndSaveTsAgain() throws Exception { - DeviceId deviceId = new DeviceId(Uuids.timeBased()); - save(deviceId, 2000000L, 95); save(deviceId, 4000000L, 100); save(deviceId, 6000000L, 105); @@ -686,7 +664,6 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { BasicTsKvEntry jsonEntry = new BasicTsKvEntry(TimeUnit.MINUTES.toMillis(5), new JsonDataEntry("test", "{\"test\":\"testValue\"}")); List timeseries = List.of(booleanEntry, stringEntry, longEntry, doubleEntry, jsonEntry); - DeviceId deviceId = new DeviceId(Uuids.timeBased()); for (TsKvEntry tsKvEntry : timeseries) { save(tenantId, deviceId, tsKvEntry); } From 771d15a6ddc71d1e95511c17c3b03ad006e750c3 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Tue, 16 Apr 2024 16:43:47 +0200 Subject: [PATCH 008/442] BaseTimeseriesServiceTest: 3 findLatest related tests added --- .../timeseries/BaseTimeseriesServiceTest.java | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java index 58b28872be..83db90ad71 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java @@ -159,6 +159,36 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { Assert.assertEquals(toTsEntry(TS, stringKvEntry), entries.get(0)); } + @Test + public void testFindLatestOpt() throws Exception { + saveEntries(deviceId, TS - 2); + saveEntries(deviceId, TS - 1); + saveEntries(deviceId, TS); + + Optional entryOpt = tsService.findLatest(tenantId, deviceId, STRING_KEY).get(MAX_TIMEOUT, TimeUnit.SECONDS); + assertThat(entryOpt).isNotNull().isPresent(); + Assert.assertEquals(toTsEntry(TS, stringKvEntry), entryOpt.get()); + } + + @Test + public void testFindLatest_NotFound() throws Exception { + List entries = tsService.findLatest(tenantId, deviceId, Collections.singleton(STRING_KEY)).get(MAX_TIMEOUT, TimeUnit.SECONDS); + assertThat(entries).hasSize(1); + TsKvEntry tsKvEntry = entries.get(0); + assertThat(tsKvEntry).isNotNull(); + // null ts latest representation + assertThat(tsKvEntry.getKey()).isEqualTo(STRING_KEY); + assertThat(tsKvEntry.getDataType()).isEqualTo(DataType.STRING); + assertThat(tsKvEntry.getValue()).isNull(); + assertThat(tsKvEntry.getTs()).isCloseTo(System.currentTimeMillis(), Offset.offset(TimeUnit.MINUTES.toMillis(1))); + } + + @Test + public void testFindLatestOpt_NotFound() throws Exception { + Optional entryOpt = tsService.findLatest(tenantId, deviceId, STRING_KEY).get(MAX_TIMEOUT, TimeUnit.SECONDS); + assertThat(entryOpt).isNotNull().isNotPresent(); + } + @Test public void testFindLatestWithoutLatestUpdate() throws Exception { saveEntries(deviceId, TS - 2); From 034b480a6ca641f9b2670c146b0a78e8190c72be Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Tue, 16 Apr 2024 16:47:27 +0200 Subject: [PATCH 009/442] RedisTbTransactionalCache refactored to make doGet using the rawKey and the Redis connection precalculated slot based on hash --- .../thingsboard/server/cache/RedisTbTransactionalCache.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/common/cache/src/main/java/org/thingsboard/server/cache/RedisTbTransactionalCache.java b/common/cache/src/main/java/org/thingsboard/server/cache/RedisTbTransactionalCache.java index bb5af26c3e..35ef07f0ce 100644 --- a/common/cache/src/main/java/org/thingsboard/server/cache/RedisTbTransactionalCache.java +++ b/common/cache/src/main/java/org/thingsboard/server/cache/RedisTbTransactionalCache.java @@ -78,7 +78,7 @@ public abstract class RedisTbTransactionalCache get(K key) { try (var connection = connectionFactory.getConnection()) { byte[] rawKey = getRawKey(key); - byte[] rawValue = connection.stringCommands().get(rawKey); + byte[] rawValue = doGet(connection, rawKey); if (rawValue == null) { return null; } else if (Arrays.equals(rawValue, BINARY_NULL_VALUE)) { @@ -95,6 +95,10 @@ public abstract class RedisTbTransactionalCache Date: Tue, 16 Apr 2024 16:50:36 +0200 Subject: [PATCH 010/442] SqlTimeseriesLatestDao refactored to use a common doFindLatestSync for any find request --- .../server/dao/sqlts/SqlTimeseriesLatestDao.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/sqlts/SqlTimeseriesLatestDao.java b/dao/src/main/java/org/thingsboard/server/dao/sqlts/SqlTimeseriesLatestDao.java index a9fb85560a..0f773e83d9 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sqlts/SqlTimeseriesLatestDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sqlts/SqlTimeseriesLatestDao.java @@ -155,17 +155,18 @@ public class SqlTimeseriesLatestDao extends BaseAbstractSqlTimeseriesDao impleme @Override public ListenableFuture> findLatestOpt(TenantId tenantId, EntityId entityId, String key) { - return service.submit(() -> Optional.ofNullable(doFindLatest(entityId, key))); + return service.submit(() -> Optional.ofNullable(doFindLatestSync(entityId, key))); } @Override public ListenableFuture findLatest(TenantId tenantId, EntityId entityId, String key) { - return service.submit(() -> getLatestTsKvEntry(entityId, key)); + log.trace("findLatest [{}][{}][{}]", tenantId, entityId, key); + return service.submit(() -> wrapNullTsKvEntry(key, doFindLatestSync(entityId, key))); } @Override public TsKvEntry findLatestSync(TenantId tenantId, EntityId entityId, String key) { - return getLatestTsKvEntry(entityId, key); + return wrapNullTsKvEntry(key, doFindLatestSync(entityId, key)); } @Override @@ -209,7 +210,7 @@ public class SqlTimeseriesLatestDao extends BaseAbstractSqlTimeseriesDao impleme ReadTsKvQueryResult::getData, MoreExecutors.directExecutor()); } - protected TsKvEntry doFindLatest(EntityId entityId, String key) { + protected TsKvEntry doFindLatestSync(EntityId entityId, String key) { TsKvLatestCompositeKey compositeKey = new TsKvLatestCompositeKey( entityId.getId(), @@ -225,7 +226,7 @@ public class SqlTimeseriesLatestDao extends BaseAbstractSqlTimeseriesDao impleme } protected ListenableFuture getRemoveLatestFuture(TenantId tenantId, EntityId entityId, DeleteTsKvQuery query) { - ListenableFuture latestFuture = service.submit(() -> doFindLatest(entityId, query.getKey())); + ListenableFuture latestFuture = service.submit(() -> doFindLatestSync(entityId, query.getKey())); return Futures.transformAsync(latestFuture, latest -> { if (latest == null) { return Futures.immediateFuture(new TsKvLatestRemovingResult(query.getKey(), false)); @@ -266,10 +267,9 @@ public class SqlTimeseriesLatestDao extends BaseAbstractSqlTimeseriesDao impleme return tsLatestQueue.add(latestEntity); } - private TsKvEntry getLatestTsKvEntry(EntityId entityId, String key) { - TsKvEntry latest = doFindLatest(entityId, key); + protected TsKvEntry wrapNullTsKvEntry(final String key, final TsKvEntry latest) { if (latest == null) { - latest = new BasicTsKvEntry(System.currentTimeMillis(), new StringDataEntry(key, null)); + return new BasicTsKvEntry(System.currentTimeMillis(), new StringDataEntry(key, null)); } return latest; } From 551325b8c7afe8afa58ed2586f6d3e6258b0894e Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Tue, 16 Apr 2024 16:52:31 +0200 Subject: [PATCH 011/442] added CachedRedisSqlTimeseriesLatestDao and TsLatestRedisCache (prototype impl with string commands). Caffeine cache not implemented yet --- .../CachedRedisSqlTimeseriesLatestDao.java | 99 +++++++++++++++---- .../dao/timeseries/TsLatestRedisCache.java | 53 ++++++++++ 2 files changed, 134 insertions(+), 18 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/sqlts/CachedRedisSqlTimeseriesLatestDao.java b/dao/src/main/java/org/thingsboard/server/dao/sqlts/CachedRedisSqlTimeseriesLatestDao.java index b283832186..482b456e60 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sqlts/CachedRedisSqlTimeseriesLatestDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sqlts/CachedRedisSqlTimeseriesLatestDao.java @@ -24,6 +24,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Primary; import org.springframework.stereotype.Component; +import org.thingsboard.server.cache.TbCacheValueWrapper; import org.thingsboard.server.cache.TbTransactionalCache; import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.EntityId; @@ -48,14 +49,12 @@ import java.util.Optional; @Primary public class CachedRedisSqlTimeseriesLatestDao extends BaseAbstractSqlTimeseriesDao implements TimeseriesLatestDao { public static final String STATS_NAME = "ts_latest.cache"; - - DefaultCounter hitCounter; - DefaultCounter missCounter; - final CacheExecutorService cacheExecutorService; final SqlTimeseriesLatestDao sqlDao; final StatsFactory statsFactory; final TbTransactionalCache cache; + DefaultCounter hitCounter; + DefaultCounter missCounter; @PostConstruct public void init() { @@ -72,37 +71,101 @@ public class CachedRedisSqlTimeseriesLatestDao extends BaseAbstractSqlTimeseries return x; }, cacheExecutorService); - Futures.addCallback(future, new FutureCallback() { - @Override - public void onSuccess(Void result) { - log.trace("saveLatest onSuccess [{}][{}][{}]", entityId, tsKvEntry.getKey(), tsKvEntry); - } - - @Override - public void onFailure(Throwable t) { - log.trace("saveLatest onFailure [{}][{}][{}]", entityId, tsKvEntry.getKey(), tsKvEntry, t); - } - }, MoreExecutors.directExecutor()); + if (log.isTraceEnabled()) { + Futures.addCallback(future, new FutureCallback<>() { + @Override + public void onSuccess(Void result) { + log.trace("saveLatest onSuccess [{}][{}][{}]", entityId, tsKvEntry.getKey(), tsKvEntry); + } + + @Override + public void onFailure(Throwable t) { + log.info("saveLatest onFailure [{}][{}][{}]", entityId, tsKvEntry.getKey(), tsKvEntry, t); + } + }, MoreExecutors.directExecutor()); + } return future; } @Override public ListenableFuture removeLatest(TenantId tenantId, EntityId entityId, DeleteTsKvQuery query) { - return sqlDao.removeLatest(tenantId, entityId, query); + ListenableFuture future = sqlDao.removeLatest(tenantId, entityId, query); + future = Futures.transform(future, x -> { + cache.evict(new TsLatestCacheKey(entityId, query.getKey())); + return x; + }, + cacheExecutorService); + if (log.isTraceEnabled()) { + Futures.addCallback(future, new FutureCallback<>() { + @Override + public void onSuccess(TsKvLatestRemovingResult result) { + log.trace("removeLatest onSuccess [{}][{}][{}]", entityId, query.getKey(), query); + } + + @Override + public void onFailure(Throwable t) { + log.info("removeLatest onFailure [{}][{}][{}]", entityId, query.getKey(), query, t); + } + }, MoreExecutors.directExecutor()); + } + return future; } @Override public ListenableFuture> findLatestOpt(TenantId tenantId, EntityId entityId, String key) { - return sqlDao.findLatestOpt(tenantId, entityId, key); + log.trace("findLatestOpt"); + return doFindLatest(tenantId, entityId, key); } @Override public ListenableFuture findLatest(TenantId tenantId, EntityId entityId, String key) { - return sqlDao.findLatest(tenantId, entityId, key); + return Futures.transform(doFindLatest(tenantId, entityId, key), x -> sqlDao.wrapNullTsKvEntry(key, x.orElse(null)), MoreExecutors.directExecutor()); + } + + public ListenableFuture> doFindLatest(TenantId tenantId, EntityId entityId, String key) { + final TsLatestCacheKey cacheKey = new TsLatestCacheKey(entityId, key); + ListenableFuture> cacheFuture = cacheExecutorService.submit(() -> cache.get(cacheKey)); + + return Futures.transformAsync(cacheFuture, (cacheValueWrap) -> { + if (cacheValueWrap != null) { + final TsKvEntry tsKvEntry = cacheValueWrap.get(); + log.debug("findLatest cache hit [{}][{}][{}]", entityId, key, tsKvEntry); + return Futures.immediateFuture(Optional.ofNullable(tsKvEntry)); + } + log.debug("findLatest cache miss [{}][{}]", entityId, key); + ListenableFuture> daoFuture = sqlDao.findLatestOpt(tenantId,entityId, key); + + return Futures.transformAsync(daoFuture, (daoValue) -> { + + if (daoValue.isEmpty()) { + //TODO implement the cache logic if no latest found in TS DAO. Currently we are always getting from DB to stay on the safe side + return Futures.immediateFuture(daoValue); + } + ListenableFuture> cachePutFuture = cacheExecutorService.submit(() -> { + cache.put(new TsLatestCacheKey(entityId, key), daoValue.get()); + return daoValue; + }); + + Futures.addCallback(cachePutFuture, new FutureCallback<>() { + @Override + public void onSuccess(Optional result) { + log.trace("saveLatest onSuccess [{}][{}][{}]", entityId, key, result); + } + + @Override + public void onFailure(Throwable t) { + log.info("saveLatest onFailure [{}][{}][{}]", entityId, key, daoValue, t); + } + + }, MoreExecutors.directExecutor()); + return cachePutFuture; + }, MoreExecutors.directExecutor()); + }, MoreExecutors.directExecutor()); } @Override public TsKvEntry findLatestSync(TenantId tenantId, EntityId entityId, String key) { + log.trace("findLatestSync DEPRECATED [{}][{}]", entityId, key); return sqlDao.findLatestSync(tenantId, entityId, key); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/TsLatestRedisCache.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/TsLatestRedisCache.java index c0bca607c9..61d2c8bdb2 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/TsLatestRedisCache.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/TsLatestRedisCache.java @@ -16,18 +16,24 @@ package org.thingsboard.server.dao.timeseries; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.NotImplementedException; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.connection.RedisConnection; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.connection.RedisStringCommands; import org.springframework.stereotype.Service; import org.thingsboard.server.cache.CacheSpecsMap; import org.thingsboard.server.cache.RedisTbTransactionalCache; import org.thingsboard.server.cache.TBRedisCacheConfiguration; +import org.thingsboard.server.cache.TbCacheTransaction; +import org.thingsboard.server.cache.TbCacheValueWrapper; import org.thingsboard.server.cache.TbJavaRedisSerializer; import org.thingsboard.server.common.data.CacheConstants; import org.thingsboard.server.common.data.kv.TsKvEntry; import java.io.Serializable; +import java.util.Collection; +import java.util.List; @ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "redis") @Service("TsLatestCache") @@ -47,4 +53,51 @@ public class TsLatestRedisCache } } + @Override + public void putIfAbsent(TsLatestCacheKey key, TsKvEntry value) { + log.trace("putIfAbsent [{}][{}]", key, value); + throw new NotImplementedException("putIfAbsent is not supported by TsLatestRedisCache"); + } + + @Override + public TbCacheValueWrapper get(TsLatestCacheKey key) { + log.debug("get [{}]", key); + return super.get(key); + } + + @Override + protected byte[] doGet(RedisConnection connection, byte[] rawKey) { + log.trace("doGet [{}][{}]", connection, rawKey); + return connection.stringCommands().get(rawKey); + } + + @Override + public void evict(TsLatestCacheKey key){ + log.trace("evict [{}]", key); + final byte[] rawKey = getRawKey(key); + try (var connection = getConnection(rawKey)) { + connection.keyCommands().del(rawKey); + } + } + + @Override + public void evict(Collection keys) { + throw new NotImplementedException("evict by many keys is not supported by TsLatestRedisCache"); + } + + @Override + public void evictOrPut(TsLatestCacheKey key, TsKvEntry value) { + throw new NotImplementedException("evictOrPut is not supported by TsLatestRedisCache"); + } + + @Override + public TbCacheTransaction newTransactionForKey(TsLatestCacheKey key) { + throw new NotImplementedException("newTransactionForKey is not supported by TsLatestRedisCache"); + } + + @Override + public TbCacheTransaction newTransactionForKeys(List keys) { + throw new NotImplementedException("newTransactionForKeys is not supported by TsLatestRedisCache"); + } + } From c49d97f7d19d69883a789b2ac2b687f6c34f322a Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Tue, 16 Apr 2024 17:35:04 +0200 Subject: [PATCH 012/442] BaseTimeseriesServiceTest added overwrite TS cases and historical data load case, that fails without condition on update only if ts is the latest --- .../timeseries/BaseTimeseriesServiceTest.java | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java index 83db90ad71..d32933684a 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java @@ -159,6 +159,40 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { Assert.assertEquals(toTsEntry(TS, stringKvEntry), entries.get(0)); } + @Test + public void testFindLatestOpt_givenSaveWithHistoricalNonOrderedTS() throws Exception { + save(tenantId, deviceId, toTsEntry(TS - 1, stringKvEntry)); + save(tenantId, deviceId, toTsEntry(TS, stringKvEntry)); + save(tenantId, deviceId, toTsEntry(TS - 10, stringKvEntry)); + save(tenantId, deviceId, toTsEntry(TS - 11, stringKvEntry)); + + Optional entryOpt = tsService.findLatest(tenantId, deviceId, STRING_KEY).get(MAX_TIMEOUT, TimeUnit.SECONDS); + assertThat(entryOpt).isNotNull().isPresent(); + Assert.assertEquals(toTsEntry(TS, stringKvEntry), entryOpt.orElse(null)); + } + + @Test + public void testFindLatestOpt_givenSaveWithSameTSOverwriteValue() throws Exception { + save(tenantId, deviceId, toTsEntry(TS, new StringDataEntry(STRING_KEY, "old"))); + save(tenantId, deviceId, toTsEntry(TS, new StringDataEntry(STRING_KEY, "new"))); + + Optional entryOpt = tsService.findLatest(tenantId, deviceId, STRING_KEY).get(MAX_TIMEOUT, TimeUnit.SECONDS); + assertThat(entryOpt).isNotNull().isPresent(); + Assert.assertEquals(toTsEntry(TS, new StringDataEntry(STRING_KEY, "new")), entryOpt.orElse(null)); + } + + public void testFindLatestOpt_givenSaveWithSameTSOverwriteTypeAndValue() throws Exception { + save(tenantId, deviceId, toTsEntry(TS, new JsonDataEntry("temp", "{\"hello\":\"world\"}"))); + save(tenantId, deviceId, toTsEntry(TS, new BooleanDataEntry("temp", true))); + save(tenantId, deviceId, toTsEntry(TS, new LongDataEntry("temp", 100L))); + save(tenantId, deviceId, toTsEntry(TS, new DoubleDataEntry("temp", Math.PI))); + save(tenantId, deviceId, toTsEntry(TS, new StringDataEntry("temp", "NOOP"))); + + Optional entryOpt = tsService.findLatest(tenantId, deviceId, STRING_KEY).get(MAX_TIMEOUT, TimeUnit.SECONDS); + assertThat(entryOpt).isNotNull().isPresent(); + Assert.assertEquals(toTsEntry(TS, new StringDataEntry("temp", "NOOP")), entryOpt.orElse(null)); + } + @Test public void testFindLatestOpt() throws Exception { saveEntries(deviceId, TS - 2); From a4469f69539ead14b1c926ce103cfdb704741823 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Wed, 17 Apr 2024 12:14:42 +0200 Subject: [PATCH 013/442] TsLatestRedisCache: added LUA upsert script. load script, eval, evalsha, test for script sha, fetch latest by zRange Redis command --- .../cache/RedisTbTransactionalCache.java | 1 + .../dao/timeseries/TsLatestRedisCache.java | 75 +++++++++++++++---- .../timeseries/TsLatestRedisCacheTest.java | 30 ++++++++ 3 files changed, 91 insertions(+), 15 deletions(-) create mode 100644 dao/src/test/java/org/thingsboard/server/dao/timeseries/TsLatestRedisCacheTest.java diff --git a/common/cache/src/main/java/org/thingsboard/server/cache/RedisTbTransactionalCache.java b/common/cache/src/main/java/org/thingsboard/server/cache/RedisTbTransactionalCache.java index 35ef07f0ce..4277a7bab9 100644 --- a/common/cache/src/main/java/org/thingsboard/server/cache/RedisTbTransactionalCache.java +++ b/common/cache/src/main/java/org/thingsboard/server/cache/RedisTbTransactionalCache.java @@ -51,6 +51,7 @@ public abstract class RedisTbTransactionalCache keySerializer = StringRedisSerializer.UTF_8; private final TbRedisSerializer valueSerializer; diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/TsLatestRedisCache.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/TsLatestRedisCache.java index 61d2c8bdb2..e9f9ab522c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/TsLatestRedisCache.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/TsLatestRedisCache.java @@ -15,12 +15,15 @@ */ package org.thingsboard.server.dao.timeseries; +import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.NotImplementedException; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.dao.InvalidDataAccessApiUsageException; import org.springframework.data.redis.connection.RedisConnection; import org.springframework.data.redis.connection.RedisConnectionFactory; -import org.springframework.data.redis.connection.RedisStringCommands; +import org.springframework.data.redis.connection.ReturnType; +import org.springframework.data.redis.serializer.StringRedisSerializer; import org.springframework.stereotype.Service; import org.thingsboard.server.cache.CacheSpecsMap; import org.thingsboard.server.cache.RedisTbTransactionalCache; @@ -32,33 +35,42 @@ import org.thingsboard.server.common.data.CacheConstants; import org.thingsboard.server.common.data.kv.TsKvEntry; import java.io.Serializable; +import java.util.Arrays; import java.util.Collection; import java.util.List; +import java.util.Set; @ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "redis") @Service("TsLatestCache") @Slf4j public class TsLatestRedisCache extends RedisTbTransactionalCache { + static final byte[] UPSERT_TS_LATEST_LUA_SCRIPT = StringRedisSerializer.UTF_8.serialize("" + + "redis.call('ZREMRANGEBYSCORE', KEYS[1], ARGV[1], ARGV[1]); " + + "redis.call('ZADD', KEYS[1], ARGV[1], ARGV[2]); " + + "local current_size = redis.call('ZCARD', KEYS[1]); " + + "if current_size > 1 then" + + " redis.call('ZREMRANGEBYRANK', KEYS[1], 0, -2) " + + "end;"); + static final byte[] UPSERT_TS_LATEST_SHA = StringRedisSerializer.UTF_8.serialize("24e226c3ea34e3e850113e8eb1f3cd2b88171988"); + public TsLatestRedisCache(TBRedisCacheConfiguration configuration, CacheSpecsMap cacheSpecsMap, RedisConnectionFactory connectionFactory) { super(CacheConstants.TS_LATEST_CACHE, cacheSpecsMap, connectionFactory, configuration, new TbJavaRedisSerializer<>()); } - @Override - public void put(TsLatestCacheKey key, TsKvEntry value) { - log.trace("put [{}][{}]", key, value); - final byte[] rawKey = getRawKey(key); - try (var connection = getConnection(rawKey)) { - put(connection, rawKey, value, RedisStringCommands.SetOption.UPSERT); + @PostConstruct + public void init() { + try (var connection = getConnection(UPSERT_TS_LATEST_SHA)) { + log.debug("Loading LUA with expected SHA[{}], connection [{}]", new String(UPSERT_TS_LATEST_SHA), connection.getNativeConnection()); + String sha = connection.scriptingCommands().scriptLoad(UPSERT_TS_LATEST_LUA_SCRIPT); + if (!Arrays.equals(UPSERT_TS_LATEST_SHA, StringRedisSerializer.UTF_8.serialize(sha))) { + log.error("SHA for UPSERT_TS_LATEST_LUA_SCRIPT wrong! Expected [{}], but actual [{}], connection [{}]", new String(UPSERT_TS_LATEST_SHA), sha, connection.getNativeConnection()); + } + } catch (Throwable t) { + log.error("Error on Redis TS Latest cache init", t); } } - @Override - public void putIfAbsent(TsLatestCacheKey key, TsKvEntry value) { - log.trace("putIfAbsent [{}][{}]", key, value); - throw new NotImplementedException("putIfAbsent is not supported by TsLatestRedisCache"); - } - @Override public TbCacheValueWrapper get(TsLatestCacheKey key) { log.debug("get [{}]", key); @@ -68,11 +80,38 @@ public class TsLatestRedisCache @Override protected byte[] doGet(RedisConnection connection, byte[] rawKey) { log.trace("doGet [{}][{}]", connection, rawKey); - return connection.stringCommands().get(rawKey); + Set values = connection.commands().zRange(rawKey, -1, -1); + return values == null ? null : values.stream().findFirst().orElse(null); } @Override - public void evict(TsLatestCacheKey key){ + public void put(TsLatestCacheKey key, TsKvEntry value) { + log.trace("put [{}][{}]", key, value); + final byte[] rawKey = getRawKey(key); + try (var connection = getConnection(rawKey)) { + byte[] rawValue = getRawValue(value); + byte[] ts = StringRedisSerializer.UTF_8.serialize(String.valueOf(value.toTsValue().getTs())); + try { + connection.scriptingCommands().evalSha(UPSERT_TS_LATEST_SHA, ReturnType.VALUE, 1, rawKey, ts, rawValue); + } catch (InvalidDataAccessApiUsageException e) { + log.debug("loading LUA [{}]", connection.getNativeConnection()); + String sha = connection.scriptingCommands().scriptLoad(UPSERT_TS_LATEST_LUA_SCRIPT); + if (!Arrays.equals(UPSERT_TS_LATEST_SHA, StringRedisSerializer.UTF_8.serialize(sha))) { + log.error("SHA for UPSERT_TS_LATEST_LUA_SCRIPT wrong! Expected [{}], but actual [{}]", new String(UPSERT_TS_LATEST_SHA), sha); + } + try { + connection.scriptingCommands().evalSha(UPSERT_TS_LATEST_SHA, ReturnType.VALUE, 1, rawKey, ts, rawValue); + } catch (InvalidDataAccessApiUsageException ignored) { + log.debug("Slowly executing eval instead of fast evalsha"); + connection.scriptingCommands().eval(UPSERT_TS_LATEST_LUA_SCRIPT, ReturnType.VALUE, 1, rawKey, ts, rawValue); + } + + } + } + } + + @Override + public void evict(TsLatestCacheKey key) { log.trace("evict [{}]", key); final byte[] rawKey = getRawKey(key); try (var connection = getConnection(rawKey)) { @@ -80,6 +119,12 @@ public class TsLatestRedisCache } } + @Override + public void putIfAbsent(TsLatestCacheKey key, TsKvEntry value) { + log.trace("putIfAbsent [{}][{}]", key, value); + throw new NotImplementedException("putIfAbsent is not supported by TsLatestRedisCache"); + } + @Override public void evict(Collection keys) { throw new NotImplementedException("evict by many keys is not supported by TsLatestRedisCache"); diff --git a/dao/src/test/java/org/thingsboard/server/dao/timeseries/TsLatestRedisCacheTest.java b/dao/src/test/java/org/thingsboard/server/dao/timeseries/TsLatestRedisCacheTest.java new file mode 100644 index 0000000000..5de26c15d3 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/timeseries/TsLatestRedisCacheTest.java @@ -0,0 +1,30 @@ +package org.thingsboard.server.dao.timeseries; + +import lombok.SneakyThrows; +import org.junit.jupiter.api.Test; + +import java.security.MessageDigest; + +import static org.assertj.core.api.Assertions.assertThat; + +class TsLatestRedisCacheTest { + + @Test + void testUpsertTsLatestLUAScriptHash() { + assertThat(getSHA1(TsLatestRedisCache.UPSERT_TS_LATEST_LUA_SCRIPT)).isEqualTo(new String(TsLatestRedisCache.UPSERT_TS_LATEST_SHA)); + } + + @SneakyThrows + String getSHA1(byte[] script) { + MessageDigest md = MessageDigest.getInstance("SHA-1"); + byte[] hash = md.digest(script); + + StringBuilder sb = new StringBuilder(); + for (byte b : hash) { + sb.append(String.format("%02x", b)); + } + + return sb.toString(); + } + +} \ No newline at end of file From 67c2392cccea99861bb1f869d4dce55f58a81f3a Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Wed, 17 Apr 2024 12:15:22 +0200 Subject: [PATCH 014/442] RedisClusterSqlTestSuite introduced --- .../dao/AbstractRedisClusterContainer.java | 92 +++++++++++++++++++ .../server/dao/RedisClusterSqlTestSuite.java | 31 +++++++ 2 files changed, 123 insertions(+) create mode 100644 dao/src/test/java/org/thingsboard/server/dao/AbstractRedisClusterContainer.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/RedisClusterSqlTestSuite.java diff --git a/dao/src/test/java/org/thingsboard/server/dao/AbstractRedisClusterContainer.java b/dao/src/test/java/org/thingsboard/server/dao/AbstractRedisClusterContainer.java new file mode 100644 index 0000000000..cfa4c00ddf --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/AbstractRedisClusterContainer.java @@ -0,0 +1,92 @@ +/** + * Copyright ยฉ 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao; + +import lombok.extern.slf4j.Slf4j; +import org.junit.ClassRule; +import org.junit.rules.ExternalResource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.Network; +import org.testcontainers.containers.output.OutputFrame; +import redis.clients.jedis.Jedis; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +@Slf4j +public class AbstractRedisClusterContainer { + + static final String nodes = "127.0.0.1:6371,127.0.0.1:6372,127.0.0.1:6373,127.0.0.1:6374,127.0.0.1:6375,127.0.0.1:6376"; + + @ClassRule(order = 0) + public static Network network = Network.newNetwork(); + @ClassRule(order = 1) + public static GenericContainer redis1 = new GenericContainer("bitnami/redis-cluster:latest").withEnv("REDIS_PORT_NUMBER","6371").withNetworkMode("host").withLogConsumer(x->log.warn("{}", ((OutputFrame)x).getUtf8StringWithoutLineEnding())).withEnv("ALLOW_EMPTY_PASSWORD","yes").withEnv("REDIS_NODES",nodes); + @ClassRule(order = 2) + public static GenericContainer redis2 = new GenericContainer("bitnami/redis-cluster:latest").withEnv("REDIS_PORT_NUMBER","6372").withNetworkMode("host").withLogConsumer(x->log.warn("{}", ((OutputFrame)x).getUtf8StringWithoutLineEnding())).withEnv("ALLOW_EMPTY_PASSWORD","yes").withEnv("REDIS_NODES",nodes); + @ClassRule(order = 3) + public static GenericContainer redis3 = new GenericContainer("bitnami/redis-cluster:latest").withEnv("REDIS_PORT_NUMBER","6373").withNetworkMode("host").withLogConsumer(x->log.warn("{}", ((OutputFrame)x).getUtf8StringWithoutLineEnding())).withEnv("ALLOW_EMPTY_PASSWORD","yes").withEnv("REDIS_NODES",nodes); + @ClassRule(order = 4) + public static GenericContainer redis4 = new GenericContainer("bitnami/redis-cluster:latest").withEnv("REDIS_PORT_NUMBER","6374").withNetworkMode("host").withLogConsumer(x->log.warn("{}", ((OutputFrame)x).getUtf8StringWithoutLineEnding())).withEnv("ALLOW_EMPTY_PASSWORD","yes").withEnv("REDIS_NODES",nodes); + @ClassRule(order = 5) + public static GenericContainer redis5 = new GenericContainer("bitnami/redis-cluster:latest").withEnv("REDIS_PORT_NUMBER","6375").withNetworkMode("host").withLogConsumer(x->log.warn("{}", ((OutputFrame)x).getUtf8StringWithoutLineEnding())).withEnv("ALLOW_EMPTY_PASSWORD","yes").withEnv("REDIS_NODES",nodes); + @ClassRule(order = 6) + public static GenericContainer redis6 = new GenericContainer("bitnami/redis-cluster:latest").withEnv("REDIS_PORT_NUMBER","6376").withNetworkMode("host").withLogConsumer(x->log.warn("{}", ((OutputFrame)x).getUtf8StringWithoutLineEnding())).withEnv("ALLOW_EMPTY_PASSWORD","yes").withEnv("REDIS_NODES",nodes); + + + @ClassRule(order = 100) + public static ExternalResource resource = new ExternalResource() { + @Override + protected void before() throws Throwable { + redis1.start(); + redis2.start(); + redis3.start(); + redis4.start(); + redis5.start(); + redis6.start(); + + String clusterCreateCommand = "echo yes | redis-cli --cluster create " + + "127.0.0.1:6371 127.0.0.1:6372 127.0.0.1:6373 127.0.0.1:6374 127.0.0.1:6375 127.0.0.1:6376 " + + "--cluster-replicas 1"; + + log.warn("Command to init Redis Cluster: {}", clusterCreateCommand); + log.warn("Connect to nodes : {}", nodes); + Thread.sleep(TimeUnit.SECONDS.toMillis(5)); + var result = redis6.execInContainer("/bin/sh", "-c", clusterCreateCommand); + log.warn("Init cluster result: {}", result); + + System.setProperty("cache.type", "redis"); + System.setProperty("redis.connection.type", "cluster"); + System.setProperty("redis.cluster.nodes", nodes); + System.setProperty("redis.password", ""); + + System.setProperty("redis.cluster.useDefaultPoolConfig", "false"); + } + + @Override + protected void after() { + redis1.stop(); + redis2.stop(); + redis3.stop(); + redis4.stop(); + redis5.stop(); + redis6.stop(); + List.of("cache.type", "redis.connection.type", "redis.standalone.host", "redis.standalone.port") + .forEach(System.getProperties()::remove); + } + }; + +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/RedisClusterSqlTestSuite.java b/dao/src/test/java/org/thingsboard/server/dao/RedisClusterSqlTestSuite.java new file mode 100644 index 0000000000..be0bcc6fc7 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/RedisClusterSqlTestSuite.java @@ -0,0 +1,31 @@ +/** + * Copyright ยฉ 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao; + +import org.junit.extensions.cpsuite.ClasspathSuite; +import org.junit.extensions.cpsuite.ClasspathSuite.ClassnameFilters; +import org.junit.runner.RunWith; + +@RunWith(ClasspathSuite.class) +@ClassnameFilters( + //All the same tests using redis instead of caffeine. + { + "org.thingsboard.server.dao.service.*ServiceSqlTest", + } +) +public class RedisClusterSqlTestSuite extends AbstractRedisClusterContainer { + +} From 743b8b1d801d751fd9fc55e31f5014853a670c5a Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Wed, 17 Apr 2024 12:16:31 +0200 Subject: [PATCH 015/442] logback-test temporary added TRACE for SqlTimeseriesLatestDao and CachedRedisSqlTimeseriesLatestDao --- dao/src/test/resources/logback-test.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dao/src/test/resources/logback-test.xml b/dao/src/test/resources/logback-test.xml index d7cff4b67b..87aa67a67e 100644 --- a/dao/src/test/resources/logback-test.xml +++ b/dao/src/test/resources/logback-test.xml @@ -10,6 +10,8 @@ + + From 168525d3b9e00636e979b36b75538ffb5ea41853 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Wed, 17 Apr 2024 12:24:16 +0200 Subject: [PATCH 016/442] mvn license:format --- .../dao/timeseries/TsLatestRedisCacheTest.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/dao/src/test/java/org/thingsboard/server/dao/timeseries/TsLatestRedisCacheTest.java b/dao/src/test/java/org/thingsboard/server/dao/timeseries/TsLatestRedisCacheTest.java index 5de26c15d3..c463b4630f 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/timeseries/TsLatestRedisCacheTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/timeseries/TsLatestRedisCacheTest.java @@ -1,3 +1,18 @@ +/** + * Copyright ยฉ 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.thingsboard.server.dao.timeseries; import lombok.SneakyThrows; From 92793360f304687cd48f8dbf5a95834efcda53a1 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Wed, 17 Apr 2024 12:39:54 +0200 Subject: [PATCH 017/442] CachedRedisSqlTimeseriesLatestDao.java removed deprecated findLatestSync --- .../server/dao/sqlts/CachedRedisSqlTimeseriesLatestDao.java | 6 ------ 1 file changed, 6 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/sqlts/CachedRedisSqlTimeseriesLatestDao.java b/dao/src/main/java/org/thingsboard/server/dao/sqlts/CachedRedisSqlTimeseriesLatestDao.java index 482b456e60..32d0ee91c9 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sqlts/CachedRedisSqlTimeseriesLatestDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sqlts/CachedRedisSqlTimeseriesLatestDao.java @@ -163,12 +163,6 @@ public class CachedRedisSqlTimeseriesLatestDao extends BaseAbstractSqlTimeseries }, MoreExecutors.directExecutor()); } - @Override - public TsKvEntry findLatestSync(TenantId tenantId, EntityId entityId, String key) { - log.trace("findLatestSync DEPRECATED [{}][{}]", entityId, key); - return sqlDao.findLatestSync(tenantId, entityId, key); - } - @Override public ListenableFuture> findAllLatest(TenantId tenantId, EntityId entityId) { return sqlDao.findAllLatest(tenantId, entityId); From 20ec210c320648a190d39ad40a78c08a6d4ce172 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Wed, 17 Apr 2024 12:43:14 +0200 Subject: [PATCH 018/442] AbstractRedisClusterContainer refactored --- .../server/dao/AbstractRedisClusterContainer.java | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/dao/src/test/java/org/thingsboard/server/dao/AbstractRedisClusterContainer.java b/dao/src/test/java/org/thingsboard/server/dao/AbstractRedisClusterContainer.java index cfa4c00ddf..a95f4b8c17 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/AbstractRedisClusterContainer.java +++ b/dao/src/test/java/org/thingsboard/server/dao/AbstractRedisClusterContainer.java @@ -58,21 +58,19 @@ public class AbstractRedisClusterContainer { redis5.start(); redis6.start(); + Thread.sleep(TimeUnit.SECONDS.toMillis(5)); // otherwise not all containers have time to start + String clusterCreateCommand = "echo yes | redis-cli --cluster create " + "127.0.0.1:6371 127.0.0.1:6372 127.0.0.1:6373 127.0.0.1:6374 127.0.0.1:6375 127.0.0.1:6376 " + "--cluster-replicas 1"; - log.warn("Command to init Redis Cluster: {}", clusterCreateCommand); - log.warn("Connect to nodes : {}", nodes); - Thread.sleep(TimeUnit.SECONDS.toMillis(5)); var result = redis6.execInContainer("/bin/sh", "-c", clusterCreateCommand); log.warn("Init cluster result: {}", result); + log.warn("Connect to nodes: {}", nodes); System.setProperty("cache.type", "redis"); System.setProperty("redis.connection.type", "cluster"); System.setProperty("redis.cluster.nodes", nodes); - System.setProperty("redis.password", ""); - System.setProperty("redis.cluster.useDefaultPoolConfig", "false"); } @@ -84,7 +82,7 @@ public class AbstractRedisClusterContainer { redis4.stop(); redis5.stop(); redis6.stop(); - List.of("cache.type", "redis.connection.type", "redis.standalone.host", "redis.standalone.port") + List.of("cache.type", "redis.connection.type", "redis.cluster.nodes", "redis.cluster.useDefaultPoolConfig\"") .forEach(System.getProperties()::remove); } }; From eb50048c5001b61f81c400cf6af97fcff9b16223 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 2 May 2024 19:44:47 +0300 Subject: [PATCH 019/442] UI: Scada widget test --- .../basic/basic-widget-config.module.ts | 12 +- .../scada-test-basic-config.component.html | 26 ++ .../scada-test-basic-config.component.ts | 64 +++ .../scada/scada-test-widget.component.html | 19 + .../scada/scada-test-widget.component.scss | 22 + .../lib/scada/scada-test-widget.component.ts | 102 +++++ .../lib/scada/scada-test-widget.models.ts | 21 + .../widget/lib/scada/scada.models.ts | 398 ++++++++++++++++++ .../scada-object-settings.component.html | 48 +++ .../scada/scada-object-settings.component.ts | 168 ++++++++ .../common/widget-settings-common.module.ts | 5 + .../widget/widget-components.module.ts | 7 +- ui-ngx/src/assets/widget/scada/drawing.svg | 149 +++++++ 13 files changed, 1036 insertions(+), 5 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/components/widget/config/basic/scada/scada-test-basic-config.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/config/basic/scada/scada-test-basic-config.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-test-widget.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-test-widget.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-test-widget.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-test-widget.models.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/scada/scada.models.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.component.ts create mode 100644 ui-ngx/src/assets/widget/scada/drawing.svg diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts index 18b80f86a5..79af9bee18 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts @@ -129,6 +129,9 @@ import { import { RadarChartBasicConfigComponent } from '@home/components/widget/config/basic/chart/radar-chart-basic-config.component'; +import { + ScadaTestBasicConfigComponent +} from '@home/components/widget/config/basic/scada/scada-test-basic-config.component'; @NgModule({ declarations: [ @@ -171,7 +174,8 @@ import { PieChartBasicConfigComponent, BarChartBasicConfigComponent, PolarAreaChartBasicConfigComponent, - RadarChartBasicConfigComponent + RadarChartBasicConfigComponent, + ScadaTestBasicConfigComponent ], imports: [ CommonModule, @@ -216,7 +220,8 @@ import { PieChartBasicConfigComponent, BarChartBasicConfigComponent, PolarAreaChartBasicConfigComponent, - RadarChartBasicConfigComponent + RadarChartBasicConfigComponent, + ScadaTestBasicConfigComponent ] }) export class BasicWidgetConfigModule { @@ -255,5 +260,6 @@ export const basicWidgetConfigComponentsMap: {[key: string]: Type + + + + + diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/scada/scada-test-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/scada/scada-test-basic-config.component.ts new file mode 100644 index 0000000000..3d6a4a6a45 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/scada/scada-test-basic-config.component.ts @@ -0,0 +1,64 @@ +/// +/// Copyright ยฉ 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { BasicWidgetConfigComponent } from '@home/components/widget/config/widget-config.component.models'; +import { WidgetConfigComponentData } from '@home/models/widget-component.models'; +import { TargetDevice, } from '@shared/models/widget.models'; +import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; +import { ScadaTestWidgetSettings } from '@home/components/widget/lib/scada/scada-test-widget.models'; + +@Component({ + selector: 'tb-scada-test-basic-config', + templateUrl: './scada-test-basic-config.component.html', + styleUrls: ['../basic-config.scss'] +}) +export class ScadaTestBasicConfigComponent extends BasicWidgetConfigComponent { + + get targetDevice(): TargetDevice { + return this.scadaTestWidgetConfigForm.get('targetDevice').value; + } + + scadaTestWidgetConfigForm: UntypedFormGroup; + + constructor(protected store: Store, + protected widgetConfigComponent: WidgetConfigComponent, + private fb: UntypedFormBuilder) { + super(store, widgetConfigComponent); + } + + protected configForm(): UntypedFormGroup { + return this.scadaTestWidgetConfigForm; + } + + protected onConfigSet(configData: WidgetConfigComponentData) { + const settings: ScadaTestWidgetSettings = {...(configData.config.settings as ScadaTestWidgetSettings || { scadaObject: {}})}; + this.scadaTestWidgetConfigForm = this.fb.group({ + targetDevice: [configData.config.targetDevice, []], + scadaObject: [settings.scadaObject, []] + }); + } + + protected prepareOutputConfig(config: any): WidgetConfigComponentData { + this.widgetConfig.config.targetDevice = config.targetDevice; + this.widgetConfig.config.settings = this.widgetConfig.config.settings || {}; + this.widgetConfig.config.settings.scadaObject = config.scadaObject; + return this.widgetConfig; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-test-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-test-widget.component.html new file mode 100644 index 0000000000..f811e7f889 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-test-widget.component.html @@ -0,0 +1,19 @@ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-test-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-test-widget.component.scss new file mode 100644 index 0000000000..5012fe3a7c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-test-widget.component.scss @@ -0,0 +1,22 @@ +/** + * Copyright ยฉ 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +.tb-scada-shape { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-test-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-test-widget.component.ts new file mode 100644 index 0000000000..25451dcf15 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-test-widget.component.ts @@ -0,0 +1,102 @@ +/// +/// Copyright ยฉ 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + AfterViewInit, + ChangeDetectorRef, + Component, + ElementRef, + OnDestroy, + OnInit, + Renderer2, + ViewChild, + ViewEncapsulation +} from '@angular/core'; +import { BasicActionWidgetComponent } from '@home/components/widget/lib/action/action-widget.models'; +import { ImagePipe } from '@shared/pipe/image.pipe'; +import { DomSanitizer } from '@angular/platform-browser'; +import { SVG, Svg } from '@svgdotjs/svg.js'; +import { HttpClient } from '@angular/common/http'; +import { ScadaObject, ScadaObjectSettings } from '@home/components/widget/lib/scada/scada.models'; +import { ResizeObserver } from '@juggle/resize-observer'; +import { ScadaTestWidgetSettings } from '@home/components/widget/lib/scada/scada-test-widget.models'; + +@Component({ + selector: 'tb-scada-test-widget', + templateUrl: './scada-test-widget.component.html', + styleUrls: ['../action/action-widget.scss', './scada-test-widget.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class ScadaTestWidgetComponent extends + BasicActionWidgetComponent implements OnInit, AfterViewInit, OnDestroy { + + @ViewChild('scadaShape', {static: false}) + scadaShape: ElementRef; + + private settings: ScadaTestWidgetSettings; + + private autoScale = true; + private scadaObject: ScadaObject; + + private shapeResize$: ResizeObserver; + + constructor(protected imagePipe: ImagePipe, + protected sanitizer: DomSanitizer, + private renderer: Renderer2, + protected cd: ChangeDetectorRef, + private http: HttpClient) { + super(cd); + } + + ngOnInit(): void { + super.ngOnInit(); + this.settings = {...this.ctx.settings}; + this.scadaObject = new ScadaObject(this.ctx, '/assets/widget/scada/drawing.svg', this.settings.scadaObject); + this.scadaObject.init().subscribe(); + } + + ngAfterViewInit(): void { + this.scadaObject.addTo(this.scadaShape.nativeElement); + if (this.autoScale) { + this.shapeResize$ = new ResizeObserver(() => { + this.onResize(); + }); + this.shapeResize$.observe(this.scadaShape.nativeElement); + this.onResize(); + } + super.ngAfterViewInit(); + } + + ngOnDestroy() { + if (this.shapeResize$) { + this.shapeResize$.disconnect(); + } + if (this.scadaObject) { + this.scadaObject.destroy(); + } + super.ngOnDestroy(); + } + + public onInit() { + super.onInit(); + } + + private onResize() { + const shapeWidth = this.scadaShape.nativeElement.getBoundingClientRect().width; + const shapeHeight = this.scadaShape.nativeElement.getBoundingClientRect().height; + this.scadaObject.setSize(shapeWidth, shapeHeight); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-test-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-test-widget.models.ts new file mode 100644 index 0000000000..a74d81e3d5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-test-widget.models.ts @@ -0,0 +1,21 @@ +/// +/// Copyright ยฉ 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { ScadaObjectSettings } from '@home/components/widget/lib/scada/scada.models'; + +export interface ScadaTestWidgetSettings { + scadaObject: ScadaObjectSettings; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada.models.ts new file mode 100644 index 0000000000..7088fe6131 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada.models.ts @@ -0,0 +1,398 @@ +/// +/// Copyright ยฉ 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { ValueType } from '@shared/models/constants'; +import { Box, Element, Runner, Svg, SVG, Timeline } from '@svgdotjs/svg.js'; +import { DataToValueType, GetValueAction, GetValueSettings } from '@shared/models/action-widget-settings.models'; +import { insertVariable, isDefinedAndNotNull, isNumber, isNumeric, isUndefinedOrNull, mergeDeep } from '@core/utils'; +import { BehaviorSubject, forkJoin, Observable } from 'rxjs'; +import { map, share } from 'rxjs/operators'; +import { ValueAction, ValueGetter } from '@home/components/widget/lib/action/action-widget.models'; +import { WidgetContext } from '@home/models/widget-component.models'; + +export type ValueMatcherType = 'any' | 'constant' | 'range'; + +export interface ValueMatcher { + type: ValueMatcherType; + value?: any; + range?: {from: number; to: number}; +} + +export type ScadaObjectAttributeValueType = 'input' | 'property'; + +export interface ScadaObjectAttributeValue { + type: ScadaObjectAttributeValueType; + propertyId?: string; +} + +export interface ScadaObjectAttributeState { + name: string; + value: ScadaObjectAttributeValue; +} + +export interface ScadaObjectState { + tag: string; + attributes: ScadaObjectAttributeState[]; + animate?: number; +} + +export interface ScadaObjectUpdateState { + matcher: ValueMatcher; + state: ScadaObjectState[]; +} + +export enum ScadaObjectBehaviorType { + setValue = 'setValue', + getValue = 'getValue' +} + +export interface ScadaObjectBehaviorBase { + id: string; + name: string; + type: ScadaObjectBehaviorType; +} + +export interface ScadaObjectBehaviorGet extends ScadaObjectBehaviorBase { + valueType: ValueType; + defaultValue: any; + onUpdate: ScadaObjectUpdateState[]; +} + +export interface ScadaObjectBehaviorSet extends ScadaObjectBehaviorBase { + todo: any; +} + +export type ScadaObjectBehavior = ScadaObjectBehaviorGet | ScadaObjectBehaviorSet; + +export type ScadaObjectPropertyType = 'string' | 'number' | 'color'; + +export interface ScadaObjectPropertyBase { + id: string; + name: string; + type: ScadaObjectPropertyType; + default: any; +} + +export interface ScadaObjectNumberProperty extends ScadaObjectPropertyBase { + min?: number; + max?: number; +} + +export type ScadaObjectProperty = ScadaObjectPropertyBase & ScadaObjectNumberProperty; + +export interface ScadaObjectMetadata { + title: string; + initial: ScadaObjectState[]; + behavior: ScadaObjectBehavior[]; + properties: ScadaObjectProperty[]; +} + +export const emptyMetadata: ScadaObjectMetadata = { + title: '', + initial: [], + behavior: [], + properties: [] +}; + + +export const parseScadaObjectMetadataFromContent = (svgContent: string): ScadaObjectMetadata => { + try { + const svgDoc = new DOMParser().parseFromString(svgContent, 'image/svg+xml'); + return parseScadaObjectMetadataFromDom(svgDoc); + } catch (_e) { + return emptyMetadata; + } +}; + +const parseScadaObjectMetadataFromDom = (svgDoc: Document): ScadaObjectMetadata => { + try { + const elements = svgDoc.getElementsByTagName('tb:metadata'); + if (elements.length) { + return JSON.parse(elements[0].innerHTML); + } else { + return emptyMetadata; + } + } catch (_e) { + return emptyMetadata; + } +}; + +const defaultGetValueSettings = (get: ScadaObjectBehaviorGet): GetValueSettings => ({ + action: GetValueAction.DO_NOTHING, + defaultValue: get.defaultValue, + executeRpc: { + method: 'getState', + requestTimeout: 5000, + requestPersistent: false, + persistentPollingInterval: 1000 + }, + getAttribute: { + key: 'state', + scope: null + }, + getTimeSeries: { + key: 'state' + }, + dataToValue: { + type: DataToValueType.NONE, + compareToValue: true, + dataToValueFunction: '/* Should return boolean value */\nreturn data;' + } + }); + +export const defaultScadaObjectSettings = (metadata: ScadaObjectMetadata): ScadaObjectSettings => { + const settings: ScadaObjectSettings = {}; + for (const behaviour of metadata.behavior) { + //behaviour.id + if (behaviour.type === ScadaObjectBehaviorType.getValue) { + settings[behaviour.id] = defaultGetValueSettings(behaviour as ScadaObjectBehaviorGet); + } else if (behaviour.type === ScadaObjectBehaviorType.setValue) { + // TODO: + } + } + for (const property of metadata.properties) { + settings[property.id] = property.default; + } + return settings; +}; + +export type ScadaObjectSettings = {[id: string]: any}; + +export class ScadaObject { + + private metadata: ScadaObjectMetadata; + private settings: ScadaObjectSettings; + + private rootElement: HTMLElement; + private svgShape: Svg; + private box: Box; + private targetWidth: number; + private targetHeight: number; + + private loadingSubject = new BehaviorSubject(false); + private valueGetters: ValueGetter[] = []; + private valueActions: ValueAction[] = []; + + private animationTimeline: Timeline; + + loading$ = this.loadingSubject.asObservable().pipe(share()); + + constructor(private ctx: WidgetContext, + private svgPath: string, + private inputSettings: ScadaObjectSettings) {} + + public init(): Observable { + return this.ctx.http.get(this.svgPath, {responseType: 'text'}).pipe( + map((inputSvgContent) => { + const doc: XMLDocument = new DOMParser().parseFromString(inputSvgContent, 'image/svg+xml'); + this.metadata = parseScadaObjectMetadataFromDom(doc); + const defaults = defaultScadaObjectSettings(this.metadata); + this.settings = mergeDeep({}, defaults, this.inputSettings || {}); + this.prepareSvgShape(doc); + this.prepareStates(); + }) + ); + } + + public addTo(element: HTMLElement) { + this.rootElement = element; + if (this.svgShape) { + this.svgShape.addTo(element); + } + } + + public destroy() { + this.valueActions.forEach(v => v.destroy()); + this.loadingSubject.complete(); + this.loadingSubject.unsubscribe(); + } + + public setSize(targetWidth: number, targetHeight: number) { + this.targetWidth = targetWidth; + this.targetHeight = targetHeight; + if (this.svgShape) { + this.resize(); + } + } + + private prepareSvgShape(doc: XMLDocument) { + const elements = doc.getElementsByTagName('tb:metadata'); + for (let i=0;i = this.settings[getBehavior.id]; + getValueSettings = {...getValueSettings, actionLabel: getBehavior.name}; + const valueGetter = + ValueGetter.fromSettings(this.ctx, getValueSettings, getBehavior.valueType, { + next: (val) => {this.onValue(getBehavior.id, val);}, + error: (e) => {} + }); + this.valueGetters.push(valueGetter); + this.valueActions.push(valueGetter); + } + } + if (this.metadata.initial) { + this.updateState(this.metadata.initial); + } + if (this.valueGetters.length) { + const getValueObservables: Array> = []; + this.valueGetters.forEach(valueGetter => { + getValueObservables.push(valueGetter.getValue()); + }); + this.loadingSubject.next(true); + forkJoin(getValueObservables).subscribe( + { + next: () => { + this.loadingSubject.next(false); + }, + error: () => { + this.loadingSubject.next(false); + } + } + ); + } + } + + private resize() { + let scale: number; + if (this.targetWidth < this.targetHeight) { + scale = this.targetWidth / this.box.width; + } else { + scale = this.targetHeight / this.box.height; + } + this.svgShape.node.style.transform = `scale(${scale})`; + } + + private onValue(id: string, value: any) { + const getBehavior = this.metadata.behavior.find(b => b.id === id) as ScadaObjectBehaviorGet; + value = this.normalizeValue(value, getBehavior.valueType); + const updateStates = this.filterUpdateStates(getBehavior.onUpdate, value); + if (this.animationTimeline) { + this.animationTimeline.finish(); + } + for (const updateState of updateStates) { + this.updateState(updateState.state, value); + } + } + + private updateState(state: ScadaObjectState[], value?: any) { + for (const stateEntry of state) { + const tag = stateEntry.tag; + const elements = this.svgShape.find(`[tb\\:tag="${tag}"]`); + const attrs = this.computeAttributes(stateEntry.attributes, value); + elements.forEach(e => { + this.setElementAttributes(e, attrs, stateEntry.animate); + }); + } + } + + private normalizeValue(value: any, type: ValueType): any { + if (isUndefinedOrNull(value)) { + switch (type) { + case ValueType.STRING: + return ''; + case ValueType.INTEGER: + case ValueType.DOUBLE: + return 0; + case ValueType.BOOLEAN: + return false; + case ValueType.JSON: + return {}; + } + } else { + return value; + } + } + + private computeAttributes(attributes: ScadaObjectAttributeState[], value: any): {[attr: string]: any} { + const res: {[attr: string]: any} = {}; + for (const attribute of attributes) { + const attr = attribute.name; + res[attr] = this.getAttributeValue(attribute, value); + } + return res; + } + + private setElementAttributes(element: Element, attrs: {[attr: string]: any}, animate?: number) { + if (isDefinedAndNotNull(animate)) { + this.animation(element, animate).attr(attrs); + } else { + element.attr(attrs); + } + } + + private animation(element: Element, duration: number): Runner { + if (!this.animationTimeline) { + this.animationTimeline = new Timeline(); + } + element.timeline(this.animationTimeline); + return element.animate(duration, 0, 'now'); + } + + private getAttributeValue(attribute: ScadaObjectAttributeState, value: any): any { + if (attribute.value.type === 'input') { + return value; + } else if (attribute.value.type === 'property') { + const id = attribute.value.propertyId; + return this.settings[id] || ''; + } else { + return ''; + } + } + + private filterUpdateStates(states: ScadaObjectUpdateState[], val: any): ScadaObjectUpdateState[] { + return states.filter(s => this.valueMatches(s.matcher, val)); + } + + private valueMatches(matcher: ValueMatcher, val: any): boolean { + switch (matcher.type) { + case 'any': + return true; + case 'constant': + return matcher.value === val; + case 'range': + if (isDefinedAndNotNull(val) && isNumeric(val)) { + const num = Number(val); + const range = matcher.range; + return ((!isNumber(range.from) || num >= range.from) && (!isNumber(range.to) || num < range.to)); + } else { + return false; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.component.html new file mode 100644 index 0000000000..d5a8cba6df --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.component.html @@ -0,0 +1,48 @@ + + +
{{ metadata?.title }}
+
+
widgets.slider.behavior
+
+
{{ behaviour.name }}
+ + +
+
+
+
+
{{ property.name }}
+ + + + + +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.component.ts new file mode 100644 index 0000000000..ad10d681a7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.component.ts @@ -0,0 +1,168 @@ +/// +/// Copyright ยฉ 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, forwardRef, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; +import { + ControlValueAccessor, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormGroup, + ValidatorFn, + Validators +} from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { + defaultScadaObjectSettings, + parseScadaObjectMetadataFromContent, + ScadaObjectBehaviorType, + ScadaObjectMetadata, + ScadaObjectSettings +} from '@home/components/widget/lib/scada/scada.models'; +import { HttpClient } from '@angular/common/http'; +import { ValueType } from '@shared/models/constants'; +import { IAliasController } from '@core/api/widget-api.models'; +import { TargetDevice, widgetType } from '@shared/models/widget.models'; +import { isDefinedAndNotNull } from '@core/utils'; + +@Component({ + selector: 'tb-scada-object-settings', + templateUrl: './scada-object-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ScadaObjectSettingsComponent), + multi: true + } + ] +}) +export class ScadaObjectSettingsComponent implements OnInit, OnChanges, ControlValueAccessor { + + ScadaObjectBehaviorType = ScadaObjectBehaviorType; + + @Input() + disabled: boolean; + + @Input() + svgPath = '/assets/widget/scada/drawing.svg'; + + @Input() + aliasController: IAliasController; + + @Input() + targetDevice: TargetDevice; + + @Input() + widgetType: widgetType; + + private modelValue: ScadaObjectSettings; + + private propagateChange = null; + + public scadaObjectSettingsFormGroup: UntypedFormGroup; + + metadata: ScadaObjectMetadata; + + constructor(protected store: Store, + private fb: UntypedFormBuilder, + private http: HttpClient) { + } + + ngOnInit(): void { + this.scadaObjectSettingsFormGroup = this.fb.group({}); + this.scadaObjectSettingsFormGroup.valueChanges.subscribe(() => { + this.updateModel(); + }); + this.loadMetadata(); + } + + ngOnChanges(changes: SimpleChanges): void { + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (!change.firstChange && change.currentValue !== change.previousValue) { + if (['svgPath'].includes(propName)) { + this.loadMetadata(); + } + } + } + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.scadaObjectSettingsFormGroup.disable({emitEvent: false}); + } else { + this.scadaObjectSettingsFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: ScadaObjectSettings): void { + this.modelValue = value || {}; + this.setupValue(); + } + + private loadMetadata() { + this.http.get(this.svgPath, {responseType: 'text'}).subscribe( + (svgContent) => { + this.metadata = parseScadaObjectMetadataFromContent(svgContent); + for (const control of Object.keys(this.scadaObjectSettingsFormGroup.controls)) { + this.scadaObjectSettingsFormGroup.removeControl(control, {emitEvent: false}); + } + for (const behaviour of this.metadata.behavior) { + this.scadaObjectSettingsFormGroup.addControl(behaviour.id, this.fb.control(null, []), {emitEvent: false}); + } + for (const property of this.metadata.properties) { + const validators: ValidatorFn[] = []; + if (property.type === 'number') { + if (isDefinedAndNotNull(property.min)) { + validators.push(Validators.min(property.min)); + } + if (isDefinedAndNotNull(property.max)) { + validators.push(Validators.max(property.max)); + } + } + this.scadaObjectSettingsFormGroup.addControl(property.id, this.fb.control(null, validators), {emitEvent: false}); + } + this.setupValue(); + } + ); + } + + private setupValue() { + if (this.metadata) { + const defaults = defaultScadaObjectSettings(this.metadata); + this.modelValue = {...defaults, ...this.modelValue}; + this.scadaObjectSettingsFormGroup.patchValue( + this.modelValue, {emitEvent: false} + ); + } + } + + private updateModel() { + this.modelValue = this.scadaObjectSettingsFormGroup.getRawValue(); + this.propagateChange(this.modelValue); + } + + protected readonly ValueType = ValueType; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts index bbe3dfb20c..152cf25b35 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts @@ -149,6 +149,9 @@ import { StatusWidgetStateSettingsComponent } from '@home/components/widget/lib/settings/common/indicator/status-widget-state-settings.component'; import { ChartBarSettingsComponent } from '@home/components/widget/lib/settings/common/chart/chart-bar-settings.component'; +import { + ScadaObjectSettingsComponent +} from '@home/components/widget/lib/settings/common/scada/scada-object-settings.component'; @NgModule({ declarations: [ @@ -204,6 +207,7 @@ import { ChartBarSettingsComponent } from '@home/components/widget/lib/settings/ TimeSeriesChartStateRowComponent, TimeSeriesChartGridSettingsComponent, StatusWidgetStateSettingsComponent, + ScadaObjectSettingsComponent, DataKeyInputComponent, EntityAliasInputComponent ], @@ -265,6 +269,7 @@ import { ChartBarSettingsComponent } from '@home/components/widget/lib/settings/ TimeSeriesChartStateRowComponent, TimeSeriesChartGridSettingsComponent, StatusWidgetStateSettingsComponent, + ScadaObjectSettingsComponent, DataKeyInputComponent, EntityAliasInputComponent ], diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts index 69185b6dd2..f062494763 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts @@ -92,6 +92,7 @@ import { PieChartWidgetComponent } from '@home/components/widget/lib/chart/pie-c import { BarChartWidgetComponent } from '@home/components/widget/lib/chart/bar-chart-widget.component'; import { PolarAreaWidgetComponent } from '@home/components/widget/lib/chart/polar-area-widget.component'; import { RadarChartWidgetComponent } from '@home/components/widget/lib/chart/radar-chart-widget.component'; +import { ScadaTestWidgetComponent } from '@home/components/widget/lib/scada/scada-test-widget.component'; @NgModule({ declarations: @@ -150,7 +151,8 @@ import { RadarChartWidgetComponent } from '@home/components/widget/lib/chart/rad PieChartWidgetComponent, BarChartWidgetComponent, PolarAreaWidgetComponent, - RadarChartWidgetComponent + RadarChartWidgetComponent, + ScadaTestWidgetComponent ], imports: [ CommonModule, @@ -212,7 +214,8 @@ import { RadarChartWidgetComponent } from '@home/components/widget/lib/chart/rad PieChartWidgetComponent, BarChartWidgetComponent, PolarAreaWidgetComponent, - RadarChartWidgetComponent + RadarChartWidgetComponent, + ScadaTestWidgetComponent ], providers: [ {provide: WIDGET_COMPONENTS_MODULE_TOKEN, useValue: WidgetComponentsModule } diff --git a/ui-ngx/src/assets/widget/scada/drawing.svg b/ui-ngx/src/assets/widget/scada/drawing.svg new file mode 100644 index 0000000000..972ca550cd --- /dev/null +++ b/ui-ngx/src/assets/widget/scada/drawing.svg @@ -0,0 +1,149 @@ + + + { + "title": "My first SCADA Object", + "initial": [ + { + "tag": "RECT", + "attributes": [ + { + "name": "fill", + "value": { + "type": "property", + "propertyId": "background" + } + }, + { + "name": "stroke", + "value": { + "type": "property", + "propertyId": "strokeColor" + } + }, + { + "name": "stroke-width", + "value": { + "type": "property", + "propertyId": "strokeWidth" + } + } + ] + } + ], + "behavior": [ + { + "id": "initialState", + "name": "Initial state", + "type": "getValue", + "valueType": "DOUBLE", + "defaultValue": 0, + "onUpdate": [ + { + "matcher": { + "type": "any" + }, + "state": [ + { + "tag": "RECT", + "animate": 200, + "attributes": [ + { + "name": "height", + "value": { + "type": "input" + } + }, + { + "name": "width", + "value": { + "type": "input" + } + } + ] + } + ] + } + ] + }, + { + "id": "disabledState", + "name": "Disabled state", + "type": "getValue", + "valueType": "BOOLEAN", + "defaultValue": false, + "onUpdate": [ + { + "matcher": { + "type": "constant", + "value": true + }, + "state": [ + { + "tag": "RECT", + "attributes": [ + { + "name": "fill", + "value": { + "type": "property", + "propertyId": "disabledBackground" + } + } + ] + } + ] + }, + { + "matcher": { + "type": "constant", + "value": false + }, + "state": [ + { + "tag": "RECT", + "attributes": [ + { + "name": "fill", + "value": { + "type": "property", + "propertyId": "background" + } + } + ] + } + ] + } + ] + } + ], + "properties": [ + { + "id": "strokeColor", + "name": "Stroke color", + "type": "color", + "default": "#aaa" + }, + { + "id": "strokeWidth", + "name": "Stroke width", + "type": "number", + "default": 5, + "min": 0, + "max": 20 + }, + { + "id": "background", + "name": "Background", + "type": "color", + "default": "green" + }, + { + "id": "disabledBackground", + "name": "Disabled background", + "type": "color", + "default": "#ccc" + } + ] + } + + + From f01f99e738c8e2d3b2e21dd5cb8770949ee0dc45 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Fri, 3 May 2024 19:21:07 +0300 Subject: [PATCH 020/442] UI: Scada metadata and configuration improvements. --- .../widget/lib/scada/scada.models.ts | 325 +++++++++++++--- .../scada-object-settings.component.html | 47 ++- .../scada/scada-object-settings.component.ts | 75 +++- .../scada/scada-object-settings.models.ts | 43 +++ ui-ngx/src/assets/widget/scada/drawing.svg | 352 +++++++++++++----- 5 files changed, 673 insertions(+), 169 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.models.ts diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada.models.ts index 7088fe6131..542e24844e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada.models.ts @@ -15,13 +15,23 @@ /// import { ValueType } from '@shared/models/constants'; -import { Box, Element, Runner, Svg, SVG, Timeline } from '@svgdotjs/svg.js'; +import { Box, Element, Runner, Svg, SVG, Timeline, Text } from '@svgdotjs/svg.js'; import { DataToValueType, GetValueAction, GetValueSettings } from '@shared/models/action-widget-settings.models'; -import { insertVariable, isDefinedAndNotNull, isNumber, isNumeric, isUndefinedOrNull, mergeDeep } from '@core/utils'; +import { + formatValue, + insertVariable, + isDefinedAndNotNull, + isNumber, + isNumeric, + isUndefinedOrNull, + mergeDeep, + parseFunction +} from '@core/utils'; import { BehaviorSubject, forkJoin, Observable } from 'rxjs'; import { map, share } from 'rxjs/operators'; import { ValueAction, ValueGetter } from '@home/components/widget/lib/action/action-widget.models'; import { WidgetContext } from '@home/models/widget-component.models'; +import { Font } from '@shared/models/widget-settings.models'; export type ValueMatcherType = 'any' | 'constant' | 'range'; @@ -31,29 +41,85 @@ export interface ValueMatcher { range?: {from: number; to: number}; } -export type ScadaObjectAttributeValueType = 'input' | 'property'; +const valueMatches = (matcher: ValueMatcher, val: any): boolean => { + switch (matcher.type) { + case 'any': + return true; + case 'constant': + return matcher.value === val; + case 'range': + if (isDefinedAndNotNull(val) && isNumeric(val)) { + const num = Number(val); + const range = matcher.range; + return ((!isNumber(range.from) || num >= range.from) && (!isNumber(range.to) || num < range.to)); + } else { + return false; + } + } +}; -export interface ScadaObjectAttributeValue { - type: ScadaObjectAttributeValueType; +export type ScadaObjectValueType = 'input' | 'constant' | 'property' | 'function' | 'valueFormat'; + +export interface ScadaObjectValueBase { + type: ScadaObjectValueType; +} + +export interface ScadaObjectValueConstant extends ScadaObjectValueBase { + constantValue?: any; +} + +export interface ScadaObjectValueProperty extends ScadaObjectValueBase { propertyId?: string; + computedPropertyValue?: any; } -export interface ScadaObjectAttributeState { +export interface ScadaObjectValueFunction extends ScadaObjectValueBase { + valueConvertFunction?: string; + valueConverter?: (val: any) => any; +} + +export interface ScadaObjectValueFormat extends ScadaObjectValueBase { + units?: ScadaObjectValue; + decimals?: ScadaObjectValue; + computedUnits?: string; + computedDecimals?: number; +} + +export type ScadaObjectValue = ScadaObjectValueProperty & ScadaObjectValueConstant & ScadaObjectValueFunction & ScadaObjectValueFormat; + +export interface ScadaObjectAttribute { name: string; - value: ScadaObjectAttributeValue; + value: ScadaObjectValue; } -export interface ScadaObjectState { +export interface ScadaObjectText { + content?: ScadaObjectValue; + font?: ScadaObjectValue; + color?: ScadaObjectValue; +} + +export interface ScadaObjectElementState { tag: string; - attributes: ScadaObjectAttributeState[]; + show?: ScadaObjectValue; + text?: ScadaObjectText; + attributes?: ScadaObjectAttribute[]; animate?: number; } +export interface ScadaObjectState { + initial?: boolean; + value?: any; + state: ScadaObjectElementState[]; +} + export interface ScadaObjectUpdateState { matcher: ValueMatcher; - state: ScadaObjectState[]; + stateId: string; } +const filterUpdateStates = (states: ScadaObjectUpdateState[], val: any): ScadaObjectUpdateState[] => + states.filter(s => valueMatches(s.matcher, val)); + export enum ScadaObjectBehaviorType { setValue = 'setValue', getValue = 'getValue' @@ -77,32 +143,38 @@ export interface ScadaObjectBehaviorSet extends ScadaObjectBehaviorBase { export type ScadaObjectBehavior = ScadaObjectBehaviorGet | ScadaObjectBehaviorSet; -export type ScadaObjectPropertyType = 'string' | 'number' | 'color'; +export type ScadaObjectPropertyType = 'string' | 'number' | 'color' | 'font' | 'units' | 'switch'; export interface ScadaObjectPropertyBase { id: string; name: string; type: ScadaObjectPropertyType; default: any; + required?: boolean; + subLabel?: string; + divider?: boolean; + fieldSuffix?: string; + disableOnProperty?: string; } export interface ScadaObjectNumberProperty extends ScadaObjectPropertyBase { min?: number; max?: number; + step?: number; } export type ScadaObjectProperty = ScadaObjectPropertyBase & ScadaObjectNumberProperty; export interface ScadaObjectMetadata { title: string; - initial: ScadaObjectState[]; + states: {[id: string]: ScadaObjectState}; behavior: ScadaObjectBehavior[]; properties: ScadaObjectProperty[]; } export const emptyMetadata: ScadaObjectMetadata = { title: '', - initial: [], + states: {}, behavior: [], properties: [] }; @@ -126,6 +198,7 @@ const parseScadaObjectMetadataFromDom = (svgDoc: Document): ScadaObjectMetadata return emptyMetadata; } } catch (_e) { + console.error(_e); return emptyMetadata; } }; @@ -201,8 +274,9 @@ export class ScadaObject { this.metadata = parseScadaObjectMetadataFromDom(doc); const defaults = defaultScadaObjectSettings(this.metadata); this.settings = mergeDeep({}, defaults, this.inputSettings || {}); + this.prepareMetadata(); this.prepareSvgShape(doc); - this.prepareStates(); + this.initStates(); }) ); } @@ -228,17 +302,58 @@ export class ScadaObject { } } + private prepareMetadata() { + for (const stateId of Object.keys(this.metadata.states)) { + const state = this.metadata.states[stateId]; + for (const elementState of state.state) { + this.prepareValue(elementState.show); + this.prepareValue(elementState.text?.content); + this.prepareValue(elementState.text?.font); + this.prepareValue(elementState.text?.color); + if (elementState.attributes) { + for (const attribute of elementState.attributes) { + this.prepareValue(attribute.value); + } + } + } + } + } + + private prepareValue(value: ScadaObjectValue) { + if (value) { + if (value.type === 'function' && value.valueConvertFunction) { + try { + value.valueConverter = parseFunction(this.insertVariables(value.valueConvertFunction), ['value']); + } catch (e) { + value.valueConverter = (v) => v; + } + } else if (value.type === 'property') { + value.computedPropertyValue = this.getPropertyValue(value.propertyId); + } else if (value.type === 'valueFormat') { + if (value.units) { + this.prepareValue(value.units); + } + if (value.decimals) { + this.prepareValue(value.decimals); + } + } + } + } + + private insertVariables(content: string): string { + for (const property of this.metadata.properties) { + const value = this.getPropertyValue(property.id); + content = insertVariable(content, property.id, value); + } + return content; + } + private prepareSvgShape(doc: XMLDocument) { const elements = doc.getElementsByTagName('tb:metadata'); for (let i=0;i s.initial); + if (initialState) { + this.updateState(initialState); } if (this.valueGetters.length) { const getValueObservables: Array> = []; @@ -301,23 +417,61 @@ export class ScadaObject { private onValue(id: string, value: any) { const getBehavior = this.metadata.behavior.find(b => b.id === id) as ScadaObjectBehaviorGet; value = this.normalizeValue(value, getBehavior.valueType); - const updateStates = this.filterUpdateStates(getBehavior.onUpdate, value); + const updateStates = filterUpdateStates(getBehavior.onUpdate, value); if (this.animationTimeline) { this.animationTimeline.finish(); } for (const updateState of updateStates) { - this.updateState(updateState.state, value); + const state = this.metadata.states[updateState.stateId]; + this.updateState(state, value); } } - private updateState(state: ScadaObjectState[], value?: any) { - for (const stateEntry of state) { - const tag = stateEntry.tag; - const elements = this.svgShape.find(`[tb\\:tag="${tag}"]`); - const attrs = this.computeAttributes(stateEntry.attributes, value); - elements.forEach(e => { - this.setElementAttributes(e, attrs, stateEntry.animate); - }); + private updateState(state: ScadaObjectState, value?: any) { + if (state) { + for (const elementState of state.state) { + const tag = elementState.tag; + const elements = this.svgShape.find(`[tb\\:tag="${tag}"]`); + if (elements.length) { + if (elementState.show) { + const show: boolean = this.computeValue(elementState.show, value); + elements.forEach(e => { + if (show) { + e.show(); + } else { + e.hide(); + } + }); + } + if (elementState.attributes) { + const attrs = this.computeAttributes(elementState.attributes, value); + elements.forEach(e => { + this.setElementAttributes(e, attrs, elementState.animate); + }); + } + if (elementState.text) { + if (elementState.text.content) { + const text: string = this.computeValue(elementState.text.content, value); + elements.forEach(e => { + this.setElementText(e, text); + }); + } + if (elementState.text.font || elementState.text.color) { + let font: Font = this.computeValue(elementState.text.font, value); + if (typeof font !== 'object') { + font = undefined; + } + let color: string = this.computeValue(elementState.text.color, value); + if (typeof color !== 'string') { + color = undefined; + } + elements.forEach(e => { + this.setElementFont(e, font, color); + }); + } + } + } + } } } @@ -339,11 +493,11 @@ export class ScadaObject { } } - private computeAttributes(attributes: ScadaObjectAttributeState[], value: any): {[attr: string]: any} { + private computeAttributes(attributes: ScadaObjectAttribute[], value: any): {[attr: string]: any} { const res: {[attr: string]: any} = {}; for (const attribute of attributes) { const attr = attribute.name; - res[attr] = this.getAttributeValue(attribute, value); + res[attr] = this.computeValue(attribute.value, value); } return res; } @@ -356,6 +510,41 @@ export class ScadaObject { } } + private setElementText(element: Element, text: string) { + let textElement: Text; + if (element.type === 'text') { + const children = element.children(); + if (children.length && children[0].type === 'tspan') { + textElement = children[0] as Text; + } else { + textElement = element as Text; + } + } else if (element.type === 'tspan') { + textElement = element as Text; + } + if (textElement) { + textElement.text(text); + } + } + + private setElementFont(element: Element, font: Font, color: string) { + if (element.type === 'text') { + const textElement = element as Text; + if (font) { + textElement.font({ + family: font.family, + size: (isDefinedAndNotNull(font.size) && isDefinedAndNotNull(font.sizeUnit)) ? + font.size + font.sizeUnit : null, + weight: font.weight, + style: font.style + }); + } + if (color) { + textElement.fill(color); + } + } + } + private animation(element: Element, duration: number): Runner { if (!this.animationTimeline) { this.animationTimeline = new Timeline(); @@ -364,35 +553,55 @@ export class ScadaObject { return element.animate(duration, 0, 'now'); } - private getAttributeValue(attribute: ScadaObjectAttributeState, value: any): any { - if (attribute.value.type === 'input') { - return value; - } else if (attribute.value.type === 'property') { - const id = attribute.value.propertyId; - return this.settings[id] || ''; + private computeValue(objectValue: ScadaObjectValue, value: any): any { + if (objectValue) { + switch (objectValue.type) { + case 'input': + return value; + case 'constant': + return objectValue.constantValue; + case 'property': + return objectValue.computedPropertyValue; + case 'function': + try { + return objectValue.valueConverter(value); + } catch (_e) { + return value; + } + case 'valueFormat': + let units = ''; + let decimals = 0; + if (objectValue.units) { + units = this.computeValue(objectValue.units, value); + } + if (objectValue.decimals) { + decimals = this.computeValue(objectValue.decimals, value); + } + return formatValue(value, decimals, units, false); + } } else { return ''; } } - private filterUpdateStates(states: ScadaObjectUpdateState[], val: any): ScadaObjectUpdateState[] { - return states.filter(s => this.valueMatches(s.matcher, val)); - } - - private valueMatches(matcher: ValueMatcher, val: any): boolean { - switch (matcher.type) { - case 'any': - return true; - case 'constant': - return matcher.value === val; - case 'range': - if (isDefinedAndNotNull(val) && isNumeric(val)) { - const num = Number(val); - const range = matcher.range; - return ((!isNumber(range.from) || num >= range.from) && (!isNumber(range.to) || num < range.to)); - } else { - return false; + private getPropertyValue(id: string): any { + const property = this.metadata.properties.find(p => p.id === id); + if (property) { + const value = this.settings[id]; + if (isDefinedAndNotNull(value)) { + return value; + } else { + switch (property.type) { + case 'string': + return ''; + case 'number': + return 0; + case 'color': + return '#000'; } + } + } else { + return ''; } } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.component.html index d5a8cba6df..2907a951dd 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.component.html @@ -32,17 +32,42 @@ -
-
-
{{ property.name }}
- - - - - +
+
+ + {{ propertyRow.label }} + +
{{ propertyRow.label }}
+
+ +
{{ property.subLabel }}
+ + +
{{ property.fieldSuffix }}
+
+ + + + +
{{ property.fieldSuffix }}
+
+ + + + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.component.ts index ad10d681a7..3e0bd7409d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.component.ts @@ -14,12 +14,15 @@ /// limitations under the License. /// -import { Component, forwardRef, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; +import { ChangeDetectorRef, Component, forwardRef, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; import { ControlValueAccessor, + NG_VALIDATORS, NG_VALUE_ACCESSOR, UntypedFormBuilder, + UntypedFormControl, UntypedFormGroup, + Validator, ValidatorFn, Validators } from '@angular/forms'; @@ -37,6 +40,11 @@ import { ValueType } from '@shared/models/constants'; import { IAliasController } from '@core/api/widget-api.models'; import { TargetDevice, widgetType } from '@shared/models/widget.models'; import { isDefinedAndNotNull } from '@core/utils'; +import { + ScadaPropertyRow, + toPropertyRows +} from '@home/components/widget/lib/settings/common/scada/scada-object-settings.models'; +import { merge, Observable, Subscription } from 'rxjs'; @Component({ selector: 'tb-scada-object-settings', @@ -47,10 +55,15 @@ import { isDefinedAndNotNull } from '@core/utils'; provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => ScadaObjectSettingsComponent), multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => ScadaObjectSettingsComponent), + multi: true } ] }) -export class ScadaObjectSettingsComponent implements OnInit, OnChanges, ControlValueAccessor { +export class ScadaObjectSettingsComponent implements OnInit, OnChanges, ControlValueAccessor, Validator { ScadaObjectBehaviorType = ScadaObjectBehaviorType; @@ -73,13 +86,18 @@ export class ScadaObjectSettingsComponent implements OnInit, OnChanges, ControlV private propagateChange = null; + private validatorTriggers: string[]; + private validatorSubscription: Subscription; + public scadaObjectSettingsFormGroup: UntypedFormGroup; metadata: ScadaObjectMetadata; + propertyRows: ScadaPropertyRow[]; constructor(protected store: Store, private fb: UntypedFormBuilder, - private http: HttpClient) { + private http: HttpClient, + private cd: ChangeDetectorRef) { } ngOnInit(): void { @@ -114,6 +132,7 @@ export class ScadaObjectSettingsComponent implements OnInit, OnChanges, ControlV this.scadaObjectSettingsFormGroup.disable({emitEvent: false}); } else { this.scadaObjectSettingsFormGroup.enable({emitEvent: false}); + this.updateValidators(); } } @@ -122,10 +141,25 @@ export class ScadaObjectSettingsComponent implements OnInit, OnChanges, ControlV this.setupValue(); } + validate(c: UntypedFormControl) { + const valid = this.scadaObjectSettingsFormGroup.valid; + return valid ? null : { + scadaObject: { + valid: false, + }, + }; + } + private loadMetadata() { + if (this.validatorSubscription) { + this.validatorSubscription.unsubscribe(); + this.validatorSubscription = null; + } + this.validatorTriggers = []; this.http.get(this.svgPath, {responseType: 'text'}).subscribe( (svgContent) => { this.metadata = parseScadaObjectMetadataFromContent(svgContent); + this.propertyRows = toPropertyRows(this.metadata.properties); for (const control of Object.keys(this.scadaObjectSettingsFormGroup.controls)) { this.scadaObjectSettingsFormGroup.removeControl(control, {emitEvent: false}); } @@ -133,7 +167,15 @@ export class ScadaObjectSettingsComponent implements OnInit, OnChanges, ControlV this.scadaObjectSettingsFormGroup.addControl(behaviour.id, this.fb.control(null, []), {emitEvent: false}); } for (const property of this.metadata.properties) { + if (property.disableOnProperty) { + if (!this.validatorTriggers.includes(property.disableOnProperty)) { + this.validatorTriggers.push(property.disableOnProperty); + } + } const validators: ValidatorFn[] = []; + if (property.required) { + validators.push(Validators.required); + } if (property.type === 'number') { if (isDefinedAndNotNull(property.min)) { validators.push(Validators.min(property.min)); @@ -144,11 +186,37 @@ export class ScadaObjectSettingsComponent implements OnInit, OnChanges, ControlV } this.scadaObjectSettingsFormGroup.addControl(property.id, this.fb.control(null, validators), {emitEvent: false}); } + if (this.validatorTriggers.length) { + const observables: Observable[] = []; + for (const trigger of this.validatorTriggers) { + observables.push(this.scadaObjectSettingsFormGroup.get(trigger).valueChanges); + } + this.validatorSubscription = merge(...observables).subscribe(() => { + this.updateValidators(); + }); + } this.setupValue(); + this.cd.markForCheck(); } ); } + private updateValidators() { + for (const trigger of this.validatorTriggers) { + const value: boolean = this.scadaObjectSettingsFormGroup.get(trigger).value; + this.metadata.properties.filter(p => p.disableOnProperty === trigger).forEach( + (p) => { + const control = this.scadaObjectSettingsFormGroup.get(p.id); + if (value) { + control.enable({emitEvent: false}); + } else { + control.disable({emitEvent: false}); + } + } + ); + } + } + private setupValue() { if (this.metadata) { const defaults = defaultScadaObjectSettings(this.metadata); @@ -156,6 +224,7 @@ export class ScadaObjectSettingsComponent implements OnInit, OnChanges, ControlV this.scadaObjectSettingsFormGroup.patchValue( this.modelValue, {emitEvent: false} ); + this.setDisabledState(this.disabled); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.models.ts new file mode 100644 index 0000000000..2426b13a0b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.models.ts @@ -0,0 +1,43 @@ +/// +/// Copyright ยฉ 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { ScadaObjectProperty } from '@home/components/widget/lib/scada/scada.models'; + +export interface ScadaPropertyRow { + label: string; + properties: ScadaObjectProperty[]; + switch?: ScadaObjectProperty; +} + +export const toPropertyRows = (properties: ScadaObjectProperty[]): ScadaPropertyRow[] => { + const result: ScadaPropertyRow[] = []; + for (const property of properties) { + let propertyRow = result.find(r => r.label === property.name); + if (!propertyRow) { + propertyRow = { + label: property.name, + properties: [] + }; + result.push(propertyRow); + } + if (property.type === 'switch') { + propertyRow.switch = property; + } else { + propertyRow.properties.push(property); + } + } + return result; +}; diff --git a/ui-ngx/src/assets/widget/scada/drawing.svg b/ui-ngx/src/assets/widget/scada/drawing.svg index 972ca550cd..03849049f3 100644 --- a/ui-ngx/src/assets/widget/scada/drawing.svg +++ b/ui-ngx/src/assets/widget/scada/drawing.svg @@ -1,39 +1,160 @@ - - { + { "title": "My first SCADA Object", - "initial": [ - { - "tag": "RECT", - "attributes": [ - { - "name": "fill", - "value": { - "type": "property", - "propertyId": "background" + "states": { + "initialState": { + "initial": true, + "state": [ + { + "tag": "level", + "attributes": [ + { + "name": "fill", + "value": { + "type": "property", + "propertyId": "levelBackground" + } + } + ] + }, + { + "tag": "levelValueBackground", + "show": { + "type": "property", + "propertyId": "showValue" } - }, - { - "name": "stroke", - "value": { - "type": "property", - "propertyId": "strokeColor" + }, + { + "tag": "levelValue", + "show": { + "type": "property", + "propertyId": "showValue" + }, + "text": { + "font": { + "type": "property", + "propertyId": "valueFont" + }, + "color": { + "type": "property", + "propertyId": "valueColor" + } } - }, - { - "name": "stroke-width", - "value": { - "type": "property", - "propertyId": "strokeWidth" + }, + { + "tag": "minLevel", + "show": { + "type": "property", + "propertyId": "showMinMaxLevel" + }, + "text": { + "content": { + "type": "property", + "propertyId": "minLevel" + }, + "font": { + "type": "property", + "propertyId": "minMaxLevelFont" + }, + "color": { + "type": "property", + "propertyId": "minMaxLevelColor" + } } - } - ] + }, + { + "tag": "maxLevel", + "show": { + "type": "property", + "propertyId": "showMinMaxLevel" + }, + "text": { + "content": { + "type": "property", + "propertyId": "maxLevel" + }, + "font": { + "type": "property", + "propertyId": "minMaxLevelFont" + }, + "color": { + "type": "property", + "propertyId": "minMaxLevelColor" + } + } + } + ] + }, + "updateLevelState": { + "state": [ + { + "tag": "level", + "animate": 200, + "attributes": [ + { + "name": "height", + "value": { + "type": "function", + "valueConvertFunction": "var level = (value - ${minLevel}) / (${maxLevel} - ${minLevel});\nlevel = Math.max(0, Math.min(1, level));\nreturn level*80;" + } + } + ] + }, + { + "tag": "levelValue", + "text": { + "content": { + "type": "valueFormat", + "units": { + "type": "property", + "propertyId": "valueUnits" + }, + "decimals": { + "type": "property", + "propertyId": "valueDecimals" + } + } + } + } + ] + }, + "disabledState": { + "state": [ + { + "tag": "level", + "attributes": [ + { + "name": "fill", + "value": { + "type": "property", + "propertyId": "disabledLevelBackground" + } + } + ] + } + ] + }, + "enabledState": { + "state": [ + { + "tag": "level", + "attributes": [ + { + "name": "fill", + "value": { + "type": "property", + "propertyId": "levelBackground" + } + } + ] + } + ] } - ], + }, "behavior": [ { - "id": "initialState", - "name": "Initial state", + "id": "levelState", + "name": "Level", "type": "getValue", "valueType": "DOUBLE", "defaultValue": 0, @@ -42,26 +163,7 @@ "matcher": { "type": "any" }, - "state": [ - { - "tag": "RECT", - "animate": 200, - "attributes": [ - { - "name": "height", - "value": { - "type": "input" - } - }, - { - "name": "width", - "value": { - "type": "input" - } - } - ] - } - ] + "stateId": "updateLevelState" } ] }, @@ -77,73 +179,129 @@ "type": "constant", "value": true }, - "state": [ - { - "tag": "RECT", - "attributes": [ - { - "name": "fill", - "value": { - "type": "property", - "propertyId": "disabledBackground" - } - } - ] - } - ] + "stateId": "disabledState" }, { "matcher": { "type": "constant", "value": false }, - "state": [ - { - "tag": "RECT", - "attributes": [ - { - "name": "fill", - "value": { - "type": "property", - "propertyId": "background" - } - } - ] - } - ] + "stateId": "enabledState" } ] } ], "properties": [ { - "id": "strokeColor", - "name": "Stroke color", - "type": "color", - "default": "#aaa" + "id": "showValue", + "name": "Value", + "type": "switch", + "default": true }, { - "id": "strokeWidth", - "name": "Stroke width", + "id": "valueUnits", + "name": "Value", + "type": "units", + "default": "", + "disableOnProperty": "showValue" + }, + { + "id": "valueDecimals", + "name": "Value", "type": "number", - "default": 5, + "default": 2, "min": 0, - "max": 20 + "max": 15, + "step": 1, + "fieldSuffix": "decimals", + "disableOnProperty": "showValue" + }, + { + "id": "valueFont", + "name": "Value", + "type": "font", + "default": { + "size": 6, + "sizeUnit": "px", + "family": "Roboto", + "weight": "normal", + "style": "normal" + }, + "disableOnProperty": "showValue" + }, + { + "id": "valueColor", + "name": "Value", + "type": "color", + "default": "#000000", + "disableOnProperty": "showValue" + }, + { + "id": "minLevel", + "name": "Level range", + "subLabel": "min", + "type": "number", + "required": true, + "default": 0, + "min": 0 }, { - "id": "background", - "name": "Background", + "id": "maxLevel", + "name": "Level range", + "subLabel": "max", + "type": "number", + "required": true, + "default": 100, + "min": 0 + }, + { + "id": "showMinMaxLevel", + "name": "Min/Max label", + "type": "switch", + "default": true + }, + { + "id": "minMaxLevelFont", + "name": "Min/Max label", + "type": "font", + "default": { + "size": 6, + "sizeUnit": "px", + "family": "Roboto", + "weight": "normal", + "style": "normal" + }, + "disableOnProperty": "showMinMaxLevel" + }, + { + "id": "minMaxLevelColor", + "name": "Min/Max label", + "type": "color", + "default": "#666", + "disableOnProperty": "showMinMaxLevel" + }, + { + "id": "levelBackground", + "name": "Level background", + "subLabel": "Enabled", "type": "color", - "default": "green" - }, - { - "id": "disabledBackground", - "name": "Disabled background", - "type": "color", - "default": "#ccc" - } + "default": "#1abb48", + "divider": true + }, + { + "id": "disabledLevelBackground", + "name": "Level background", + "subLabel": "Disabled", + "type": "color", + "default": "#ccc" + } ] - } - - + } + + + + min + + N/A + max From 937b48d85b8819df726e1c929f967de32d2071a3 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Mon, 6 May 2024 19:30:31 +0300 Subject: [PATCH 021/442] UI: Scada test improvements, add actions. --- .../widget/lib/scada/scada.models.ts | 316 ++++++++++++++---- .../scada-object-settings.component.html | 27 +- .../scada/scada-object-settings.models.ts | 4 +- ui-ngx/src/assets/widget/scada/drawing.svg | 272 +++++++++++++-- 4 files changed, 520 insertions(+), 99 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada.models.ts index 542e24844e..d0aeac27a7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada.models.ts @@ -16,7 +16,12 @@ import { ValueType } from '@shared/models/constants'; import { Box, Element, Runner, Svg, SVG, Timeline, Text } from '@svgdotjs/svg.js'; -import { DataToValueType, GetValueAction, GetValueSettings } from '@shared/models/action-widget-settings.models'; +import { + DataToValueType, + GetValueAction, + GetValueSettings, SetValueAction, + SetValueSettings, ValueToDataType +} from '@shared/models/action-widget-settings.models'; import { formatValue, insertVariable, @@ -29,34 +34,10 @@ import { } from '@core/utils'; import { BehaviorSubject, forkJoin, Observable } from 'rxjs'; import { map, share } from 'rxjs/operators'; -import { ValueAction, ValueGetter } from '@home/components/widget/lib/action/action-widget.models'; +import { ValueAction, ValueGetter, ValueSetter } from '@home/components/widget/lib/action/action-widget.models'; import { WidgetContext } from '@home/models/widget-component.models'; -import { Font } from '@shared/models/widget-settings.models'; - -export type ValueMatcherType = 'any' | 'constant' | 'range'; - -export interface ValueMatcher { - type: ValueMatcherType; - value?: any; - range?: {from: number; to: number}; -} - -const valueMatches = (matcher: ValueMatcher, val: any): boolean => { - switch (matcher.type) { - case 'any': - return true; - case 'constant': - return matcher.value === val; - case 'range': - if (isDefinedAndNotNull(val) && isNumeric(val)) { - const num = Number(val); - const range = matcher.range; - return ((!isNumber(range.from) || num >= range.from) && (!isNumber(range.to) || num < range.to)); - } else { - return false; - } - } -}; +import { ColorProcessor, constantColor, Font } from '@shared/models/widget-settings.models'; +import { AttributeScope } from '@shared/models/telemetry/telemetry.models'; export type ScadaObjectValueType = 'input' | 'constant' | 'property' | 'function' | 'valueFormat'; @@ -100,26 +81,28 @@ export interface ScadaObjectText { export interface ScadaObjectElementState { tag: string; + inputValue?: string; show?: ScadaObjectValue; text?: ScadaObjectText; attributes?: ScadaObjectAttribute[]; animate?: number; + addClass?: string; + removeClass?: string; + animationTimeline?: Timeline; } export interface ScadaObjectState { initial?: boolean; - value?: any; + triggerValues?: string[]; + conditionFunction?: string; + condition?: (values: {[key: string]: any}) => boolean; state: ScadaObjectElementState[]; } export interface ScadaObjectUpdateState { - matcher: ValueMatcher; - stateId: string; + updateValue: string; } -const filterUpdateStates = (states: ScadaObjectUpdateState[], val: any): ScadaObjectUpdateState[] => - states.filter(s => valueMatches(s.matcher, val)); - export enum ScadaObjectBehaviorType { setValue = 'setValue', getValue = 'getValue' @@ -138,12 +121,14 @@ export interface ScadaObjectBehaviorGet extends ScadaObjectBehaviorBase { } export interface ScadaObjectBehaviorSet extends ScadaObjectBehaviorBase { - todo: any; + valueToDataType: ValueToDataType; + constantValue: any; + valueToDataFunction: string; } export type ScadaObjectBehavior = ScadaObjectBehaviorGet | ScadaObjectBehaviorSet; -export type ScadaObjectPropertyType = 'string' | 'number' | 'color' | 'font' | 'units' | 'switch'; +export type ScadaObjectPropertyType = 'string' | 'number' | 'color' | 'color-settings' | 'font' | 'units' | 'switch'; export interface ScadaObjectPropertyBase { id: string; @@ -155,6 +140,8 @@ export interface ScadaObjectPropertyBase { divider?: boolean; fieldSuffix?: string; disableOnProperty?: string; + rowClass?: string; + fieldClass?: string; } export interface ScadaObjectNumberProperty extends ScadaObjectPropertyBase { @@ -165,8 +152,38 @@ export interface ScadaObjectNumberProperty extends ScadaObjectPropertyBase { export type ScadaObjectProperty = ScadaObjectPropertyBase & ScadaObjectNumberProperty; +export interface ScadaObjectCallBehavior { + behaviorId: string; + conditionFunction?: string; + condition?: (value: any) => boolean; +} + +export interface ScadaObjectStateValue { + initialValue: any; + callBehavior?: ScadaObjectCallBehavior[]; +} + +export type ScadaObjectElementActionTrigger = 'click'; + +export type ScadaObjectActionType = 'updateValue'; +export type ScadaObjectActionUpdateValueType = 'toggle' | 'increment' | 'constant'; + +export interface ScadaObjectElementAction { + trigger: ScadaObjectElementActionTrigger; + enabledTriggerValues?: string[]; + enabledConditionFunction?: string; + enabledCondition?: (values: {[key: string]: any}) => boolean; + actionType: ScadaObjectActionType; + updateValueId?: string; + updateValueType?: ScadaObjectActionUpdateValueType; + updateValueConstant?: any; + updateValueInc?: number; +} + export interface ScadaObjectMetadata { title: string; + stateValues: {[id: string]: ScadaObjectStateValue}; + actions: {[tag: string]: ScadaObjectElementAction}; states: {[id: string]: ScadaObjectState}; behavior: ScadaObjectBehavior[]; properties: ScadaObjectProperty[]; @@ -174,6 +191,8 @@ export interface ScadaObjectMetadata { export const emptyMetadata: ScadaObjectMetadata = { title: '', + stateValues: {}, + actions: {}, states: {}, behavior: [], properties: [] @@ -211,7 +230,7 @@ const defaultGetValueSettings = (get: ScadaObjectBehaviorGet): GetValueSettings< requestTimeout: 5000, requestPersistent: false, persistentPollingInterval: 1000 - }, + }, getAttribute: { key: 'state', scope: null @@ -221,19 +240,41 @@ const defaultGetValueSettings = (get: ScadaObjectBehaviorGet): GetValueSettings< }, dataToValue: { type: DataToValueType.NONE, - compareToValue: true, - dataToValueFunction: '/* Should return boolean value */\nreturn data;' + compareToValue: true, + dataToValueFunction: '/* Should return boolean value */\nreturn data;' } }); +const defaultSetValueSettings = (set: ScadaObjectBehaviorSet): SetValueSettings => ({ + action: SetValueAction.EXECUTE_RPC, + executeRpc: { + method: 'setState', + requestTimeout: 5000, + requestPersistent: false, + persistentPollingInterval: 1000 + }, + setAttribute: { + key: 'state', + scope: AttributeScope.SERVER_SCOPE + }, + putTimeSeries: { + key: 'state' + }, + valueToData: { + type: set.valueToDataType, + constantValue: set.constantValue, + valueToDataFunction: set.valueToDataFunction ? set.valueToDataFunction : + '/* Convert input boolean value to RPC parameters or attribute/time-series value */\nreturn value;' + } +}); + export const defaultScadaObjectSettings = (metadata: ScadaObjectMetadata): ScadaObjectSettings => { const settings: ScadaObjectSettings = {}; - for (const behaviour of metadata.behavior) { - //behaviour.id - if (behaviour.type === ScadaObjectBehaviorType.getValue) { - settings[behaviour.id] = defaultGetValueSettings(behaviour as ScadaObjectBehaviorGet); - } else if (behaviour.type === ScadaObjectBehaviorType.setValue) { - // TODO: + for (const behavior of metadata.behavior) { + if (behavior.type === ScadaObjectBehaviorType.getValue) { + settings[behavior.id] = defaultGetValueSettings(behavior as ScadaObjectBehaviorGet); + } else if (behavior.type === ScadaObjectBehaviorType.setValue) { + settings[behavior.id] = defaultSetValueSettings(behavior as ScadaObjectBehaviorSet); } } for (const property of metadata.properties) { @@ -258,8 +299,10 @@ export class ScadaObject { private loadingSubject = new BehaviorSubject(false); private valueGetters: ValueGetter[] = []; private valueActions: ValueAction[] = []; + private valueSetters: {[behaviorId: string]: ValueSetter} = {}; - private animationTimeline: Timeline; + private stateValueSubjects: {[id: string]: BehaviorSubject} = {}; + private stateValues: {[id: string]: any} = {}; loading$ = this.loadingSubject.asObservable().pipe(share()); @@ -289,6 +332,10 @@ export class ScadaObject { } public destroy() { + for (const stateValueId of Object.keys(this.stateValueSubjects)) { + this.stateValueSubjects[stateValueId].complete(); + this.stateValueSubjects[stateValueId].unsubscribe(); + } this.valueActions.forEach(v => v.destroy()); this.loadingSubject.complete(); this.loadingSubject.unsubscribe(); @@ -303,8 +350,33 @@ export class ScadaObject { } private prepareMetadata() { + for (const stateValueId of Object.keys(this.metadata.stateValues)) { + const stateValue = this.metadata.stateValues[stateValueId]; + if (stateValue.callBehavior) { + stateValue.callBehavior.forEach(callBehavior => { + let condition = () => true; + if (callBehavior.conditionFunction) { + try { + condition = parseFunction(this.insertVariables(callBehavior.conditionFunction), ['value']); + } catch (e) { + condition = () => false; + } + } + callBehavior.condition = condition; + }); + } + } for (const stateId of Object.keys(this.metadata.states)) { const state = this.metadata.states[stateId]; + let condition = () => true; + if (state.conditionFunction) { + try { + condition = parseFunction(this.insertVariables(state.conditionFunction), ['values']); + } catch (e) { + condition = () => false; + } + } + state.condition = condition; for (const elementState of state.state) { this.prepareValue(elementState.show); this.prepareValue(elementState.text?.content); @@ -367,6 +439,35 @@ export class ScadaObject { } private initStates() { + for (const stateValueId of Object.keys(this.metadata.stateValues)) { + const stateValue = this.metadata.stateValues[stateValueId]; + this.stateValueSubjects[stateValueId] = new BehaviorSubject(stateValue.initialValue); + this.stateValues[stateValueId] = stateValue.initialValue; + this.stateValueSubjects[stateValueId].subscribe(val => { + this.stateValues[stateValueId] = val; + const states = Object.values(this.metadata.states); + const triggerStates = states.filter(s => s.triggerValues && s.triggerValues.includes(stateValueId)); + for (const state of triggerStates) { + if (state.condition(this.stateValues)) { + this.updateState(state); + } + } + }); + } + for (const tag of Object.keys(this.metadata.actions)) { + const action = this.metadata.actions[tag]; + const elements = this.svgShape.find(`[tb\\:tag="${tag}"]`); + switch (action.trigger) { + case 'click': + elements.forEach(e => { + e.attr('cursor', 'pointer'); + e.on('click', () => { + this.triggerAction(action); + }); + }); + break; + } + } for (const behavior of this.metadata.behavior) { if (behavior.type === ScadaObjectBehaviorType.getValue) { const getBehavior = behavior as ScadaObjectBehaviorGet; @@ -379,6 +480,13 @@ export class ScadaObject { }); this.valueGetters.push(valueGetter); this.valueActions.push(valueGetter); + } else if (behavior.type === ScadaObjectBehaviorType.setValue) { + const setBehavior = behavior as ScadaObjectBehaviorSet; + let setValueSettings: SetValueSettings = this.settings[setBehavior.id]; + setValueSettings = {...setValueSettings, actionLabel: setBehavior.name}; + const valueSetter = ValueSetter.fromSettings(this.ctx, setValueSettings); + this.valueSetters[setBehavior.id] = valueSetter; + this.valueActions.push(valueSetter); } } const initialState = Object.values(this.metadata.states).find(s => s.initial); @@ -404,6 +512,59 @@ export class ScadaObject { } } + private triggerAction(action: ScadaObjectElementAction) { + switch (action.actionType) { + case 'updateValue': + const targetValue = action.updateValueId; + const valueSubject = this.stateValueSubjects[targetValue]; + if (valueSubject) { + const currentVal = valueSubject.value; + let newValue: any; + switch (action.updateValueType) { + case 'toggle': + newValue = !currentVal; + break; + case 'increment': + if (isNumber(currentVal)) { + newValue = currentVal + action.updateValueInc; + } + break; + case 'constant': + newValue = action.updateValueConstant; + break; + } + if (isDefinedAndNotNull(newValue)) { + valueSubject.next(newValue); + const stateValue = this.metadata.stateValues[targetValue]; + if (stateValue.callBehavior) { + const observables: Observable[] = []; + for (const behavior of stateValue.callBehavior) { + if (behavior.condition(newValue)) { + const valueSetter = this.valueSetters[behavior.behaviorId]; + observables.push(valueSetter.setValue(newValue)); + } + } + if (observables.length) { + this.loadingSubject.next(true); + forkJoin(observables).subscribe( + { + next: () => { + this.loadingSubject.next(false); + }, + error: (err) => { + this.loadingSubject.next(false); + valueSubject.next(currentVal); + } + } + ); + } + } + } + } + break; + } + } + private resize() { let scale: number; if (this.targetWidth < this.targetHeight) { @@ -417,20 +578,23 @@ export class ScadaObject { private onValue(id: string, value: any) { const getBehavior = this.metadata.behavior.find(b => b.id === id) as ScadaObjectBehaviorGet; value = this.normalizeValue(value, getBehavior.valueType); - const updateStates = filterUpdateStates(getBehavior.onUpdate, value); - if (this.animationTimeline) { - this.animationTimeline.finish(); - } - for (const updateState of updateStates) { - const state = this.metadata.states[updateState.stateId]; - this.updateState(state, value); + for (const onUpdate of getBehavior.onUpdate) { + const targetStateValueId = onUpdate.updateValue; + this.stateValueSubjects[targetStateValueId].next(value); } } - private updateState(state: ScadaObjectState, value?: any) { + private updateState(state: ScadaObjectState) { if (state) { for (const elementState of state.state) { const tag = elementState.tag; + let value; + if (elementState.inputValue) { + value = this.stateValues[elementState.inputValue]; + } + if (elementState.animationTimeline) { + elementState.animationTimeline.finish(); + } const elements = this.svgShape.find(`[tb\\:tag="${tag}"]`); if (elements.length) { if (elementState.show) { @@ -443,10 +607,20 @@ export class ScadaObject { } }); } + if (elementState.addClass) { + elements.forEach(e => { + e.addClass(elementState.addClass); + }); + } + if (elementState.removeClass) { + elements.forEach(e => { + e.removeClass(elementState.removeClass); + }); + } if (elementState.attributes) { const attrs = this.computeAttributes(elementState.attributes, value); elements.forEach(e => { - this.setElementAttributes(e, attrs, elementState.animate); + this.setElementAttributes(elementState, e, attrs, elementState.animate); }); } if (elementState.text) { @@ -502,9 +676,9 @@ export class ScadaObject { return res; } - private setElementAttributes(element: Element, attrs: {[attr: string]: any}, animate?: number) { + private setElementAttributes(elementState: ScadaObjectElementState, element: Element, attrs: {[attr: string]: any}, animate?: number) { if (isDefinedAndNotNull(animate)) { - this.animation(element, animate).attr(attrs); + this.animation(elementState, element, animate).attr(attrs); } else { element.attr(attrs); } @@ -545,11 +719,11 @@ export class ScadaObject { } } - private animation(element: Element, duration: number): Runner { - if (!this.animationTimeline) { - this.animationTimeline = new Timeline(); + private animation(elementState: ScadaObjectElementState, element: Element, duration: number): Runner { + if (!elementState.animationTimeline) { + elementState.animationTimeline = new Timeline(); } - element.timeline(this.animationTimeline); + element.timeline(elementState.animationTimeline); return element.animate(duration, 0, 'now'); } @@ -561,7 +735,14 @@ export class ScadaObject { case 'constant': return objectValue.constantValue; case 'property': - return objectValue.computedPropertyValue; + const property = this.getProperty(objectValue.propertyId); + if (property.type === 'color-settings') { + const colorProcessor: ColorProcessor = objectValue.computedPropertyValue; + colorProcessor.update(value); + return colorProcessor.color; + } else { + return objectValue.computedPropertyValue; + } case 'function': try { return objectValue.valueConverter(value); @@ -584,11 +765,18 @@ export class ScadaObject { } } + private getProperty(id: string): ScadaObjectProperty { + return this.metadata.properties.find(p => p.id === id); + } + private getPropertyValue(id: string): any { - const property = this.metadata.properties.find(p => p.id === id); + const property = this.getProperty(id); if (property) { const value = this.settings[id]; if (isDefinedAndNotNull(value)) { + if (property.type === 'color-settings') { + return ColorProcessor.fromSettings(value); + } return value; } else { switch (property.type) { @@ -598,6 +786,8 @@ export class ScadaObject { return 0; case 'color': return '#000'; + case 'color-settings': + return constantColor('#000'); } } } else { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.component.html index 2907a951dd..972a5c532c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.component.html @@ -30,39 +30,54 @@ [widgetType]="widgetType" formControlName="{{ behaviour.id }}"> +
-
+
{{ propertyRow.label }}
{{ propertyRow.label }}
-
+
{{ property.subLabel }}
- + -
{{ property.fieldSuffix }}
+
{{ property.fieldSuffix }}
- + + + -
{{ property.fieldSuffix }}
+
{{ property.fieldSuffix }}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.models.ts index 2426b13a0b..07009a98cf 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.models.ts @@ -20,6 +20,7 @@ export interface ScadaPropertyRow { label: string; properties: ScadaObjectProperty[]; switch?: ScadaObjectProperty; + rowClass?: string; } export const toPropertyRows = (properties: ScadaObjectProperty[]): ScadaPropertyRow[] => { @@ -29,7 +30,8 @@ export const toPropertyRows = (properties: ScadaObjectProperty[]): ScadaProperty if (!propertyRow) { propertyRow = { label: property.name, - properties: [] + properties: [], + rowClass: property.rowClass }; result.push(propertyRow); } diff --git a/ui-ngx/src/assets/widget/scada/drawing.svg b/ui-ngx/src/assets/widget/scada/drawing.svg index 03849049f3..61e8d3f1ca 100644 --- a/ui-ngx/src/assets/widget/scada/drawing.svg +++ b/ui-ngx/src/assets/widget/scada/drawing.svg @@ -1,12 +1,71 @@ { "title": "My first SCADA Object", + "stateValues": { + "level": { + "initialValue": 0 + }, + "disabled": { + "initialValue": false + }, + "on": { + "initialValue": false, + "callBehavior": [ + { + "behaviorId": "onUpdateState", + "conditionFunction": "return value === true;" + }, + { + "behaviorId": "offUpdateState", + "conditionFunction": "return value === false;" + } + ] + } + }, + "actions": { + "onButton": { + "trigger": "click", + "actionType": "updateValue", + "updateValueId": "on", + "updateValueType": "constant", + "updateValueConstant": true + }, + "offButton": { + "trigger": "click", + "actionType": "updateValue", + "updateValueId": "on", + "updateValueType": "constant", + "updateValueConstant": false + } + }, "states": { "initialState": { "initial": true, "state": [ + { + "tag": "levelTitle", + "show": { + "type": "property", + "propertyId": "showLevelTitle" + }, + "text": { + "content": { + "type": "property", + "propertyId": "levelTitle" + }, + "font": { + "type": "property", + "propertyId": "levelTitleFont" + }, + "color": { + "type": "property", + "propertyId": "levelTitleColor" + } + } + }, { "tag": "level", + "inputValue": "level", "attributes": [ { "name": "fill", @@ -86,9 +145,11 @@ ] }, "updateLevelState": { + "triggerValues": ["level"], "state": [ { "tag": "level", + "inputValue": "level", "animate": 200, "attributes": [ { @@ -102,6 +163,7 @@ }, { "tag": "levelValue", + "inputValue": "level", "text": { "content": { "type": "valueFormat", @@ -118,7 +180,28 @@ } ] }, + "updateLevelBackgroundState": { + "triggerValues": ["disabled", "level"], + "conditionFunction": "return values.disabled === false;", + "state": [ + { + "tag": "level", + "inputValue": "level", + "attributes": [ + { + "name": "fill", + "value": { + "type": "property", + "propertyId": "levelBackground" + } + } + ] + } + ] + }, "disabledState": { + "triggerValues": ["disabled"], + "conditionFunction": "return values.disabled === true;", "state": [ { "tag": "level", @@ -134,16 +217,68 @@ } ] }, - "enabledState": { + "onState": { + "triggerValues": ["on"], + "conditionFunction": "return values.on === true;", "state": [ { - "tag": "level", + "tag": "fan", + "addClass": "fan-rotate" + }, + { + "tag": "onButtonBackground", "attributes": [ { "name": "fill", "value": { - "type": "property", - "propertyId": "levelBackground" + "type": "constant", + "constantValue": "#777" + } + } + ] + }, + { + "tag": "offButtonBackground", + "attributes": [ + { + "name": "fill", + "value": { + "type": "constant", + "constantValue": "#ed121f" + } + } + ] + } + ] + }, + "offState": { + "triggerValues": ["on"], + "conditionFunction": "return values.on === false;", + "state": [ + { + "tag": "fan", + "removeClass": "fan-rotate" + }, + { + "tag": "onButtonBackground", + "attributes": [ + { + "name": "fill", + "value": { + "type": "constant", + "constantValue": "#12ed19" + } + } + ] + }, + { + "tag": "offButtonBackground", + "attributes": [ + { + "name": "fill", + "value": { + "type": "constant", + "constantValue": "#777" } } ] @@ -160,10 +295,7 @@ "defaultValue": 0, "onUpdate": [ { - "matcher": { - "type": "any" - }, - "stateId": "updateLevelState" + "updateValue": "level" } ] }, @@ -175,35 +307,87 @@ "defaultValue": false, "onUpdate": [ { - "matcher": { - "type": "constant", - "value": true - }, - "stateId": "disabledState" - }, + "updateValue": "disabled" + } + ] + }, + { + "id": "onState", + "name": "On/Off state", + "type": "getValue", + "valueType": "BOOLEAN", + "defaultValue": false, + "onUpdate": [ { - "matcher": { - "type": "constant", - "value": false - }, - "stateId": "enabledState" + "updateValue": "on" } ] + }, + { + "id": "onUpdateState", + "name": "Power On", + "type": "setValue", + "valueToDataType": "CONSTANT", + "constantValue": true + }, + { + "id": "offUpdateState", + "name": "Power Off", + "type": "setValue", + "valueToDataType": "CONSTANT", + "constantValue": false } ], "properties": [ + { + "id": "showLevelTitle", + "name": "Level title", + "type": "switch", + "default": true, + "rowClass": "column-xs" + }, + { + "id": "levelTitle", + "name": "Level title", + "type": "string", + "default": "Level", + "disableOnProperty": "showLevelTitle", + "fieldClass": "flex" + }, + { + "id": "levelTitleFont", + "name": "Level title", + "type": "font", + "default": { + "size": 6, + "sizeUnit": "px", + "family": "Roboto", + "weight": "normal", + "style": "normal" + }, + "disableOnProperty": "showLevelTitle" + }, + { + "id": "levelTitleColor", + "name": "Level title", + "type": "color", + "default": "#000000", + "disableOnProperty": "showLevelTitle" + }, { "id": "showValue", "name": "Value", "type": "switch", - "default": true + "default": true, + "rowClass": "column-xs" }, { "id": "valueUnits", "name": "Value", "type": "units", "default": "", - "disableOnProperty": "showValue" + "disableOnProperty": "showValue", + "fieldClass": "flex" }, { "id": "valueDecimals", @@ -214,7 +398,8 @@ "max": 15, "step": 1, "fieldSuffix": "decimals", - "disableOnProperty": "showValue" + "disableOnProperty": "showValue", + "fieldClass": "flex" }, { "id": "valueFont", @@ -243,7 +428,9 @@ "type": "number", "required": true, "default": 0, - "min": 0 + "min": 0, + "rowClass": "column-xs", + "fieldClass": "flex-xs" }, { "id": "maxLevel", @@ -252,7 +439,8 @@ "type": "number", "required": true, "default": 100, - "min": 0 + "min": 0, + "fieldClass": "flex-xs" }, { "id": "showMinMaxLevel", @@ -284,9 +472,14 @@ "id": "levelBackground", "name": "Level background", "subLabel": "Enabled", - "type": "color", - "default": "#1abb48", - "divider": true + "type": "color-settings", + "default": { + "type": "constant", + "color": "#1abb48", + "colorFunction": "return value > 70 ? '#d5280d' : '#1abb48';" + }, + "divider": true, + "rowClass": "column-xs" }, { "id": "disabledLevelBackground", @@ -297,11 +490,32 @@ } ] } + min - N/A - max + + N/A + Level + max + + + + + + On + + + + Off + From ebce8698e346eab5cf1fe5c4369850573f7cc664 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Tue, 7 May 2024 19:58:48 +0300 Subject: [PATCH 022/442] UI: Refactoring. --- ui-ngx/src/app/core/api/widget-api.models.ts | 3 +- .../basic/basic-widget-config.module.ts | 10 +- .../scada-test-basic-config.component.html | 26 - .../svg/iot-svg-basic-config.component.html | 48 ++ .../iot-svg-basic-config.component.ts} | 55 +- .../widget/lib/scada/scada.models.ts | 797 ------------------ .../iot-svg-object-settings.component.html} | 15 +- .../iot-svg-object-settings.component.ts} | 34 +- .../iot-svg-object-settings.models.ts} | 8 +- .../common/widget-settings-common.module.ts | 8 +- .../iot-svg-widget.component.html} | 9 +- .../iot-svg-widget.component.scss} | 34 +- .../iot-svg-widget.component.ts} | 59 +- .../iot-svg-widget.models.ts} | 23 +- .../widget/lib/svg/iot-svg.models.ts | 634 ++++++++++++++ .../widget/widget-components.module.ts | 6 +- .../components/widget/widget.component.ts | 35 +- .../assets/widget/{scada => svg}/drawing.svg | 382 ++------- 18 files changed, 970 insertions(+), 1216 deletions(-) delete mode 100644 ui-ngx/src/app/modules/home/components/widget/config/basic/scada/scada-test-basic-config.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/config/basic/svg/iot-svg-basic-config.component.html rename ui-ngx/src/app/modules/home/components/widget/config/basic/{scada/scada-test-basic-config.component.ts => svg/iot-svg-basic-config.component.ts} (50%) delete mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/scada/scada.models.ts rename ui-ngx/src/app/modules/home/components/widget/lib/settings/common/{scada/scada-object-settings.component.html => svg/iot-svg-object-settings.component.html} (87%) rename ui-ngx/src/app/modules/home/components/widget/lib/settings/common/{scada/scada-object-settings.component.ts => svg/iot-svg-object-settings.component.ts} (88%) rename ui-ngx/src/app/modules/home/components/widget/lib/settings/common/{scada/scada-object-settings.models.ts => svg/iot-svg-object-settings.models.ts} (82%) rename ui-ngx/src/app/modules/home/components/widget/lib/{scada/scada-test-widget.component.html => svg/iot-svg-widget.component.html} (57%) rename ui-ngx/src/app/modules/home/components/widget/lib/{scada/scada-test-widget.component.scss => svg/iot-svg-widget.component.scss} (54%) rename ui-ngx/src/app/modules/home/components/widget/lib/{scada/scada-test-widget.component.ts => svg/iot-svg-widget.component.ts} (51%) rename ui-ngx/src/app/modules/home/components/widget/lib/{scada/scada-test-widget.models.ts => svg/iot-svg-widget.models.ts} (50%) create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts rename ui-ngx/src/assets/widget/{scada => svg}/drawing.svg (54%) diff --git a/ui-ngx/src/app/core/api/widget-api.models.ts b/ui-ngx/src/app/core/api/widget-api.models.ts index 1fcd18bd93..0300c89d7f 100644 --- a/ui-ngx/src/app/core/api/widget-api.models.ts +++ b/ui-ngx/src/app/core/api/widget-api.models.ts @@ -23,7 +23,7 @@ import { DatasourceType, KeyInfo, LegendConfig, - LegendData, TargetDevice, + LegendData, TargetDevice, WidgetAction, WidgetActionDescriptor, widgetType } from '@shared/models/widget.models'; @@ -95,6 +95,7 @@ export interface WidgetActionsApi { getActionDescriptors: (actionSourceId: string) => Array; handleWidgetAction: ($event: Event, descriptor: WidgetActionDescriptor, entityId?: EntityId, entityName?: string, additionalParams?: any, entityLabel?: string) => void; + onWidgetAction: ($event: Event, action: WidgetAction) => void; elementClick: ($event: Event) => void; cardClick: ($event: Event) => void; click: ($event: Event) => void; diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts index 79af9bee18..6f83f9ac25 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts @@ -129,9 +129,7 @@ import { import { RadarChartBasicConfigComponent } from '@home/components/widget/config/basic/chart/radar-chart-basic-config.component'; -import { - ScadaTestBasicConfigComponent -} from '@home/components/widget/config/basic/scada/scada-test-basic-config.component'; +import { IotSvgBasicConfigComponent } from '@home/components/widget/config/basic/svg/iot-svg-basic-config.component'; @NgModule({ declarations: [ @@ -175,7 +173,7 @@ import { BarChartBasicConfigComponent, PolarAreaChartBasicConfigComponent, RadarChartBasicConfigComponent, - ScadaTestBasicConfigComponent + IotSvgBasicConfigComponent ], imports: [ CommonModule, @@ -221,7 +219,7 @@ import { BarChartBasicConfigComponent, PolarAreaChartBasicConfigComponent, RadarChartBasicConfigComponent, - ScadaTestBasicConfigComponent + IotSvgBasicConfigComponent ] }) export class BasicWidgetConfigModule { @@ -261,5 +259,5 @@ export const basicWidgetConfigComponentsMap: {[key: string]: Type - - - - - diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/svg/iot-svg-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/svg/iot-svg-basic-config.component.html new file mode 100644 index 0000000000..f0dfeacf66 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/svg/iot-svg-basic-config.component.html @@ -0,0 +1,48 @@ + + + + + +
+
widget-config.card-appearance
+
+
{{ 'widgets.background.background' | translate }}
+ + +
+
+
widget-config.show-card-buttons
+ + {{ 'fullscreen.fullscreen' | translate }} + +
+
+
{{ 'widget-config.card-border-radius' | translate }}
+ + + +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/scada/scada-test-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/svg/iot-svg-basic-config.component.ts similarity index 50% rename from ui-ngx/src/app/modules/home/components/widget/config/basic/scada/scada-test-basic-config.component.ts rename to ui-ngx/src/app/modules/home/components/widget/config/basic/svg/iot-svg-basic-config.component.ts index 3d6a4a6a45..cccdf03aba 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/scada/scada-test-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/svg/iot-svg-basic-config.component.ts @@ -20,22 +20,26 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { BasicWidgetConfigComponent } from '@home/components/widget/config/widget-config.component.models'; import { WidgetConfigComponentData } from '@home/models/widget-component.models'; -import { TargetDevice, } from '@shared/models/widget.models'; +import { TargetDevice, WidgetConfig, } from '@shared/models/widget.models'; import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; -import { ScadaTestWidgetSettings } from '@home/components/widget/lib/scada/scada-test-widget.models'; +import { + iotSvgWidgetDefaultSettings, + IotSvgWidgetSettings +} from '@home/components/widget/lib/svg/iot-svg-widget.models'; +import { isUndefined } from '@core/utils'; @Component({ - selector: 'tb-scada-test-basic-config', - templateUrl: './scada-test-basic-config.component.html', + selector: 'tb-iot-svg-basic-config', + templateUrl: './iot-svg-basic-config.component.html', styleUrls: ['../basic-config.scss'] }) -export class ScadaTestBasicConfigComponent extends BasicWidgetConfigComponent { +export class IotSvgBasicConfigComponent extends BasicWidgetConfigComponent { get targetDevice(): TargetDevice { - return this.scadaTestWidgetConfigForm.get('targetDevice').value; + return this.iotSvgWidgetConfigForm.get('targetDevice').value; } - scadaTestWidgetConfigForm: UntypedFormGroup; + iotSvgWidgetConfigForm: UntypedFormGroup; constructor(protected store: Store, protected widgetConfigComponent: WidgetConfigComponent, @@ -44,21 +48,48 @@ export class ScadaTestBasicConfigComponent extends BasicWidgetConfigComponent { } protected configForm(): UntypedFormGroup { - return this.scadaTestWidgetConfigForm; + return this.iotSvgWidgetConfigForm; } protected onConfigSet(configData: WidgetConfigComponentData) { - const settings: ScadaTestWidgetSettings = {...(configData.config.settings as ScadaTestWidgetSettings || { scadaObject: {}})}; - this.scadaTestWidgetConfigForm = this.fb.group({ + const settings: IotSvgWidgetSettings = {...iotSvgWidgetDefaultSettings, ...(configData.config.settings || {})}; + this.iotSvgWidgetConfigForm = this.fb.group({ targetDevice: [configData.config.targetDevice, []], - scadaObject: [settings.scadaObject, []] + iotSvg: [settings.iotSvg, []], + iotSvgObject: [settings.iotSvgObject, []], + + background: [settings.background, []], + + cardButtons: [this.getCardButtons(configData.config), []], + borderRadius: [configData.config.borderRadius, []], + + actions: [configData.config.actions || {}, []] }); } protected prepareOutputConfig(config: any): WidgetConfigComponentData { this.widgetConfig.config.targetDevice = config.targetDevice; this.widgetConfig.config.settings = this.widgetConfig.config.settings || {}; - this.widgetConfig.config.settings.scadaObject = config.scadaObject; + this.widgetConfig.config.settings.iotSvg = config.iotSvg; + this.widgetConfig.config.settings.iotSvgObject = config.iotSvgObject; + this.widgetConfig.config.settings.background = config.background; + + this.setCardButtons(config.cardButtons, this.widgetConfig.config); + this.widgetConfig.config.borderRadius = config.borderRadius; + + this.widgetConfig.config.actions = config.actions; return this.widgetConfig; } + + private getCardButtons(config: WidgetConfig): string[] { + const buttons: string[] = []; + if (isUndefined(config.enableFullscreen) || config.enableFullscreen) { + buttons.push('fullscreen'); + } + return buttons; + } + + private setCardButtons(buttons: string[], config: WidgetConfig) { + config.enableFullscreen = buttons.includes('fullscreen'); + } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada.models.ts deleted file mode 100644 index d0aeac27a7..0000000000 --- a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada.models.ts +++ /dev/null @@ -1,797 +0,0 @@ -/// -/// Copyright ยฉ 2016-2024 The Thingsboard Authors -/// -/// Licensed under the Apache License, Version 2.0 (the "License"); -/// you may not use this file except in compliance with the License. -/// You may obtain a copy of the License at -/// -/// http://www.apache.org/licenses/LICENSE-2.0 -/// -/// Unless required by applicable law or agreed to in writing, software -/// distributed under the License is distributed on an "AS IS" BASIS, -/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -/// See the License for the specific language governing permissions and -/// limitations under the License. -/// - -import { ValueType } from '@shared/models/constants'; -import { Box, Element, Runner, Svg, SVG, Timeline, Text } from '@svgdotjs/svg.js'; -import { - DataToValueType, - GetValueAction, - GetValueSettings, SetValueAction, - SetValueSettings, ValueToDataType -} from '@shared/models/action-widget-settings.models'; -import { - formatValue, - insertVariable, - isDefinedAndNotNull, - isNumber, - isNumeric, - isUndefinedOrNull, - mergeDeep, - parseFunction -} from '@core/utils'; -import { BehaviorSubject, forkJoin, Observable } from 'rxjs'; -import { map, share } from 'rxjs/operators'; -import { ValueAction, ValueGetter, ValueSetter } from '@home/components/widget/lib/action/action-widget.models'; -import { WidgetContext } from '@home/models/widget-component.models'; -import { ColorProcessor, constantColor, Font } from '@shared/models/widget-settings.models'; -import { AttributeScope } from '@shared/models/telemetry/telemetry.models'; - -export type ScadaObjectValueType = 'input' | 'constant' | 'property' | 'function' | 'valueFormat'; - -export interface ScadaObjectValueBase { - type: ScadaObjectValueType; -} - -export interface ScadaObjectValueConstant extends ScadaObjectValueBase { - constantValue?: any; -} - -export interface ScadaObjectValueProperty extends ScadaObjectValueBase { - propertyId?: string; - computedPropertyValue?: any; -} - -export interface ScadaObjectValueFunction extends ScadaObjectValueBase { - valueConvertFunction?: string; - valueConverter?: (val: any) => any; -} - -export interface ScadaObjectValueFormat extends ScadaObjectValueBase { - units?: ScadaObjectValue; - decimals?: ScadaObjectValue; - computedUnits?: string; - computedDecimals?: number; -} - -export type ScadaObjectValue = ScadaObjectValueProperty & ScadaObjectValueConstant & ScadaObjectValueFunction & ScadaObjectValueFormat; - -export interface ScadaObjectAttribute { - name: string; - value: ScadaObjectValue; -} - -export interface ScadaObjectText { - content?: ScadaObjectValue; - font?: ScadaObjectValue; - color?: ScadaObjectValue; -} - -export interface ScadaObjectElementState { - tag: string; - inputValue?: string; - show?: ScadaObjectValue; - text?: ScadaObjectText; - attributes?: ScadaObjectAttribute[]; - animate?: number; - addClass?: string; - removeClass?: string; - animationTimeline?: Timeline; -} - -export interface ScadaObjectState { - initial?: boolean; - triggerValues?: string[]; - conditionFunction?: string; - condition?: (values: {[key: string]: any}) => boolean; - state: ScadaObjectElementState[]; -} - -export interface ScadaObjectUpdateState { - updateValue: string; -} - -export enum ScadaObjectBehaviorType { - setValue = 'setValue', - getValue = 'getValue' -} - -export interface ScadaObjectBehaviorBase { - id: string; - name: string; - type: ScadaObjectBehaviorType; -} - -export interface ScadaObjectBehaviorGet extends ScadaObjectBehaviorBase { - valueType: ValueType; - defaultValue: any; - onUpdate: ScadaObjectUpdateState[]; -} - -export interface ScadaObjectBehaviorSet extends ScadaObjectBehaviorBase { - valueToDataType: ValueToDataType; - constantValue: any; - valueToDataFunction: string; -} - -export type ScadaObjectBehavior = ScadaObjectBehaviorGet | ScadaObjectBehaviorSet; - -export type ScadaObjectPropertyType = 'string' | 'number' | 'color' | 'color-settings' | 'font' | 'units' | 'switch'; - -export interface ScadaObjectPropertyBase { - id: string; - name: string; - type: ScadaObjectPropertyType; - default: any; - required?: boolean; - subLabel?: string; - divider?: boolean; - fieldSuffix?: string; - disableOnProperty?: string; - rowClass?: string; - fieldClass?: string; -} - -export interface ScadaObjectNumberProperty extends ScadaObjectPropertyBase { - min?: number; - max?: number; - step?: number; -} - -export type ScadaObjectProperty = ScadaObjectPropertyBase & ScadaObjectNumberProperty; - -export interface ScadaObjectCallBehavior { - behaviorId: string; - conditionFunction?: string; - condition?: (value: any) => boolean; -} - -export interface ScadaObjectStateValue { - initialValue: any; - callBehavior?: ScadaObjectCallBehavior[]; -} - -export type ScadaObjectElementActionTrigger = 'click'; - -export type ScadaObjectActionType = 'updateValue'; -export type ScadaObjectActionUpdateValueType = 'toggle' | 'increment' | 'constant'; - -export interface ScadaObjectElementAction { - trigger: ScadaObjectElementActionTrigger; - enabledTriggerValues?: string[]; - enabledConditionFunction?: string; - enabledCondition?: (values: {[key: string]: any}) => boolean; - actionType: ScadaObjectActionType; - updateValueId?: string; - updateValueType?: ScadaObjectActionUpdateValueType; - updateValueConstant?: any; - updateValueInc?: number; -} - -export interface ScadaObjectMetadata { - title: string; - stateValues: {[id: string]: ScadaObjectStateValue}; - actions: {[tag: string]: ScadaObjectElementAction}; - states: {[id: string]: ScadaObjectState}; - behavior: ScadaObjectBehavior[]; - properties: ScadaObjectProperty[]; -} - -export const emptyMetadata: ScadaObjectMetadata = { - title: '', - stateValues: {}, - actions: {}, - states: {}, - behavior: [], - properties: [] -}; - - -export const parseScadaObjectMetadataFromContent = (svgContent: string): ScadaObjectMetadata => { - try { - const svgDoc = new DOMParser().parseFromString(svgContent, 'image/svg+xml'); - return parseScadaObjectMetadataFromDom(svgDoc); - } catch (_e) { - return emptyMetadata; - } -}; - -const parseScadaObjectMetadataFromDom = (svgDoc: Document): ScadaObjectMetadata => { - try { - const elements = svgDoc.getElementsByTagName('tb:metadata'); - if (elements.length) { - return JSON.parse(elements[0].innerHTML); - } else { - return emptyMetadata; - } - } catch (_e) { - console.error(_e); - return emptyMetadata; - } -}; - -const defaultGetValueSettings = (get: ScadaObjectBehaviorGet): GetValueSettings => ({ - action: GetValueAction.DO_NOTHING, - defaultValue: get.defaultValue, - executeRpc: { - method: 'getState', - requestTimeout: 5000, - requestPersistent: false, - persistentPollingInterval: 1000 - }, - getAttribute: { - key: 'state', - scope: null - }, - getTimeSeries: { - key: 'state' - }, - dataToValue: { - type: DataToValueType.NONE, - compareToValue: true, - dataToValueFunction: '/* Should return boolean value */\nreturn data;' - } - }); - -const defaultSetValueSettings = (set: ScadaObjectBehaviorSet): SetValueSettings => ({ - action: SetValueAction.EXECUTE_RPC, - executeRpc: { - method: 'setState', - requestTimeout: 5000, - requestPersistent: false, - persistentPollingInterval: 1000 - }, - setAttribute: { - key: 'state', - scope: AttributeScope.SERVER_SCOPE - }, - putTimeSeries: { - key: 'state' - }, - valueToData: { - type: set.valueToDataType, - constantValue: set.constantValue, - valueToDataFunction: set.valueToDataFunction ? set.valueToDataFunction : - '/* Convert input boolean value to RPC parameters or attribute/time-series value */\nreturn value;' - } -}); - -export const defaultScadaObjectSettings = (metadata: ScadaObjectMetadata): ScadaObjectSettings => { - const settings: ScadaObjectSettings = {}; - for (const behavior of metadata.behavior) { - if (behavior.type === ScadaObjectBehaviorType.getValue) { - settings[behavior.id] = defaultGetValueSettings(behavior as ScadaObjectBehaviorGet); - } else if (behavior.type === ScadaObjectBehaviorType.setValue) { - settings[behavior.id] = defaultSetValueSettings(behavior as ScadaObjectBehaviorSet); - } - } - for (const property of metadata.properties) { - settings[property.id] = property.default; - } - return settings; -}; - -export type ScadaObjectSettings = {[id: string]: any}; - -export class ScadaObject { - - private metadata: ScadaObjectMetadata; - private settings: ScadaObjectSettings; - - private rootElement: HTMLElement; - private svgShape: Svg; - private box: Box; - private targetWidth: number; - private targetHeight: number; - - private loadingSubject = new BehaviorSubject(false); - private valueGetters: ValueGetter[] = []; - private valueActions: ValueAction[] = []; - private valueSetters: {[behaviorId: string]: ValueSetter} = {}; - - private stateValueSubjects: {[id: string]: BehaviorSubject} = {}; - private stateValues: {[id: string]: any} = {}; - - loading$ = this.loadingSubject.asObservable().pipe(share()); - - constructor(private ctx: WidgetContext, - private svgPath: string, - private inputSettings: ScadaObjectSettings) {} - - public init(): Observable { - return this.ctx.http.get(this.svgPath, {responseType: 'text'}).pipe( - map((inputSvgContent) => { - const doc: XMLDocument = new DOMParser().parseFromString(inputSvgContent, 'image/svg+xml'); - this.metadata = parseScadaObjectMetadataFromDom(doc); - const defaults = defaultScadaObjectSettings(this.metadata); - this.settings = mergeDeep({}, defaults, this.inputSettings || {}); - this.prepareMetadata(); - this.prepareSvgShape(doc); - this.initStates(); - }) - ); - } - - public addTo(element: HTMLElement) { - this.rootElement = element; - if (this.svgShape) { - this.svgShape.addTo(element); - } - } - - public destroy() { - for (const stateValueId of Object.keys(this.stateValueSubjects)) { - this.stateValueSubjects[stateValueId].complete(); - this.stateValueSubjects[stateValueId].unsubscribe(); - } - this.valueActions.forEach(v => v.destroy()); - this.loadingSubject.complete(); - this.loadingSubject.unsubscribe(); - } - - public setSize(targetWidth: number, targetHeight: number) { - this.targetWidth = targetWidth; - this.targetHeight = targetHeight; - if (this.svgShape) { - this.resize(); - } - } - - private prepareMetadata() { - for (const stateValueId of Object.keys(this.metadata.stateValues)) { - const stateValue = this.metadata.stateValues[stateValueId]; - if (stateValue.callBehavior) { - stateValue.callBehavior.forEach(callBehavior => { - let condition = () => true; - if (callBehavior.conditionFunction) { - try { - condition = parseFunction(this.insertVariables(callBehavior.conditionFunction), ['value']); - } catch (e) { - condition = () => false; - } - } - callBehavior.condition = condition; - }); - } - } - for (const stateId of Object.keys(this.metadata.states)) { - const state = this.metadata.states[stateId]; - let condition = () => true; - if (state.conditionFunction) { - try { - condition = parseFunction(this.insertVariables(state.conditionFunction), ['values']); - } catch (e) { - condition = () => false; - } - } - state.condition = condition; - for (const elementState of state.state) { - this.prepareValue(elementState.show); - this.prepareValue(elementState.text?.content); - this.prepareValue(elementState.text?.font); - this.prepareValue(elementState.text?.color); - if (elementState.attributes) { - for (const attribute of elementState.attributes) { - this.prepareValue(attribute.value); - } - } - } - } - } - - private prepareValue(value: ScadaObjectValue) { - if (value) { - if (value.type === 'function' && value.valueConvertFunction) { - try { - value.valueConverter = parseFunction(this.insertVariables(value.valueConvertFunction), ['value']); - } catch (e) { - value.valueConverter = (v) => v; - } - } else if (value.type === 'property') { - value.computedPropertyValue = this.getPropertyValue(value.propertyId); - } else if (value.type === 'valueFormat') { - if (value.units) { - this.prepareValue(value.units); - } - if (value.decimals) { - this.prepareValue(value.decimals); - } - } - } - } - - private insertVariables(content: string): string { - for (const property of this.metadata.properties) { - const value = this.getPropertyValue(property.id); - content = insertVariable(content, property.id, value); - } - return content; - } - - private prepareSvgShape(doc: XMLDocument) { - const elements = doc.getElementsByTagName('tb:metadata'); - for (let i=0;i(stateValue.initialValue); - this.stateValues[stateValueId] = stateValue.initialValue; - this.stateValueSubjects[stateValueId].subscribe(val => { - this.stateValues[stateValueId] = val; - const states = Object.values(this.metadata.states); - const triggerStates = states.filter(s => s.triggerValues && s.triggerValues.includes(stateValueId)); - for (const state of triggerStates) { - if (state.condition(this.stateValues)) { - this.updateState(state); - } - } - }); - } - for (const tag of Object.keys(this.metadata.actions)) { - const action = this.metadata.actions[tag]; - const elements = this.svgShape.find(`[tb\\:tag="${tag}"]`); - switch (action.trigger) { - case 'click': - elements.forEach(e => { - e.attr('cursor', 'pointer'); - e.on('click', () => { - this.triggerAction(action); - }); - }); - break; - } - } - for (const behavior of this.metadata.behavior) { - if (behavior.type === ScadaObjectBehaviorType.getValue) { - const getBehavior = behavior as ScadaObjectBehaviorGet; - let getValueSettings: GetValueSettings = this.settings[getBehavior.id]; - getValueSettings = {...getValueSettings, actionLabel: getBehavior.name}; - const valueGetter = - ValueGetter.fromSettings(this.ctx, getValueSettings, getBehavior.valueType, { - next: (val) => {this.onValue(getBehavior.id, val);}, - error: (e) => {} - }); - this.valueGetters.push(valueGetter); - this.valueActions.push(valueGetter); - } else if (behavior.type === ScadaObjectBehaviorType.setValue) { - const setBehavior = behavior as ScadaObjectBehaviorSet; - let setValueSettings: SetValueSettings = this.settings[setBehavior.id]; - setValueSettings = {...setValueSettings, actionLabel: setBehavior.name}; - const valueSetter = ValueSetter.fromSettings(this.ctx, setValueSettings); - this.valueSetters[setBehavior.id] = valueSetter; - this.valueActions.push(valueSetter); - } - } - const initialState = Object.values(this.metadata.states).find(s => s.initial); - if (initialState) { - this.updateState(initialState); - } - if (this.valueGetters.length) { - const getValueObservables: Array> = []; - this.valueGetters.forEach(valueGetter => { - getValueObservables.push(valueGetter.getValue()); - }); - this.loadingSubject.next(true); - forkJoin(getValueObservables).subscribe( - { - next: () => { - this.loadingSubject.next(false); - }, - error: () => { - this.loadingSubject.next(false); - } - } - ); - } - } - - private triggerAction(action: ScadaObjectElementAction) { - switch (action.actionType) { - case 'updateValue': - const targetValue = action.updateValueId; - const valueSubject = this.stateValueSubjects[targetValue]; - if (valueSubject) { - const currentVal = valueSubject.value; - let newValue: any; - switch (action.updateValueType) { - case 'toggle': - newValue = !currentVal; - break; - case 'increment': - if (isNumber(currentVal)) { - newValue = currentVal + action.updateValueInc; - } - break; - case 'constant': - newValue = action.updateValueConstant; - break; - } - if (isDefinedAndNotNull(newValue)) { - valueSubject.next(newValue); - const stateValue = this.metadata.stateValues[targetValue]; - if (stateValue.callBehavior) { - const observables: Observable[] = []; - for (const behavior of stateValue.callBehavior) { - if (behavior.condition(newValue)) { - const valueSetter = this.valueSetters[behavior.behaviorId]; - observables.push(valueSetter.setValue(newValue)); - } - } - if (observables.length) { - this.loadingSubject.next(true); - forkJoin(observables).subscribe( - { - next: () => { - this.loadingSubject.next(false); - }, - error: (err) => { - this.loadingSubject.next(false); - valueSubject.next(currentVal); - } - } - ); - } - } - } - } - break; - } - } - - private resize() { - let scale: number; - if (this.targetWidth < this.targetHeight) { - scale = this.targetWidth / this.box.width; - } else { - scale = this.targetHeight / this.box.height; - } - this.svgShape.node.style.transform = `scale(${scale})`; - } - - private onValue(id: string, value: any) { - const getBehavior = this.metadata.behavior.find(b => b.id === id) as ScadaObjectBehaviorGet; - value = this.normalizeValue(value, getBehavior.valueType); - for (const onUpdate of getBehavior.onUpdate) { - const targetStateValueId = onUpdate.updateValue; - this.stateValueSubjects[targetStateValueId].next(value); - } - } - - private updateState(state: ScadaObjectState) { - if (state) { - for (const elementState of state.state) { - const tag = elementState.tag; - let value; - if (elementState.inputValue) { - value = this.stateValues[elementState.inputValue]; - } - if (elementState.animationTimeline) { - elementState.animationTimeline.finish(); - } - const elements = this.svgShape.find(`[tb\\:tag="${tag}"]`); - if (elements.length) { - if (elementState.show) { - const show: boolean = this.computeValue(elementState.show, value); - elements.forEach(e => { - if (show) { - e.show(); - } else { - e.hide(); - } - }); - } - if (elementState.addClass) { - elements.forEach(e => { - e.addClass(elementState.addClass); - }); - } - if (elementState.removeClass) { - elements.forEach(e => { - e.removeClass(elementState.removeClass); - }); - } - if (elementState.attributes) { - const attrs = this.computeAttributes(elementState.attributes, value); - elements.forEach(e => { - this.setElementAttributes(elementState, e, attrs, elementState.animate); - }); - } - if (elementState.text) { - if (elementState.text.content) { - const text: string = this.computeValue(elementState.text.content, value); - elements.forEach(e => { - this.setElementText(e, text); - }); - } - if (elementState.text.font || elementState.text.color) { - let font: Font = this.computeValue(elementState.text.font, value); - if (typeof font !== 'object') { - font = undefined; - } - let color: string = this.computeValue(elementState.text.color, value); - if (typeof color !== 'string') { - color = undefined; - } - elements.forEach(e => { - this.setElementFont(e, font, color); - }); - } - } - } - } - } - } - - private normalizeValue(value: any, type: ValueType): any { - if (isUndefinedOrNull(value)) { - switch (type) { - case ValueType.STRING: - return ''; - case ValueType.INTEGER: - case ValueType.DOUBLE: - return 0; - case ValueType.BOOLEAN: - return false; - case ValueType.JSON: - return {}; - } - } else { - return value; - } - } - - private computeAttributes(attributes: ScadaObjectAttribute[], value: any): {[attr: string]: any} { - const res: {[attr: string]: any} = {}; - for (const attribute of attributes) { - const attr = attribute.name; - res[attr] = this.computeValue(attribute.value, value); - } - return res; - } - - private setElementAttributes(elementState: ScadaObjectElementState, element: Element, attrs: {[attr: string]: any}, animate?: number) { - if (isDefinedAndNotNull(animate)) { - this.animation(elementState, element, animate).attr(attrs); - } else { - element.attr(attrs); - } - } - - private setElementText(element: Element, text: string) { - let textElement: Text; - if (element.type === 'text') { - const children = element.children(); - if (children.length && children[0].type === 'tspan') { - textElement = children[0] as Text; - } else { - textElement = element as Text; - } - } else if (element.type === 'tspan') { - textElement = element as Text; - } - if (textElement) { - textElement.text(text); - } - } - - private setElementFont(element: Element, font: Font, color: string) { - if (element.type === 'text') { - const textElement = element as Text; - if (font) { - textElement.font({ - family: font.family, - size: (isDefinedAndNotNull(font.size) && isDefinedAndNotNull(font.sizeUnit)) ? - font.size + font.sizeUnit : null, - weight: font.weight, - style: font.style - }); - } - if (color) { - textElement.fill(color); - } - } - } - - private animation(elementState: ScadaObjectElementState, element: Element, duration: number): Runner { - if (!elementState.animationTimeline) { - elementState.animationTimeline = new Timeline(); - } - element.timeline(elementState.animationTimeline); - return element.animate(duration, 0, 'now'); - } - - private computeValue(objectValue: ScadaObjectValue, value: any): any { - if (objectValue) { - switch (objectValue.type) { - case 'input': - return value; - case 'constant': - return objectValue.constantValue; - case 'property': - const property = this.getProperty(objectValue.propertyId); - if (property.type === 'color-settings') { - const colorProcessor: ColorProcessor = objectValue.computedPropertyValue; - colorProcessor.update(value); - return colorProcessor.color; - } else { - return objectValue.computedPropertyValue; - } - case 'function': - try { - return objectValue.valueConverter(value); - } catch (_e) { - return value; - } - case 'valueFormat': - let units = ''; - let decimals = 0; - if (objectValue.units) { - units = this.computeValue(objectValue.units, value); - } - if (objectValue.decimals) { - decimals = this.computeValue(objectValue.decimals, value); - } - return formatValue(value, decimals, units, false); - } - } else { - return ''; - } - } - - private getProperty(id: string): ScadaObjectProperty { - return this.metadata.properties.find(p => p.id === id); - } - - private getPropertyValue(id: string): any { - const property = this.getProperty(id); - if (property) { - const value = this.settings[id]; - if (isDefinedAndNotNull(value)) { - if (property.type === 'color-settings') { - return ColorProcessor.fromSettings(value); - } - return value; - } else { - switch (property.type) { - case 'string': - return ''; - case 'number': - return 0; - case 'color': - return '#000'; - case 'color-settings': - return constantColor('#000'); - } - } - } else { - return ''; - } - } -} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/svg/iot-svg-object-settings.component.html similarity index 87% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.component.html rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/common/svg/iot-svg-object-settings.component.html index 972a5c532c..6d4345825e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/svg/iot-svg-object-settings.component.html @@ -21,7 +21,7 @@
widgets.slider.behavior
{{ behaviour.name }}
- - + formControlName="{{ behaviour.id }}"> + + +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/svg/iot-svg-object-settings.component.ts similarity index 88% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.component.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/common/svg/iot-svg-object-settings.component.ts index 3e0bd7409d..32cec1be22 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/svg/iot-svg-object-settings.component.ts @@ -31,10 +31,10 @@ import { AppState } from '@core/core.state'; import { defaultScadaObjectSettings, parseScadaObjectMetadataFromContent, - ScadaObjectBehaviorType, - ScadaObjectMetadata, - ScadaObjectSettings -} from '@home/components/widget/lib/scada/scada.models'; + IotSvgBehaviorType, + IotSvgMetadata, + IotSvgObjectSettings +} from '@home/components/widget/lib/svg/iot-svg.models'; import { HttpClient } from '@angular/common/http'; import { ValueType } from '@shared/models/constants'; import { IAliasController } from '@core/api/widget-api.models'; @@ -43,35 +43,36 @@ import { isDefinedAndNotNull } from '@core/utils'; import { ScadaPropertyRow, toPropertyRows -} from '@home/components/widget/lib/settings/common/scada/scada-object-settings.models'; +} from '@home/components/widget/lib/settings/common/svg/iot-svg-object-settings.models'; import { merge, Observable, Subscription } from 'rxjs'; +import { WidgetActionCallbacks } from '@home/components/widget/action/manage-widget-actions.component.models'; @Component({ - selector: 'tb-scada-object-settings', - templateUrl: './scada-object-settings.component.html', + selector: 'tb-iot-svg-object-settings', + templateUrl: './iot-svg-object-settings.component.html', styleUrls: ['./../../widget-settings.scss'], providers: [ { provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => ScadaObjectSettingsComponent), + useExisting: forwardRef(() => IotSvgObjectSettingsComponent), multi: true }, { provide: NG_VALIDATORS, - useExisting: forwardRef(() => ScadaObjectSettingsComponent), + useExisting: forwardRef(() => IotSvgObjectSettingsComponent), multi: true } ] }) -export class ScadaObjectSettingsComponent implements OnInit, OnChanges, ControlValueAccessor, Validator { +export class IotSvgObjectSettingsComponent implements OnInit, OnChanges, ControlValueAccessor, Validator { - ScadaObjectBehaviorType = ScadaObjectBehaviorType; + IotSvgBehaviorType = IotSvgBehaviorType; @Input() disabled: boolean; @Input() - svgPath = '/assets/widget/scada/drawing.svg'; + svgPath = 'drawing.svg'; @Input() aliasController: IAliasController; @@ -79,10 +80,13 @@ export class ScadaObjectSettingsComponent implements OnInit, OnChanges, ControlV @Input() targetDevice: TargetDevice; + @Input() + callbacks: WidgetActionCallbacks; + @Input() widgetType: widgetType; - private modelValue: ScadaObjectSettings; + private modelValue: IotSvgObjectSettings; private propagateChange = null; @@ -91,7 +95,7 @@ export class ScadaObjectSettingsComponent implements OnInit, OnChanges, ControlV public scadaObjectSettingsFormGroup: UntypedFormGroup; - metadata: ScadaObjectMetadata; + metadata: IotSvgMetadata; propertyRows: ScadaPropertyRow[]; constructor(protected store: Store, @@ -136,7 +140,7 @@ export class ScadaObjectSettingsComponent implements OnInit, OnChanges, ControlV } } - writeValue(value: ScadaObjectSettings): void { + writeValue(value: IotSvgObjectSettings): void { this.modelValue = value || {}; this.setupValue(); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/svg/iot-svg-object-settings.models.ts similarity index 82% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.models.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/common/svg/iot-svg-object-settings.models.ts index 07009a98cf..12231d2b1a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/svg/iot-svg-object-settings.models.ts @@ -14,16 +14,16 @@ /// limitations under the License. /// -import { ScadaObjectProperty } from '@home/components/widget/lib/scada/scada.models'; +import { IotSvgProperty } from '@home/components/widget/lib/svg/iot-svg.models'; export interface ScadaPropertyRow { label: string; - properties: ScadaObjectProperty[]; - switch?: ScadaObjectProperty; + properties: IotSvgProperty[]; + switch?: IotSvgProperty; rowClass?: string; } -export const toPropertyRows = (properties: ScadaObjectProperty[]): ScadaPropertyRow[] => { +export const toPropertyRows = (properties: IotSvgProperty[]): ScadaPropertyRow[] => { const result: ScadaPropertyRow[] = []; for (const property of properties) { let propertyRow = result.find(r => r.label === property.name); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts index 152cf25b35..5f2229cba5 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts @@ -150,8 +150,8 @@ import { } from '@home/components/widget/lib/settings/common/indicator/status-widget-state-settings.component'; import { ChartBarSettingsComponent } from '@home/components/widget/lib/settings/common/chart/chart-bar-settings.component'; import { - ScadaObjectSettingsComponent -} from '@home/components/widget/lib/settings/common/scada/scada-object-settings.component'; + IotSvgObjectSettingsComponent +} from '@home/components/widget/lib/settings/common/svg/iot-svg-object-settings.component'; @NgModule({ declarations: [ @@ -207,7 +207,7 @@ import { TimeSeriesChartStateRowComponent, TimeSeriesChartGridSettingsComponent, StatusWidgetStateSettingsComponent, - ScadaObjectSettingsComponent, + IotSvgObjectSettingsComponent, DataKeyInputComponent, EntityAliasInputComponent ], @@ -269,7 +269,7 @@ import { TimeSeriesChartStateRowComponent, TimeSeriesChartGridSettingsComponent, StatusWidgetStateSettingsComponent, - ScadaObjectSettingsComponent, + IotSvgObjectSettingsComponent, DataKeyInputComponent, EntityAliasInputComponent ], diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-test-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg-widget.component.html similarity index 57% rename from ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-test-widget.component.html rename to ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg-widget.component.html index f811e7f889..b2f4ce1815 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-test-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg-widget.component.html @@ -15,5 +15,12 @@ limitations under the License. --> -
+
+
+ +
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-test-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg-widget.component.scss similarity index 54% rename from ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-test-widget.component.scss rename to ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg-widget.component.scss index 5012fe3a7c..b109bac863 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-test-widget.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg-widget.component.scss @@ -13,10 +13,38 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -.tb-scada-shape { + +.tb-iot-svg-panel { width: 100%; height: 100%; + position: relative; display: flex; - align-items: center; - justify-content: center; + flex-direction: column; + gap: 16px; + padding: 20px 24px 24px 24px; + > div:not(.tb-iot-svg-overlay) { + z-index: 1; + } + .tb-iot-svg-overlay { + position: absolute; + top: 12px; + left: 12px; + bottom: 12px; + right: 12px; + } + div.tb-widget-title { + padding: 0; + } + .tb-iot-svg-content { + flex: 1; + min-width: 0; + min-height: 0; + .tb-iot-svg-shape { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + } + } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-test-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg-widget.component.ts similarity index 51% rename from ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-test-widget.component.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg-widget.component.ts index 25451dcf15..c7b5e87723 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-test-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg-widget.component.ts @@ -28,28 +28,35 @@ import { import { BasicActionWidgetComponent } from '@home/components/widget/lib/action/action-widget.models'; import { ImagePipe } from '@shared/pipe/image.pipe'; import { DomSanitizer } from '@angular/platform-browser'; -import { SVG, Svg } from '@svgdotjs/svg.js'; import { HttpClient } from '@angular/common/http'; -import { ScadaObject, ScadaObjectSettings } from '@home/components/widget/lib/scada/scada.models'; +import { IotSvgObject } from '@home/components/widget/lib/svg/iot-svg.models'; import { ResizeObserver } from '@juggle/resize-observer'; -import { ScadaTestWidgetSettings } from '@home/components/widget/lib/scada/scada-test-widget.models'; +import { + iotSvgWidgetDefaultSettings, + IotSvgWidgetSettings +} from '@home/components/widget/lib/svg/iot-svg-widget.models'; +import { Observable } from 'rxjs'; +import { backgroundStyle, ComponentStyle, overlayStyle } from '@shared/models/widget-settings.models'; @Component({ - selector: 'tb-scada-test-widget', - templateUrl: './scada-test-widget.component.html', - styleUrls: ['../action/action-widget.scss', './scada-test-widget.component.scss'], + selector: 'tb-iot-svg-widget', + templateUrl: './iot-svg-widget.component.html', + styleUrls: ['../action/action-widget.scss', './iot-svg-widget.component.scss'], encapsulation: ViewEncapsulation.None }) -export class ScadaTestWidgetComponent extends +export class IotSvgWidgetComponent extends BasicActionWidgetComponent implements OnInit, AfterViewInit, OnDestroy { - @ViewChild('scadaShape', {static: false}) - scadaShape: ElementRef; + @ViewChild('iotSvgShape', {static: false}) + iotSvgShape: ElementRef; + + private settings: IotSvgWidgetSettings; - private settings: ScadaTestWidgetSettings; + backgroundStyle$: Observable; + overlayStyle: ComponentStyle = {}; + iotSvgObject: IotSvgObject; private autoScale = true; - private scadaObject: ScadaObject; private shapeResize$: ResizeObserver; @@ -63,18 +70,25 @@ export class ScadaTestWidgetComponent extends ngOnInit(): void { super.ngOnInit(); - this.settings = {...this.ctx.settings}; - this.scadaObject = new ScadaObject(this.ctx, '/assets/widget/scada/drawing.svg', this.settings.scadaObject); - this.scadaObject.init().subscribe(); + this.settings = {...iotSvgWidgetDefaultSettings, ...this.ctx.settings}; + + this.backgroundStyle$ = backgroundStyle(this.settings.background, this.imagePipe, this.sanitizer); + this.overlayStyle = overlayStyle(this.settings.background.overlay); + + this.iotSvgObject = new IotSvgObject(this.ctx, this.settings.iotSvg, this.settings.iotSvgObject); + this.iotSvgObject.onError((error) => { + this.ctx.showErrorToast(error, 'bottom', 'center', this.ctx.toastTargetId, true); + }); + this.iotSvgObject.init().subscribe(); } ngAfterViewInit(): void { - this.scadaObject.addTo(this.scadaShape.nativeElement); + this.iotSvgObject.addTo(this.iotSvgShape.nativeElement); if (this.autoScale) { this.shapeResize$ = new ResizeObserver(() => { this.onResize(); }); - this.shapeResize$.observe(this.scadaShape.nativeElement); + this.shapeResize$.observe(this.iotSvgShape.nativeElement); this.onResize(); } super.ngAfterViewInit(); @@ -84,19 +98,22 @@ export class ScadaTestWidgetComponent extends if (this.shapeResize$) { this.shapeResize$.disconnect(); } - if (this.scadaObject) { - this.scadaObject.destroy(); + if (this.iotSvgObject) { + this.iotSvgObject.destroy(); } super.ngOnDestroy(); } public onInit() { super.onInit(); + const borderRadius = this.ctx.$widgetElement.css('borderRadius'); + this.overlayStyle = {...this.overlayStyle, ...{borderRadius}}; + this.cd.detectChanges(); } private onResize() { - const shapeWidth = this.scadaShape.nativeElement.getBoundingClientRect().width; - const shapeHeight = this.scadaShape.nativeElement.getBoundingClientRect().height; - this.scadaObject.setSize(shapeWidth, shapeHeight); + const shapeWidth = this.iotSvgShape.nativeElement.getBoundingClientRect().width; + const shapeHeight = this.iotSvgShape.nativeElement.getBoundingClientRect().height; + this.iotSvgObject.setSize(shapeWidth, shapeHeight); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-test-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg-widget.models.ts similarity index 50% rename from ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-test-widget.models.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg-widget.models.ts index a74d81e3d5..e4992ae62f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-test-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg-widget.models.ts @@ -14,8 +14,25 @@ /// limitations under the License. /// -import { ScadaObjectSettings } from '@home/components/widget/lib/scada/scada.models'; +import { IotSvgObjectSettings } from '@home/components/widget/lib/svg/iot-svg.models'; +import { BackgroundSettings, BackgroundType } from '@shared/models/widget-settings.models'; -export interface ScadaTestWidgetSettings { - scadaObject: ScadaObjectSettings; +export interface IotSvgWidgetSettings { + iotSvg: string; + iotSvgObject: IotSvgObjectSettings; + background: BackgroundSettings; } + +export const iotSvgWidgetDefaultSettings: IotSvgWidgetSettings = { + iotSvg: '/assets/widget/svg/drawing.svg', + iotSvgObject: {}, + background: { + type: BackgroundType.color, + color: '#fff', + overlay: { + enabled: false, + color: 'rgba(255,255,255,0.72)', + blur: 3 + } + } +}; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts new file mode 100644 index 0000000000..d0bd66dcbf --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts @@ -0,0 +1,634 @@ +/// +/// Copyright ยฉ 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { ValueType } from '@shared/models/constants'; +import { Box, Element, Runner, Svg, SVG, Text } from '@svgdotjs/svg.js'; +import { + DataToValueType, + GetValueAction, + GetValueSettings, + SetValueAction, + SetValueSettings, + ValueToDataType +} from '@shared/models/action-widget-settings.models'; +import { formatValue, isDefinedAndNotNull, isUndefinedOrNull, mergeDeep, parseFunction } from '@core/utils'; +import { BehaviorSubject, forkJoin, Observable, Observer } from 'rxjs'; +import { map, share } from 'rxjs/operators'; +import { ValueAction, ValueGetter, ValueSetter } from '@home/components/widget/lib/action/action-widget.models'; +import { WidgetContext } from '@home/models/widget-component.models'; +import { ColorProcessor, constantColor, Font } from '@shared/models/widget-settings.models'; +import { AttributeScope } from '@shared/models/telemetry/telemetry.models'; +import { UtilsService } from '@core/services/utils.service'; +import { WidgetAction, WidgetActionType } from '@shared/models/widget.models'; + +export interface IotSvgApi { + formatValue: (value: any, dec?: number, units?: string, showZeroDecimals?: boolean) => string | undefined; + text: (element: Element | Element[], text: string) => void; + font: (element: Element | Element[], font: Font, color: string) => void; + animate: (element: Element, duration: number) => Runner; + disable: (element: Element | Element[]) => void; + enable: (element: Element | Element[]) => void; + callAction: (event: Event, behaviorId: string, value?: any, observer?: Partial>) => void; + setValue: (valueId: string, value: any) => void; +} + +export interface IotSvgContext { + api: IotSvgApi; + tags: {[id: string]: Element[]}; + values: {[id: string]: any}; + properties: {[id: string]: any}; +} + +export type IotSvgStateRenderFunction = (svg: Svg, ctx: IotSvgContext) => void; + +export type IotSvgTagStateRenderFunction = (element: Element, ctx: IotSvgContext) => void; + +export type IotSvgActionTrigger = 'click'; + +export type IotSvgActionFunction = (event: Event, ctx: IotSvgContext) => void; +export interface IotSvgAction { + actionFunction?: string; + action?: IotSvgActionFunction; +} + +export interface IotSvgTag { + tag: string; + stateRenderFunction?: string; + stateRender?: IotSvgTagStateRenderFunction; + actions?: {[trigger: string]: IotSvgAction}; +} + +export enum IotSvgBehaviorType { + value = 'value', + action = 'action', + widgetAction = 'widgetAction' +} + +export interface IotSvgBehaviorBase { + id: string; + name: string; + type: IotSvgBehaviorType; +} + +export interface IotSvgBehaviorValue extends IotSvgBehaviorBase { + valueType: ValueType; + defaultValue: any; + valueId: string; +} + +export interface IotSvgBehaviorAction extends IotSvgBehaviorBase { + valueToDataType: ValueToDataType; + constantValue: any; + valueToDataFunction: string; +} + +export type IotSvgBehavior = IotSvgBehaviorValue | IotSvgBehaviorAction; + +export type IotSvgPropertyType = 'string' | 'number' | 'color' | 'color-settings' | 'font' | 'units' | 'switch'; + +export interface IotSvgPropertyBase { + id: string; + name: string; + type: IotSvgPropertyType; + default: any; + required?: boolean; + subLabel?: string; + divider?: boolean; + fieldSuffix?: string; + disableOnProperty?: string; + rowClass?: string; + fieldClass?: string; +} + +export interface IotSvgNumberProperty extends IotSvgPropertyBase { + min?: number; + max?: number; + step?: number; +} + +export type IotSvgProperty = IotSvgPropertyBase & IotSvgNumberProperty; + +export type ScadaObjectElementActionTrigger = 'click'; + +export type ScadaObjectActionType = 'updateValue'; +export type ScadaObjectActionUpdateValueType = 'toggle' | 'increment' | 'constant'; + +export interface ScadaObjectElementAction { + trigger: ScadaObjectElementActionTrigger; + enabledTriggerValues?: string[]; + enabledConditionFunction?: string; + enabledCondition?: (values: {[key: string]: any}) => boolean; + actionType: ScadaObjectActionType; + updateValueId?: string; + updateValueType?: ScadaObjectActionUpdateValueType; + updateValueConstant?: any; + updateValueInc?: number; +} + +export interface IotSvgMetadata { + title: string; + stateRenderFunction?: string; + stateRender?: IotSvgStateRenderFunction; + tags: IotSvgTag[]; + behavior: IotSvgBehavior[]; + properties: IotSvgProperty[]; +} + +export const emptyMetadata: IotSvgMetadata = { + title: '', + tags: [], + behavior: [], + properties: [] +}; + + +export const parseScadaObjectMetadataFromContent = (svgContent: string): IotSvgMetadata => { + try { + const svgDoc = new DOMParser().parseFromString(svgContent, 'image/svg+xml'); + return parseScadaObjectMetadataFromDom(svgDoc); + } catch (_e) { + return emptyMetadata; + } +}; + +const parseScadaObjectMetadataFromDom = (svgDoc: Document): IotSvgMetadata => { + try { + const elements = svgDoc.getElementsByTagName('tb:metadata'); + if (elements.length) { + return JSON.parse(elements[0].textContent); + } else { + return emptyMetadata; + } + } catch (_e) { + console.error(_e); + return emptyMetadata; + } +}; + +const defaultGetValueSettings = (get: IotSvgBehaviorValue): GetValueSettings => ({ + action: GetValueAction.DO_NOTHING, + defaultValue: get.defaultValue, + executeRpc: { + method: 'getState', + requestTimeout: 5000, + requestPersistent: false, + persistentPollingInterval: 1000 + }, + getAttribute: { + key: 'state', + scope: null + }, + getTimeSeries: { + key: 'state' + }, + dataToValue: { + type: DataToValueType.NONE, + compareToValue: true, + dataToValueFunction: '/* Should return boolean value */\nreturn data;' + } + }); + +const defaultSetValueSettings = (set: IotSvgBehaviorAction): SetValueSettings => ({ + action: SetValueAction.EXECUTE_RPC, + executeRpc: { + method: 'setState', + requestTimeout: 5000, + requestPersistent: false, + persistentPollingInterval: 1000 + }, + setAttribute: { + key: 'state', + scope: AttributeScope.SERVER_SCOPE + }, + putTimeSeries: { + key: 'state' + }, + valueToData: { + type: set.valueToDataType, + constantValue: set.constantValue, + valueToDataFunction: set.valueToDataFunction ? set.valueToDataFunction : + '/* Convert input boolean value to RPC parameters or attribute/time-series value */\nreturn value;' + } +}); + +const defaultWidgetActionSettings = (widgetAction: IotSvgBehavior): WidgetAction => ({ + type: WidgetActionType.updateDashboardState, + targetDashboardStateId: null, + openRightLayout: false, + setEntityId: true, + stateEntityParamName: null +}); + +export const defaultScadaObjectSettings = (metadata: IotSvgMetadata): IotSvgObjectSettings => { + const settings: IotSvgObjectSettings = {}; + for (const behavior of metadata.behavior) { + if (behavior.type === IotSvgBehaviorType.value) { + settings[behavior.id] = defaultGetValueSettings(behavior as IotSvgBehaviorValue); + } else if (behavior.type === IotSvgBehaviorType.action) { + settings[behavior.id] = defaultSetValueSettings(behavior as IotSvgBehaviorAction); + } else if (behavior.type === IotSvgBehaviorType.widgetAction) { + settings[behavior.id] = defaultWidgetActionSettings(behavior); + } + } + for (const property of metadata.properties) { + settings[property.id] = property.default; + } + return settings; +}; + +export type IotSvgObjectSettings = {[id: string]: any}; + +const parseError = (ctx: WidgetContext, err: any): string => + ctx.$injector.get(UtilsService).parseException(err).message || 'Unknown Error'; + +export class IotSvgObject { + + private metadata: IotSvgMetadata; + private settings: IotSvgObjectSettings; + private context: IotSvgContext; + + private rootElement: HTMLElement; + private svgShape: Svg; + private box: Box; + private targetWidth: number; + private targetHeight: number; + + private loadingSubject = new BehaviorSubject(false); + private valueGetters: ValueGetter[] = []; + private valueActions: ValueAction[] = []; + private valueSetters: {[behaviorId: string]: ValueSetter} = {}; + + private stateValueSubjects: {[id: string]: BehaviorSubject} = {}; + + loading$ = this.loadingSubject.asObservable().pipe(share()); + + private _onError: (error: string) => void = () => {}; + + constructor(private ctx: WidgetContext, + private svgPath: string, + private inputSettings: IotSvgObjectSettings) {} + + public init(): Observable { + return this.ctx.http.get(this.svgPath, {responseType: 'text'}).pipe( + map((inputSvgContent) => { + const doc: XMLDocument = new DOMParser().parseFromString(inputSvgContent, 'image/svg+xml'); + this.metadata = parseScadaObjectMetadataFromDom(doc); + const defaults = defaultScadaObjectSettings(this.metadata); + this.settings = mergeDeep({}, defaults, this.inputSettings || {}); + this.prepareMetadata(); + this.prepareSvgShape(doc); + this.initialize(); + }) + ); + } + + public onError(onError: (error: string) => void) { + this._onError = onError; + } + + public addTo(element: HTMLElement) { + this.rootElement = element; + if (this.svgShape) { + this.svgShape.addTo(element); + } + } + + public destroy() { + for (const stateValueId of Object.keys(this.stateValueSubjects)) { + this.stateValueSubjects[stateValueId].complete(); + this.stateValueSubjects[stateValueId].unsubscribe(); + } + this.valueActions.forEach(v => v.destroy()); + this.loadingSubject.complete(); + this.loadingSubject.unsubscribe(); + } + + public setSize(targetWidth: number, targetHeight: number) { + this.targetWidth = targetWidth; + this.targetHeight = targetHeight; + if (this.svgShape) { + this.resize(); + } + } + + private prepareMetadata() { + this.metadata.stateRender = parseFunction(this.metadata.stateRenderFunction, ['svg', 'ctx']) || (() => {}); + for (const tag of this.metadata.tags) { + tag.stateRender = parseFunction(tag.stateRenderFunction, ['element', 'ctx']) || (() => {}); + if (tag.actions) { + for (const trigger of Object.keys(tag.actions)) { + const action = tag.actions[trigger]; + action.action = parseFunction(action.actionFunction, ['event', 'ctx']) || (() => {}); + } + } + } + } + + private prepareSvgShape(doc: XMLDocument) { + const elements = doc.getElementsByTagName('tb:metadata'); + for (let i=0;i { + element.attr('cursor', 'pointer'); + element.on(trigger, (event) => { + action.action(event, this.context); + }); + }); + } + } + } + for (const behavior of this.metadata.behavior) { + if (behavior.type === IotSvgBehaviorType.value) { + const getBehavior = behavior as IotSvgBehaviorValue; + let getValueSettings: GetValueSettings = this.settings[getBehavior.id]; + getValueSettings = {...getValueSettings, actionLabel: getBehavior.name}; + const stateValueSubject = new BehaviorSubject(getValueSettings.defaultValue); + this.stateValueSubjects[getBehavior.valueId] = stateValueSubject; + this.context.values[getBehavior.valueId] = getValueSettings.defaultValue; + stateValueSubject.subscribe((value) => { + this.onStateValueChanged(getBehavior.valueId, value); + }); + const valueGetter = + ValueGetter.fromSettings(this.ctx, getValueSettings, getBehavior.valueType, { + next: (val) => {this.onValue(getBehavior.id, val);}, + error: (err) => { + const message = parseError(this.ctx, err); + this._onError(message); + } + }); + this.valueGetters.push(valueGetter); + this.valueActions.push(valueGetter); + } else if (behavior.type === IotSvgBehaviorType.action) { + const setBehavior = behavior as IotSvgBehaviorAction; + let setValueSettings: SetValueSettings = this.settings[setBehavior.id]; + setValueSettings = {...setValueSettings, actionLabel: setBehavior.name}; + const valueSetter = ValueSetter.fromSettings(this.ctx, setValueSettings); + this.valueSetters[setBehavior.id] = valueSetter; + this.valueActions.push(valueSetter); + } else if (behavior.type === IotSvgBehaviorType.widgetAction) { + // TODO: + } + } + this.renderState(); + if (this.valueGetters.length) { + const getValueObservables: Array> = []; + this.valueGetters.forEach(valueGetter => { + getValueObservables.push(valueGetter.getValue()); + }); + this.loadingSubject.next(true); + forkJoin(getValueObservables).subscribe( + { + next: () => { + this.loadingSubject.next(false); + }, + error: () => { + this.loadingSubject.next(false); + } + } + ); + } + } + + private callAction(event: Event, behaviorId: string, value?: any, observer?: Partial>) { + const behavior = this.metadata.behavior.find(b => b.id === behaviorId); + if (behavior) { + if (behavior.type === IotSvgBehaviorType.action) { + const valueSetter = this.valueSetters[behaviorId]; + if (valueSetter) { + this.loadingSubject.next(true); + valueSetter.setValue(value).subscribe( + { + next: () => { + if (observer?.next) { + observer.next(); + } + this.loadingSubject.next(false); + }, + error: (err) => { + this.loadingSubject.next(false); + if (observer?.error) { + observer.error(err); + } + const message = parseError(this.ctx, err); + this._onError(message); + } + } + ); + } + } else if (behavior.type === IotSvgBehaviorType.widgetAction) { + const widgetAction: WidgetAction = this.settings[behavior.id]; + this.ctx.actionsApi.onWidgetAction(event, widgetAction); + } + } + } + + private resize() { + let scale: number; + if (this.targetWidth < this.targetHeight) { + scale = this.targetWidth / this.box.width; + } else { + scale = this.targetHeight / this.box.height; + } + this.svgShape.node.style.transform = `scale(${scale})`; + } + + private onValue(id: string, value: any) { + const valueBehavior = this.metadata.behavior.find(b => b.id === id) as IotSvgBehaviorValue; + value = this.normalizeValue(value, valueBehavior.valueType); + const valueId = valueBehavior.valueId; + this.setValue(valueId, value); + } + + private setValue(valueId: string, value: any) { + const stateValueSubject = this.stateValueSubjects[valueId]; + if (stateValueSubject && stateValueSubject.value !== value) { + stateValueSubject.next(value); + } + } + + private onStateValueChanged(id: string, value: any) { + if (this.context.values[id] !== value) { + this.context.values[id] = value; + this.renderState(); + } + } + + private renderState(): void { + this.metadata.stateRender(this.svgShape, this.context); + for (const tag of this.metadata.tags) { + const elements = this.svgShape.find(`[tb\\:tag="${tag.tag}"]`); + elements.forEach(element => { + tag.stateRender(element, this.context); + }); + } + } + + private normalizeValue(value: any, type: ValueType): any { + if (isUndefinedOrNull(value)) { + switch (type) { + case ValueType.STRING: + return ''; + case ValueType.INTEGER: + case ValueType.DOUBLE: + return 0; + case ValueType.BOOLEAN: + return false; + case ValueType.JSON: + return {}; + } + } else { + return value; + } + } + + private setElementText(e: Element | Element[], text: string) { + this.elements(e).forEach(element => { + let textElement: Text; + if (element.type === 'text') { + const children = element.children(); + if (children.length && children[0].type === 'tspan') { + textElement = children[0] as Text; + } else { + textElement = element as Text; + } + } else if (element.type === 'tspan') { + textElement = element as Text; + } + if (textElement) { + textElement.text(text); + } + }); + } + + private setElementFont(e: Element | Element[], font: Font, color: string) { + this.elements(e).forEach(element => { + if (element.type === 'text') { + const textElement = element as Text; + if (font) { + textElement.font({ + family: font.family, + size: (isDefinedAndNotNull(font.size) && isDefinedAndNotNull(font.sizeUnit)) ? + font.size + font.sizeUnit : null, + weight: font.weight, + style: font.style + }); + } + if (color) { + textElement.fill(color); + } + } + }); + } + + private animate(element: Element, duration: number): Runner { + element.timeline().finish(); + return element.animate(duration, 0, 'now'); + } + + private disableElement(e: Element | Element[]) { + this.elements(e).forEach(element => { + element.attr({'pointer-events': 'none'}); + }); + } + + private enableElement(e: Element | Element[]) { + this.elements(e).forEach(element => { + element.attr({'pointer-events': null}); + }); + } + + private elements(element: Element | Element[]): Element[] { + return Array.isArray(element) ? element : [element]; + } + + private getProperty(id: string): IotSvgProperty { + return this.metadata.properties.find(p => p.id === id); + } + + private getPropertyValue(id: string): any { + const property = this.getProperty(id); + if (property) { + const value = this.settings[id]; + if (isDefinedAndNotNull(value)) { + if (property.type === 'color-settings') { + return ColorProcessor.fromSettings(value); + } + return value; + } else { + switch (property.type) { + case 'string': + return ''; + case 'number': + return 0; + case 'color': + return '#000'; + case 'color-settings': + return constantColor('#000'); + } + } + } else { + return ''; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts index f062494763..ae9034d1cd 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts @@ -92,7 +92,7 @@ import { PieChartWidgetComponent } from '@home/components/widget/lib/chart/pie-c import { BarChartWidgetComponent } from '@home/components/widget/lib/chart/bar-chart-widget.component'; import { PolarAreaWidgetComponent } from '@home/components/widget/lib/chart/polar-area-widget.component'; import { RadarChartWidgetComponent } from '@home/components/widget/lib/chart/radar-chart-widget.component'; -import { ScadaTestWidgetComponent } from '@home/components/widget/lib/scada/scada-test-widget.component'; +import { IotSvgWidgetComponent } from '@home/components/widget/lib/svg/iot-svg-widget.component'; @NgModule({ declarations: @@ -152,7 +152,7 @@ import { ScadaTestWidgetComponent } from '@home/components/widget/lib/scada/scad BarChartWidgetComponent, PolarAreaWidgetComponent, RadarChartWidgetComponent, - ScadaTestWidgetComponent + IotSvgWidgetComponent ], imports: [ CommonModule, @@ -215,7 +215,7 @@ import { ScadaTestWidgetComponent } from '@home/components/widget/lib/scada/scad BarChartWidgetComponent, PolarAreaWidgetComponent, RadarChartWidgetComponent, - ScadaTestWidgetComponent + IotSvgWidgetComponent ], providers: [ {provide: WIDGET_COMPONENTS_MODULE_TOKEN, useValue: WidgetComponentsModule } diff --git a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts index a79fa89242..b8f9be3f56 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts @@ -39,7 +39,7 @@ import { } from '@angular/core'; import { DashboardWidget } from '@home/models/dashboard-component.models'; import { - Widget, + Widget, WidgetAction, WidgetActionDescriptor, widgetActionSources, WidgetActionType, @@ -57,7 +57,7 @@ import { WidgetService } from '@core/http/widget.service'; import { UtilsService } from '@core/services/utils.service'; import { forkJoin, Observable, of, ReplaySubject, Subscription, throwError } from 'rxjs'; import { - deepClone, + deepClone, guid, insertVariable, isDefined, isNotEmptyStr, @@ -254,6 +254,7 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI actionDescriptorsBySourceId, getActionDescriptors: this.getActionDescriptors.bind(this), handleWidgetAction: this.handleWidgetAction.bind(this), + onWidgetAction: this.onWidgetAction.bind(this), elementClick: this.elementClick.bind(this), cardClick: this.cardClick.bind(this), click: this.click.bind(this), @@ -1039,7 +1040,7 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI return result; } - private handleWidgetAction($event: Event, descriptor: WidgetActionDescriptor, + private handleWidgetAction($event: Event, descriptor: WidgetAction, entityId?: EntityId, entityName?: string, additionalParams?: any, entityLabel?: string): void { const type = descriptor.type; const targetEntityParamName = descriptor.stateEntityParamName; @@ -1112,7 +1113,7 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI const customHtml = descriptor.customHtml; const customCss = descriptor.customCss; const customResources = descriptor.customResources; - const actionNamespace = `custom-action-pretty-${descriptor.name.toLowerCase()}`; + const actionNamespace = `custom-action-pretty-${guid()}`; let htmlTemplate = ''; if (isDefined(customHtml) && customHtml.length > 0) { htmlTemplate = customHtml; @@ -1420,13 +1421,8 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI const idsList = descriptors.map(descriptor => `#${descriptor.name}`).join(','); const targetElement = $(elementClicked).closest(idsList, this.widgetContext.$container[0]); if (targetElement.length && targetElement[0].id) { - $event.stopPropagation(); const descriptor = descriptors.find(descriptorInfo => descriptorInfo.name === targetElement[0].id); - const entityInfo = this.getActiveEntityInfo(); - const entityId = entityInfo ? entityInfo.entityId : null; - const entityName = entityInfo ? entityInfo.entityName : null; - const entityLabel = entityInfo && entityInfo.entityLabel ? entityInfo.entityLabel : null; - this.handleWidgetAction($event, descriptor, entityId, entityName, null, entityLabel); + this.onWidgetAction($event, descriptor); } } } @@ -1442,18 +1438,23 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI private onClick($event: Event, sourceId: string) { const descriptors = this.getActionDescriptors(sourceId); if (descriptors.length) { + this.onWidgetAction($event, descriptors[0]); + } + } + + private onWidgetAction($event: Event, action: WidgetAction) { + if ($event) { $event.stopPropagation(); - const descriptor = descriptors[0]; - const entityInfo = this.getActiveEntityInfo(); - const entityId = entityInfo ? entityInfo.entityId : null; - const entityName = entityInfo ? entityInfo.entityName : null; - const entityLabel = entityInfo && entityInfo.entityLabel ? entityInfo.entityLabel : null; - this.handleWidgetAction($event, descriptor, entityId, entityName, null, entityLabel); } + const entityInfo = this.getActiveEntityInfo(); + const entityId = entityInfo ? entityInfo.entityId : null; + const entityName = entityInfo ? entityInfo.entityName : null; + const entityLabel = entityInfo && entityInfo.entityLabel ? entityInfo.entityLabel : null; + this.handleWidgetAction($event, action, entityId, entityName, null, entityLabel); } private loadCustomActionResources(actionNamespace: string, customCss: string, customResources: Array, - actionDescriptor: WidgetActionDescriptor): Observable { + actionDescriptor: WidgetAction): Observable { const resourceTasks: Observable[] = []; const modulesTasks: Observable[] = []; diff --git a/ui-ngx/src/assets/widget/scada/drawing.svg b/ui-ngx/src/assets/widget/svg/drawing.svg similarity index 54% rename from ui-ngx/src/assets/widget/scada/drawing.svg rename to ui-ngx/src/assets/widget/svg/drawing.svg index 61e8d3f1ca..91bfb8b47d 100644 --- a/ui-ngx/src/assets/widget/scada/drawing.svg +++ b/ui-ngx/src/assets/widget/svg/drawing.svg @@ -1,341 +1,115 @@ - { - "title": "My first SCADA Object", - "stateValues": { - "level": { - "initialValue": 0 - }, - "disabled": { - "initialValue": false + { + "title": "My first IoT SVG Object", + "stateRenderFunction": "var showMinMaxLevel = ctx.properties.showMinMaxLevel;\nvar minLevelElement = ctx.tags.minLevel[0];\nvar maxLevelElement = ctx.tags.maxLevel[0];\nvar minLevel = ctx.properties.minLevel; \nvar maxLevel = ctx.properties.maxLevel;\n\nif (showMinMaxLevel) {\n \n var minMaxLevelFont = ctx.properties.minMaxLevelFont;\n var minMaxLevelColor = ctx.properties.minMaxLevelColor;\n \n ctx.api.text(minLevelElement, minLevel);\n ctx.api.text(maxLevelElement, maxLevel);\n \n ctx.api.font(minLevelElement, minMaxLevelFont, minMaxLevelColor);\n ctx.api.font(maxLevelElement, minMaxLevelFont, minMaxLevelColor);\n \n} else {\n minLevelElement.hide();\n maxLevelElement.hide();\n}\n\nvar disabled = ctx.values.disabled;\nvar on = ctx.values.on;\nvar level = ctx.values.level;\n\nvar onButton = ctx.tags.onButton;\nvar offButton = ctx.tags.offButton;\nvar levelUpButton = ctx.tags.levelUpButton;\nvar levelDownButton = ctx.tags.levelDownButton;\n\nvar onButtonEnabled = !disabled && !on;\nvar offButtonEnabled = !disabled && on;\nvar levelUpEnabled = !disabled && level < maxLevel;\nvar levelDownEnabled = !disabled && level > minLevel;\n\nif (onButtonEnabled) {\n ctx.api.enable(onButton);\n onButton[0].findOne('rect').attr({fill: '#12ed19'});\n} else {\n ctx.api.disable(onButton);\n onButton[0].findOne('rect').attr({fill: '#777'});\n}\n \nif (offButtonEnabled) {\n ctx.api.enable(offButton);\n offButton[0].findOne('rect').attr({fill: '#ed121f'});\n} else {\n ctx.api.disable(offButton);\n offButton[0].findOne('rect').attr({fill: '#777'});\n} \n\n\nif (levelUpEnabled) {\n ctx.api.enable(levelUpButton);\n levelUpButton[0].findOne('rect').attr({fill: '#fff'});\n} else {\n ctx.api.disable(levelUpButton);\n levelUpButton[0].findOne('rect').attr({fill: '#777'});\n}\n \nif (levelDownEnabled) {\n ctx.api.enable(levelDownButton);\n levelDownButton[0].findOne('rect').attr({fill: '#fff'});\n} else {\n ctx.api.disable(levelDownButton);\n levelDownButton[0].findOne('rect').attr({fill: '#777'});\n}", + "tags": [ + { + "tag": "level", + "stateRenderFunction": "var level = ctx.values.level; \nvar disabled = ctx.values.disabled;\nvar minLevel = ctx.properties.minLevel; \nvar maxLevel = ctx.properties.maxLevel;\n\nvar height = (level - minLevel) / (maxLevel - minLevel);\nheight = Math.max(0, Math.min(1, height))*80; \n\nctx.api.animate(element, 200).attr({height: height});\n\nvar fill;\nif (disabled) {\n fill = ctx.properties.disabledLevelBackground;\n} else {\n var colorProcessor = ctx.properties.levelBackground;\n colorProcessor.update(level);\n fill = colorProcessor.color;\n}\n\nelement.attr({fill: fill});" }, - "on": { - "initialValue": false, - "callBehavior": [ - { - "behaviorId": "onUpdateState", - "conditionFunction": "return value === true;" - }, - { - "behaviorId": "offUpdateState", - "conditionFunction": "return value === false;" - } - ] - } - }, - "actions": { - "onButton": { - "trigger": "click", - "actionType": "updateValue", - "updateValueId": "on", - "updateValueType": "constant", - "updateValueConstant": true + { + "tag": "levelTitle", + "stateRenderFunction": "var showLevelTitle = ctx.properties.showLevelTitle;\n\nif (showLevelTitle) {\n var levelTitle = ctx.properties.levelTitle;\n var levelTitleFont = ctx.properties.levelTitleFont;\n var levelTitleColor = ctx.properties.levelTitleColor;\n \n ctx.api.text(element, levelTitle);\n ctx.api.font(element, levelTitleFont, levelTitleColor);\n \n} else {\n element.hide();\n}" }, - "offButton": { - "trigger": "click", - "actionType": "updateValue", - "updateValueId": "on", - "updateValueType": "constant", - "updateValueConstant": false - } - }, - "states": { - "initialState": { - "initial": true, - "state": [ - { - "tag": "levelTitle", - "show": { - "type": "property", - "propertyId": "showLevelTitle" - }, - "text": { - "content": { - "type": "property", - "propertyId": "levelTitle" - }, - "font": { - "type": "property", - "propertyId": "levelTitleFont" - }, - "color": { - "type": "property", - "propertyId": "levelTitleColor" - } - } - }, - { - "tag": "level", - "inputValue": "level", - "attributes": [ - { - "name": "fill", - "value": { - "type": "property", - "propertyId": "levelBackground" - } - } - ] - }, - { - "tag": "levelValueBackground", - "show": { - "type": "property", - "propertyId": "showValue" - } - }, - { - "tag": "levelValue", - "show": { - "type": "property", - "propertyId": "showValue" - }, - "text": { - "font": { - "type": "property", - "propertyId": "valueFont" - }, - "color": { - "type": "property", - "propertyId": "valueColor" - } - } - }, - { - "tag": "minLevel", - "show": { - "type": "property", - "propertyId": "showMinMaxLevel" - }, - "text": { - "content": { - "type": "property", - "propertyId": "minLevel" - }, - "font": { - "type": "property", - "propertyId": "minMaxLevelFont" - }, - "color": { - "type": "property", - "propertyId": "minMaxLevelColor" - } - } - }, - { - "tag": "maxLevel", - "show": { - "type": "property", - "propertyId": "showMinMaxLevel" - }, - "text": { - "content": { - "type": "property", - "propertyId": "maxLevel" - }, - "font": { - "type": "property", - "propertyId": "minMaxLevelFont" - }, - "color": { - "type": "property", - "propertyId": "minMaxLevelColor" - } - } - } - ] + { + "tag": "levelValue", + "stateRenderFunction": "var showValue = ctx.properties.showValue;\n\nif (showValue) {\n var valueFont = ctx.properties.valueFont;\n var valueColor = ctx.properties.valueColor;\n var level = ctx.values.level; \n \n var levelText = ctx.api.formatValue(level, ctx.properties.valueDecimals, ctx.properties.valueUnits, false);\n\n ctx.api.font(element, valueFont, valueColor);\n ctx.api.text(element, levelText);\n\n} else {\n element.hide();\n ctx.tags.levelValueBackground[0].hide();\n}", + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'levelValueClick');" + } + } }, - "updateLevelState": { - "triggerValues": ["level"], - "state": [ - { - "tag": "level", - "inputValue": "level", - "animate": 200, - "attributes": [ - { - "name": "height", - "value": { - "type": "function", - "valueConvertFunction": "var level = (value - ${minLevel}) / (${maxLevel} - ${minLevel});\nlevel = Math.max(0, Math.min(1, level));\nreturn level*80;" - } - } - ] - }, - { - "tag": "levelValue", - "inputValue": "level", - "text": { - "content": { - "type": "valueFormat", - "units": { - "type": "property", - "propertyId": "valueUnits" - }, - "decimals": { - "type": "property", - "propertyId": "valueDecimals" - } - } - } - } - ] + { + "tag": "fan", + "stateRenderFunction": "var on = ctx.values.on;\nif (on) {\n element.addClass('fan-rotate');\n} else {\n element.removeClass('fan-rotate');\n}\n" }, - "updateLevelBackgroundState": { - "triggerValues": ["disabled", "level"], - "conditionFunction": "return values.disabled === false;", - "state": [ - { - "tag": "level", - "inputValue": "level", - "attributes": [ - { - "name": "fill", - "value": { - "type": "property", - "propertyId": "levelBackground" - } - } - ] - } - ] + { + "tag": "onButton", + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'onUpdateState');" + } + } }, - "disabledState": { - "triggerValues": ["disabled"], - "conditionFunction": "return values.disabled === true;", - "state": [ - { - "tag": "level", - "attributes": [ - { - "name": "fill", - "value": { - "type": "property", - "propertyId": "disabledLevelBackground" - } - } - ] - } - ] + { + "tag": "offButton", + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'offUpdateState');" + } + } }, - "onState": { - "triggerValues": ["on"], - "conditionFunction": "return values.on === true;", - "state": [ - { - "tag": "fan", - "addClass": "fan-rotate" - }, - { - "tag": "onButtonBackground", - "attributes": [ - { - "name": "fill", - "value": { - "type": "constant", - "constantValue": "#777" - } - } - ] - }, - { - "tag": "offButtonBackground", - "attributes": [ - { - "name": "fill", - "value": { - "type": "constant", - "constantValue": "#ed121f" - } - } - ] - } - ] + { + "tag": "levelUpButton", + "actions": { + "click": { + "actionFunction": "var level = ctx.values.level; \nvar maxLevel = ctx.properties.maxLevel;\n\nvar newLevel = Math.min(maxLevel, level + 5);\nctx.api.setValue('level', newLevel);\nctx.api.callAction(event, 'levelUpdateState', newLevel, {\n error: () => {\n ctx.api.setValue('level', level);\n }\n});" + } + } }, - "offState": { - "triggerValues": ["on"], - "conditionFunction": "return values.on === false;", - "state": [ - { - "tag": "fan", - "removeClass": "fan-rotate" - }, - { - "tag": "onButtonBackground", - "attributes": [ - { - "name": "fill", - "value": { - "type": "constant", - "constantValue": "#12ed19" - } - } - ] - }, - { - "tag": "offButtonBackground", - "attributes": [ - { - "name": "fill", - "value": { - "type": "constant", - "constantValue": "#777" - } - } - ] - } - ] + { + "tag": "levelDownButton", + "actions": { + "click": { + "actionFunction": "var level = ctx.values.level; \nvar minLevel = ctx.properties.minLevel;\n\nvar newLevel = Math.max(minLevel, level - 5);\nctx.api.setValue('level', newLevel);\nctx.api.callAction(event, 'levelUpdateState', newLevel, {\n error: () => {\n ctx.api.setValue('level', level);\n }\n});" + } + } } - }, + ], "behavior": [ { "id": "levelState", "name": "Level", - "type": "getValue", + "type": "value", "valueType": "DOUBLE", "defaultValue": 0, - "onUpdate": [ - { - "updateValue": "level" - } - ] + "valueId": "level" }, { "id": "disabledState", "name": "Disabled state", - "type": "getValue", + "type": "value", "valueType": "BOOLEAN", "defaultValue": false, - "onUpdate": [ - { - "updateValue": "disabled" - } - ] + "valueId": "disabled" }, { "id": "onState", "name": "On/Off state", - "type": "getValue", + "type": "value", "valueType": "BOOLEAN", "defaultValue": false, - "onUpdate": [ - { - "updateValue": "on" - } - ] + "valueId": "on" }, { "id": "onUpdateState", "name": "Power On", - "type": "setValue", + "type": "action", + "valueType": "BOOLEAN", "valueToDataType": "CONSTANT", "constantValue": true }, { "id": "offUpdateState", "name": "Power Off", - "type": "setValue", + "type": "action", + "valueType": "BOOLEAN", "valueToDataType": "CONSTANT", "constantValue": false + }, + { + "id": "levelUpdateState", + "name": "Update level", + "type": "action", + "valueType": "DOUBLE", + "valueToDataType": "VALUE", + "constantValue": 0 + }, + { + "id": "levelValueClick", + "name": "On level value click", + "type": "widgetAction" } ], "properties": [ @@ -489,7 +263,7 @@ "default": "#ccc" } ] - }</tb:metadata> + } From c4ff43a840f666ae10740bcb98fb331ab778b4cc Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Tue, 7 May 2024 20:02:06 +0300 Subject: [PATCH 023/442] UI: Minor fix. --- .../modules/home/components/widget/lib/svg/iot-svg.models.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts index d0bd66dcbf..9b16e56649 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts @@ -624,7 +624,7 @@ export class IotSvgObject { case 'color': return '#000'; case 'color-settings': - return constantColor('#000'); + return ColorProcessor.fromSettings(constantColor('#000')); } } } else { From 0beeb99b99df9cb6c809c66c0564194a830c61ba Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Wed, 8 May 2024 17:13:25 +0300 Subject: [PATCH 024/442] UI: Refactoring and improvements --- .../json/system/widget_bundles/cards.json | 4 +- .../json/system/widget_types/iot_svg.json | 26 +++++++++++ .../action-button-basic-config.component.html | 10 ++-- ...command-button-basic-config.component.html | 6 +-- .../power-button-basic-config.component.html | 16 +++---- .../toggle-button-basic-config.component.html | 16 +++---- .../status-widget-basic-config.component.html | 12 ++--- .../single-switch-basic-config.component.html | 16 +++---- .../rpc/slider-basic-config.component.html | 8 ++-- ...tion-button-widget-settings.component.html | 8 ++-- ...mand-button-widget-settings.component.html | 6 +-- ...ower-button-widget-settings.component.html | 16 +++---- ...ggle-button-widget-settings.component.html | 16 +++---- ...value-action-settings-panel.component.html | 4 +- ...t-value-action-settings-panel.component.ts | 4 +- .../get-value-action-settings.component.ts | 12 +++-- ...value-action-settings-panel.component.html | 2 +- ...idget-action-settings-panel.component.html | 2 +- .../iot-svg-object-settings.component.html | 29 ++++++------ .../svg/iot-svg-object-settings.component.ts | 44 +++++++++--------- .../svg/iot-svg-object-settings.models.ts | 6 +-- ...ngle-switch-widget-settings.component.html | 16 +++---- .../slider-widget-settings.component.html | 8 ++-- .../status-widget-settings.component.html | 12 ++--- .../widget/lib/svg/iot-svg.models.ts | 46 +++++++++---------- .../components/value-input.component.html | 4 +- .../components/value-input.component.ts | 12 ++++- .../app/shared/pipe/custom-translate.pipe.ts | 33 +++++++++++++ ui-ngx/src/app/shared/shared.module.ts | 4 ++ .../assets/locale/locale.constant-en_US.json | 3 ++ ui-ngx/src/assets/widget/svg/drawing.svg | 15 ++++-- 31 files changed, 252 insertions(+), 164 deletions(-) create mode 100644 application/src/main/data/json/system/widget_types/iot_svg.json create mode 100644 ui-ngx/src/app/shared/pipe/custom-translate.pipe.ts diff --git a/application/src/main/data/json/system/widget_bundles/cards.json b/application/src/main/data/json/system/widget_bundles/cards.json index 39996839f1..43f1ab4886 100644 --- a/application/src/main/data/json/system/widget_bundles/cards.json +++ b/application/src/main/data/json/system/widget_bundles/cards.json @@ -2,10 +2,9 @@ "widgetsBundle": { "alias": "cards", "title": "Cards", - "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAMAAAB+IdObAAAC/VBMVEUAAAAuTILg4ODf398dMkk0SWLg4ODg4OAyP1UrQF8sRH2/v8fDx8dFbHsDCBhKWGvDxsYaNET///8AAADg4OD4+Pjn5+fj4+P6+vrv7+/q8voKCgre3d3R2eft7e3TzMjP1+UdHR3My8vQzs7r6+vz8/PQ0NCKiorS0tPb29zm8Pnp6enQzMrX19j19fXNzc3m5eXW1dacnJzT1NnT2+nJycrx8fHf6vTMztHh7PXP1ePTzswbGxwWFhYsLC3Y4u7Wzcjj7ffT1NTS2OHl4NvZ1tXPz9QhISHVzst3d3jZ2drX1NNKSkrP1N6Xl5fn495tbW3d3uHW0tGysrLs7/HY3OG4ubnl7vbi6fLL0NhSUlLd5/PS0NCqqalXVlbp7e/Zz8rs6eXg4+XLzM0yUI3e4ubf0coSEhLc5vGsrKynp6bl6OvV2eBjY2MkJCbO0tvHxsbT1t3Q0dfBwMCSkpLj5efq5eG1tbU2MihVav/d3+Ta2Nfa09COjo7W3+vMztXl6u7Y3uakpKSEhIQ7Ozxynsy7u7ve2dbDw8QxMTLu8fPg3Nng5+7IzNJLWnHi5ekgN2rw8/ba5PDU2uSfn59AYZqamZlcXF03NzejoqHn6/Ld4+oODg7d2dqvr6/q8fbu7Oo7W5MpKSpsmMdxcXLe1dG9vb2Sf25OTk/j3Nbl1cpMcKZFRUZkjsA2VJJ8fHwiPXJGaaB/f4Dd1dQqRXy3zuWZqsmOiodRYnllZmd1hqJpeZPW2dlchblzdXZBQUEPIDjFyM9WfbVghrBRdq6Xn6tpaWkSI0Oqu9qyxN+4vMJ0gZlteIgcNGANFyagprCgs9GAk7mSmKMeKjiwt75YfalXZoBBTmWssLaGipFBOS4XJCVfcIqBhIl9bFvGvrXAtKCOk5gKGS46JBilq7RVWFwjMyuVoOllb3lsXk8lFg5kdvp3hfK1r6qyo5Gax6iFkKViiJufk4alquKTjIs6cIRXNyiv1LqKpqjE38t8jLCzq6GGvZdrr35uroH7pmE0AAAAEnRSTlMA/u8g/v6/r/4gtyC/v7+wr69GtHW3AAAd4UlEQVR42sTYb0wTZxzAceaSLfvzYrlcr60pjacFet4oLRVE0ris3SQW2u2UIuCk8bBGaRFaqIaillqnlT+rUqUoAXVDQyIbDNwSFifBOaMuJmrYoi/IQjLiC7fwwhfGZVv2e55C/+yYDBj4FcqhJN7H3z3PXU1Keu31ly6/FNeOqc5HOxXpS9ylSD9G+07Qj9ClS1+eeumTQCDw1htJ0MuvLCMXsVdfBgc69S9wU6eNTxwXd+bXcddQ30b6HPrs88+m+3gq/A380bfXLp3/ieM4feANcCyTEotZ9rKXk17HjggEU6KKOMf1eMe1aQaWJChiGJBcBwjJ8/o3k5JezSYWN+krSZd3YIhwItiROBEMAUdMMjUNYSC5duonjud5LpCURBKL3TIMOX/v65m6+/Xd+G7GdyvWVzN269bNu6M8qnopICSCfPHdiriSI61ZAx/THYz2TrTW1tZd0NZIa6dat3a6ra0HO6xLC9lxT+jAlsQijoNCSMwBkGhrd3U0F/Jutztr6SZyL3cGSdxEkhEDIFBsIPEQgeJd+Nza2lwYhOIhu8X4b20LwGsFIdpNRMpm/yeI0IGbYiSvCPlwiRBwQALHu9OtbS5uRJCRGGRb9UkKvvSpurJOp/trKtKIrmrjvt27uw4UHTL6KVXRESYzq8tMmWuNJ2vmBcn914mAIzfX4bF5OI/NJpxIDJKoWL363XXNZxrtUHkUIjITqpMIQqzKJPz+AVEasc3ffcQ8FNj8oXlzLSGvbcuorTFfPX3EX2vOXNCllYwhiQNZkWuw8Dxj5NwdvpkhiQ5QoLasKxZAuovkZvhac2Bb0YHU0xfNFYS6oi0lbYBp22zO2EyI0vpWthUd8Ov9YSbt4nwWey4UHYlwIiYLz13RMnzQFz8SISTKwE1DWjAEV51Wm03MnqjtELzOExIbiWAiGKJVGnmbb03cNiyAJDhgJAjSYG/AkEVNCBFOBIpA0vUMQEKhkC8SWFqFA4k5lmNIA5SzlBBoSgGOaHGXFs8wHO9xOC0eWySPLxGS6Fi+/HkQkhFJRKoUkUilEhOS7BQim0gRkbIUgkghRATJwrfzhkAz7r+xXQsEFqfTY2dZBSpoO4ivrRgk3vFciIQkSLVRQa7UGI0MQZIMo2aMpDEdjtFvyFQaGTN/CPRPiOk+ZFqTHDI4LD6Lz2dxVjndVqlUWihtlNptsWvrORCr1SqciIykjGqVlFRQarGEkrIKmVpKkWqKgBcZQakYsXwBEKHENB6euG8ImXy+kNPncPgsJSUON6WQS6WKbMpuC9+OjOSdXbveAcmMECu0xGsElwhZkTvWZ9s3XHpooKCndKLA2Rtur9ndU7Cvb7h2qDL8oL3y+6sHh1vae0dKS+8nQJa/CEg/vLsFSEyCizhMYwPusH8g7DenFrA9qT2OAX9PWt/QxpPqLnO7d+I2hgxvfdg93otG8p8hKvgFqxqtbfgKC1wk+X8gvwMhnmIwIAuGUGW1RVn+NmWBtVTZYxkYK8jcfGRjZn17dS+GrBlu6d01NjY6ujYi2bJl9VlwxCCF1sINAkhWdhbLsmIxwZByWBxyjZols0npAiGXL1/+3RQLLAan0wA5nVXjB6jysvBJc4V+qOGGviBcGrjYE65IO91UWdkz8jA80hMua6nsKJmYGG9ubi4uLl63ZcuZM2fPRiEnrNbCGSCaDFKsJ0kZQBjSKBOTJMkyYvXCIMcRpKpK63JV4cDidGpR6fDJUQqWoliFtQH2n1JLlcOt2PvBe++RanfQ4wv5LBzalW0dvg6oGUI/VhgHKYSUAohIJVJJRSK4wuQSuUgigWOJSq9YIKS/v/+bKpfLtQHldQEFHCiNRo8hajWlUFihLosWIOe2792rZhV2uMs7mCYZUCB8n4GCdlYxI2T2JAtcI3f6jx//pgQ5cnJAguaCJ+Jy6fMBYldQYlLMIojcbtE63Qr5OQUL39s9cHsxHu2Uybjp4AoRq7efAwhOAFnM8EQAgh05q1aBxOXSolzKTflKDCFlIJFDAME3RDnc2OV2D6yi+qOdnQyDLCRKLBZCGhuXDtIfg6RugJR6SPnRpk0Iwqpl9VdISgGQoKUKIPBvHFEBJL3eCA8VDIcvLj4Ib9BhPcVDwNGoX3II5EUjwW2IQChW3PS+xohGonADhLcrcCxvMZicmveNxvpOBJle7cFESOOSQ0Di9ZbgqnCuDUoEcVNkvV5/hXPb7SxAtBYPj0IPkCYT7Afp7w8ODnZyNlBs3Vpc3Gx/8RDswAYTqsqFIBY3z1zR6+H8bbzbbYGt2eGw4BwIos/XDF64cGGwkww2FEMvEnJnerF7ESMykTgI73HAynca4Pxt6GoyoSdh2Gl9ITiGjS3/ws6dO/cflfENoICEkBNLBTmOtt8NUAkASrw5OS6XExzTE7HB2RtQEUiuwwc3C6u1oSOEIJv2YwgaSXMQgsUuj4egtHOBpIhmuduoVLNBvC4TQFrKc1KVWm0MEvSFsMMZKKrRwk84PEHWrQyMWGAk2vz9b//284X9g4NHGY7LKjqiVMP2uwBIoPTYsUoSeYqGqomZUj2TPwfyQ4kXdi0vLI+W8vKM1FTYuUpu3kIQxm0P2izQ2GGapj/9cAQg7gPDeXn0sRsjBq3m0Hpd3tBHRribpB3Oo+m84bYoZPXcIYE9vRW1ew6LxJXteXlFxExd+fPpv0PuIAjefHPKAZKRsWqV93Fd3ZNNH+kZimXdPEl20/T6snZaVwCQqzTdHgZXaZVTf6zg9sO8NoAOIQWy1CaukRMn5rDYh/I0BHEjL6u6oLYbIDM22UhEgmuwUHhpASQjAyjTkCd1T359jCBuiqLc6mqaLhOLmTKd7hfDbZoOe4KOME3/4gzQ46Gq9RtDoXFwtdg6Wnrp9cp4SLDhhD3nP0OKuiQEsTEvFQ7TAPLc3H8SfzyTPrUmPmvhSysaSB7V3XpcB5eWAz0K8ryfpgM8yT3Q6R4a+nR0Dfx3EDp2rmrvGR3T3Q8Z2uljLo7jO0ZoOu2fkPI57VqKY8PE7BDRU8mk6q8/rE+fSRIngiH4TuJtgfWe86jO+6guH/ZdB4ozD3UbORlzG05+xXjlkCMUWoOOnemZn+p0E6HQqE531cHISI9tmC6LXyNB+wlr1pwgN/bUzArBTUomJwlyUiWAYAeWwGMwnogG7h8oh4UjSU4mY9J0utHk5JADQR7CsVZTX5P5AG4s47QuM93YBA/17sT7CHuO2j4XCAAyCQFkloQQTMEvj+rgQ6ONOIyMDLdyPV0aSoa3ISBpWa8rNWjzBzVajZHjBnS63en1R5uamsTxkIbGc9s/YFfOAdKW10fMF3IHdi0vLPbI2xEXQFwuuK5+fVz3N/tmF9NWGcZxP5jOCzWetD1t0kDaA93BtqOlKcG0IqntKFQptoq6nDqQqCnMWqzSabuosMAMsiVKqpWULAtjYkwweCFzZMLFmA6FBAhMh0OdLmM6o3MzS7zx/76nX7A5SOa48gelb7suOb/zvM/7XZtJ9EDrikscGHhXFTj+DiDv/mwNHH8YIp84HJFC5aDV2htJdAJ7/b5sEYW7RbJ77SL+inflWSIlGEu3MVfFoGDYAtKvKFIiMzM5OROvwaJ0iwbzERtfigdyBLxmaqYikUgHGBhUqYq3U4vmByut1uoXXzA56Hyk8JEhldWLmRXaabY+PUOEyKPu+voWz9r7kYr9rX6/P5gS2dlW7e+G2ZXkKyRMrmGz3ihhUyInT54881UXiQYvcBzHaziNjf/iFcKvVKRZ73Cg6+74VKW6mNhOPR7+zmrdhLGMKZLACB5N2Ddoi3sIe5Ej2SKddne9sGaRwQrKoZSIvzjYzlwdli2QyAown2AyImeIiGaLyejR6TgNkeF/hQb4shlAhFCtUvUNJLaD5mbkdg1fVPTai8iPVx/b/s4bUyrVNw+8TUy+69uULVKvra/3rllEoaUoUCzQSsnASipfc47A40xlVyknuFxOp05nseg4j+sXUeRPcQ3CBeIq69YDHR1VhHiFdSvPcw1dpYJNT8bGj5kCqg8e6Ol5443mgKpmW5YIb+Nttesz+rWePDPzVVeRxemsc5bn5VmAx5WMyBceF/AAX4X1IWf0wCegozWg2sp5BE6tVhchfkVFQkRZg5D0vLp9S5+qwpstQhaV2tdH5P6TM2cg4iyvq6srdxIsHk8yRyxOTzTqwVvnA1bVQ09R8s7vJ+Wt4KmQjgLD6C6r6qO+QxiBDeYvE9GPjPSvV0RGZ4gIPIgEAc9//gKPMWeKamuaYCxdrFjME8ENCG7F6NiqCtQkIHJvWkQ/ojeuT0SQ7JOjokg5NARqgmvTjY2VZ1j8NE1epnwxmJfBGW6r6fs05K5fKbJeEZmZGaUi5fAQNEAQLE5ylzPkrQWnK4KVIYmhXguRe9NVq2rdqtaZmZljEMmDCPUQVUSTOoJzbSIWwWZDa8zKiAhMIPIWIoJ1loxIAZvPMjcEiOw/diwlwmkyJsiUumj0wIFoNFqXHZN/iZUFzZeG5/VKIvI4RAAVMdlMGRG5UmrAwCOfuS6ky15orypSmlERPC5HB4g4okgfJA2g7QCN07MidU4LPggEQYNZjMfR6SYij2dERkwjKyMik1+XR6yyss1vTNtsTudIWgS3VZNB4F0YY0UiEQfWSWCiA9AQJT589kPKs3UCr4/A1iVgOiZ4HANURDS5763Xt5l4/j/OkeJGjADU3UNtu9MuK0XKIYKYpOBdLkckKQITBKUcEoBKpEQsroTEblc6BARE8EQH7C2y199/HOAsCiJiq9LzqzS/CulVarxMIf93D0p+b+UmtTxLRDWZLbI8JA5KlIjUQYVqLFPB+9FOt9tt74hadE7kFBFpeh87cPilIiN60yoReel5M7Mc/eDOnZvGtcxySlgAjwyyxsqdhWmRQADN71iDDrdcx5GQXClCLODhrANwSstQwQOddrt9oCMKJSyeDgy4W5qeoJvu92HlcRtv2rLl2mMt5Sam+qUCJoN2507ikDu+IiZt1aB2Zeg0aZGKwI8QUetIEhQVpTSgRLKd4BIsOhELQBl9BoWKoWn7BKB1I4WOgQG7+2XsJgIcBexpwqpM6crRr1yazaBEKg0eUqdebvZVasSS5og0G59felXk6bGWdfLHs2azWm3GnwaYpEX4pAiHfwL4SIM5SAtJ4AQ5jrN4MNAELqMD0107u3fvW4QebMeNYZJ2zapV66dPjUMsfQ7XcJlp1ktMhqqhVQeNoA8XZyZAhJpwEAmFiUjE4QqGzWmq8WjvVwNzY2ORLuQTOIgAQeB5G3q/RKddwmLDp0nWJHuZlWyc2G2+1nxEXpMssEM+hikaDy/Lnt2Z7mJcupqIKhAIVJrVYkTQ9KTp97rUjT51q683Zg7G23tj3eHW1pg33tgdr437NFywUYjH/R5za6u5OxaMx9Vo4xIJpcTNGsg+8L592GuUXXx319nplSIKr9cbFnvFmJpJ4R08Ur0iLSpzU6WhwlV79gARUQPUK0KWiJHrjat94ZC/IdjdjmlnazUO6vlj3tri3laXS+3jfEGvQ9cbCwcb28PBWqUSFm5DS339PqAg7Dsfv/jumytFtKFQqL9t0Iw2tDK7EcplVsC2yZOSjczqImVlZYNYKIVGiqRIsb893h3yd4eK1eZYt/+l1t7ixuJWX3Vt3NsYdrl0Pk/c542EfLFQvDoYb7TYRYukh/izuHg+1pclUmLA1Se7+daa7kG8vBbe1hJCVeVqAxbaagX2DxaBBrDMBB0cqr7OaREzuxjJrdZZ8KvDsITMHMnc0eHwRJ3nY2iF3e4WaADqkmQxfP7vC9kiSjmTK2OScGFmFXybKNc4CMmmx1okIkVcUgQXq6PtEZpZmsQ6MjehBHVJ6GSY4omK5JVHO+wtokdLixvQF+D77xcX/84SUaB0I4DIX+CgBV0IkkRNPVIiFIigp8QbGYuUCOkrD6RAl57Ebe9EG2x3Uw5euPDbhfWZj1ScPn36oIVWLWKQlxRxUiP6jpOU0jVOAGJXGUmQZbsUdrfoQccrHfCgnDq1tPT5+ogE9pedPqgjFUs0EK/e4rRw4hIJRKCZ7eGBB1l8xEJqB1ZS7QQiQoEORDrdyWr2PfhtnUTKIFKkBmmRBswsoi6eoOGIUzr/aZYjFsAhPqEWoRoREUJS5JNOeFCWixgYeYG8wCBn5HKcLJUUyFn5ZqZKyjIFOHSK4nVAWy2INKQ8dGSix7sS2EQg6I0eOOFEB3LCSC89gUAk9DYe/T96cpcDJiQvUiIodzowtGcNoEX2G8jkCE6X8pJCPWPIL1QaZRJjYSGvwdlZJcMXkqMg1ycyOTpaUYmKlRQR9Ep6yEf2Ms43sE8/LRFhk9AXyiqbpkFtVnM8DI0JN+68OxUQpLoRy69Vj5E1bZb95ofq2KeZiBjYwlxWK92sxLNEppQZZFpGKzXIJaykimWvT2QUfNXVlUwRvgobHISmvdhxo4dk8JqcqMFfLexexZZPYZVpC91t1PBAL9Hm5stgIpKwlXZ1YcueHpF45A+wcJUckUpXLq9jJH+dIsdycg5jl4q2WUIVm68AuduaerB5iLEfHBQlAGebmiD3Ku70Y4VVIyZatxARnjcqceBGa0iadDpKx8Zg8uQLJtPIiO35msqhI+uT7Dlff3wzdqyoCOeQ5FONbU17e6hHE+IhiiBGe58hW+6FpD5jAdEGjEYs7j4iy1XkaluIBXA1jKVN+Ivn3tz1Xtl6nUW59Nns5Q0bd+3a9e3gccJPR6d+mp09vvv48RPTn01MHA0Fg6HQid/BxMTE4Yf6as4eGR8/e+j5Q89/dRYcGVr06IK7gwcpF8axcwQ2btgzPDx8+tz83Nye9RHZcOny7OU/cmYwCD50orZ2dnZ26kRte3//CcLU9JS3NoQvqoanP/uMaBxemN+TYRiP+T3f9bb3h0LBv08BqAQmJ3/8cXT0GFzmITI8DJFbmBsMRO4cnrv8xyXi8fPvR48iGD9NHZ0CKE/T1/g5+ju1WJhbOLychYXDE99MT+M/4NMHRU6dTJtsJCJ77r7pptsUzI2l5Pab7sjZcOnSxgDYMze8QJgbnpsjD9xLVA8RFMH8xqx4zBM2EDbmkL/zNUmTpcmUyZunz8HmDvJd3RLmRqK4hXzr+J4NZX99S9jx7Y4d3+JBilcQIOP9K98WP1mxY8f+wNLSKbC0tHRukqpMTj537tz9d8EDJrdLbiC33HbrTf/zTztn05q4Ewbw7/CYSSYoAVtTI8E0DZjT6qGXRRERAjkU9CoRLIJCTl5iEXuXqiAeFikeCgu97aW99XP9JxN1zeo22epm/S/7a5uXdtLpj8zL4xOnp0cEa+YjqCSKNfPRVBLJmvkoKolkzXwUlUQSPURRyd8rgpnMHnRYodNTziua2Fv0RESyyfgusx6sGNHzO+/ifHwP2omIXOVje0jDCo2e3oNLIr6vaOWERBp+NiJIELo+kZ2i8ZMSyZ8xPsprkVEy2fCLJNuMj2WwCE7JsoxhB1E8ukhSBR83a5EecfCLfGbARz1YhHutvjyz4KfUgYH5W0R0wYPHnghtV73ZjLSd+Gx2tyXiKxpGZKwDCG3ANh62tSvAj1qW7b8xjArM1NLhsa1dHE+Ef0163NprkX4y+cIw6VhsmWDULRF+si76GE6knpMSmRZWnpRxpXnDO0WjftWryo7FjueVr2jcNV7R0USELzGPeG0tUozFqnTUqmOALRHh07qoEbJpFZ9tT6SlwlKtcsCgQQ8ca/4MQivRkuFG/s0ib6JYicVueJGAPipCm1aCinxzRd444D0RKw06Famzv1nk02TymYy4E5dvyiEiQn04XYlo/bM3brgQHIupd6ZVFIGIj7uPivAWItvL/nyKDR2aOqr1zwClL6QrSKQ1EQwRauL/QSQU/0T+XpFCzGNmBYp8PmURbDQ9atyWSLnZ/BaLTabN9JYImm+KhhPBx/j4cKzlnxDPqciHYi2M0THA+A+LEA1R5w9FFxWEg0XiraqP25+LzMY7RQNERAzHAIsoWGSH/SIfeYWIEX8kER7hPyqipBDZif63NOkcvIcCgFhwkckhICCglBIgIvfSe8hRkfuXl6/p9HPxZUxFhL1Fz98VoSt14HE6ryHYwJnwc/RaBZSRYQCAZVdEZjSgIrK4IxISbx5pAdTW80hI/CKsKzC4gFGqPbXBbI6QOprauDa65i3HsEY78SLSNbjogIUASdDJKIwnwp6EiFkbOKjNjlKOaEqOmjBzl4rGGVBJZU3wkUiAqAHnMA86APAaBi6kiOBoDpG3NB8SgNVqTajI5bfWkoroI80HF/6OdNpDGLU12UKd3EjhzIEKTskEDTE2+MiUiAjZDacYQLQE8IuEj7UoW8mHFhD2d/a48QtNC0/VXrunEpHLoW3YjNNx2B0RChVh5znIogcjx5yMCO3sZKMiucQhAXhRyTI6cFmEdVBB5+FHMEtEVAAVl0olHpAerrNz1dZbV3t4a41nJLhqrXjWXNKt1ldN61CR24rWj/ttq7RQ9v3hl4ocTrAIIJRJxvPnSL6NxQfIQ1zEXRYIPcTjYzqPxMh57AdoIS1wHjmc4HmEwuVjjWvgiUhnXf9LjOCbECkfmBAFQZDZQ5HJbwkhItefyo5pJdcil6ZdJ23p6alomgvSO2zTeXK5I/fglR5t7k3h6Wn+vggvM5lS9kAyDMuHEAGM5UKMsBIhFoQ+xt9TptilRKLfBCbw97EVDsYQcEcwHAMUfEcowpctkRtv4Pp57lfciDQBgkRQRCIoKxHarwXC/VxyGRcKJJJcSJLXU2ZfCnXv4kmhrAJkpbY3WCcLhQdJSoUZtXiZfEF4ELvag8CyQqjOnv08IyRzOkEuz1yaulCluWuvK9wIugIUUgQD/2U2W+UpdH08m41CzCOXRsdCFxAa3ulYbqxZyeKrnGl5w2+oML5xTf1fYy4WHbU21DFss5tFCZ7Z59dwIdpqc9SGUHhBIyhn7p81SISd2Qkz0qgwoFbBRZMui/QgTwwLhdallPlB5JY2RNKuvhUKzRAivKWV0EOiBhaCYNRrRkvQ/3jsivAmDi1CIK0rs370MZ41+vSAJE/GguDkZ3d+kfijQJBvZrOpwKMQIgkkdlMPCQMsBYLhrkQaNHoi7QsIL0JVSrtPrNZh/P0PImdAQEtv1Aoh0jFzFTG0CKyCRkxFHDGcSLaQ97jdtJ9W/lMFQGTZdD6/oCIF9jtXRMRkWQRonM+PWFYM0dlBUDHoIAKGcIg6APlEmO5CdXbErUGwIsVxPH1iVeQ4gYrEk99x+3o+mSwBCBzXSiadUwka97NOPgAV2YO0yWudSNCIU/tAAP1GowguRr4xIzel4W5jjRX5LACfSo0beS1gZk+x3OGwwUFj5q6wy/0IgJUkFVz0K8nt72eSGY8lB9IKBPgtcGanIiqTOBRGlXnlg3ktHzsp061R61SCRlck7scTQaILAsCiOCQ5eVmUNiKKSEjdxOPW6QSNRCRv5ny8UpHuxKUPYE4mBSJ7T7YrEWU8IdyNcjnaroJHLZyb5zCEhGkD5GqG6F43B8EeiAGdPSgb789rUTZh/C8mH3ImzCVgGAA1AQGwZgXA0cFl8AwD4fwxYPiNRoTO7E0GkDIwjbbk2DkIQNQAHqwOBmDsHtnMr34hHaS/lT2Wwy2RpW2ny+WFYXfJ5G7ZhOkNLeTYLsSf/NgMI8IBxj0QHXX0KIYREbDBgTJl0iJw1nmASMDD0N1sPCGRpIXOgBI6jG93kJnVZHWeyUi1ECLKQDRUJA7bz8xQuLZPR0TJDTrADoyU2hkwEAB+BDjrtOEaAZjADUz+YJHkcnn/M5Hq8oZsz+ZG4n2RCGKtYBGP/SKFTWc/kaDxcJETCRrfE7nrufQXiyLd9hHoGv3Wgpz0upkQIog/TqaR59FhnR3WrxCHJD7RgULnESnUgx6aamRV7jBUNkVjxuhFjv+cHaFwIihd9Ph6vhaxi8WXft8mJYrFLgD3tZhGkOr2eyIVqfbTiSARCsYYHQzG+MC3y7bAB+nsDS58iELBcBT+vAglEpFGv+vjnoq0u11iNKHfaQJwle4IgfBcLepUpF5dkMtKJyWyi//9Wvs7u/cK8Z/I0UWYenmXZQ1gtHVeBcjelFsKeKBFeckA7pfLndMR2eZ/vTSJ8E/kT4pEsmY+ikoiWTMfRSWRrJmPopJI1sxHUcl/Q8/cLJgt7e8AAAAASUVORK5CYII=", + "image": "tb-image:Y2FyZHNfc3lzdGVtX2J1bmRsZV9pbWFnZS5wbmc=:IkNhcmRzIiBzeXN0ZW0gYnVuZGxlIGltYWdl;data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAMAAAB+IdObAAAC/VBMVEUAAAAuTILg4ODf398dMkk0SWLg4ODg4OAyP1UrQF8sRH2/v8fDx8dFbHsDCBhKWGvDxsYaNET///8AAADg4OD4+Pjn5+fj4+P6+vrv7+/q8voKCgre3d3R2eft7e3TzMjP1+UdHR3My8vQzs7r6+vz8/PQ0NCKiorS0tPb29zm8Pnp6enQzMrX19j19fXNzc3m5eXW1dacnJzT1NnT2+nJycrx8fHf6vTMztHh7PXP1ePTzswbGxwWFhYsLC3Y4u7Wzcjj7ffT1NTS2OHl4NvZ1tXPz9QhISHVzst3d3jZ2drX1NNKSkrP1N6Xl5fn495tbW3d3uHW0tGysrLs7/HY3OG4ubnl7vbi6fLL0NhSUlLd5/PS0NCqqalXVlbp7e/Zz8rs6eXg4+XLzM0yUI3e4ubf0coSEhLc5vGsrKynp6bl6OvV2eBjY2MkJCbO0tvHxsbT1t3Q0dfBwMCSkpLj5efq5eG1tbU2MihVav/d3+Ta2Nfa09COjo7W3+vMztXl6u7Y3uakpKSEhIQ7Ozxynsy7u7ve2dbDw8QxMTLu8fPg3Nng5+7IzNJLWnHi5ekgN2rw8/ba5PDU2uSfn59AYZqamZlcXF03NzejoqHn6/Ld4+oODg7d2dqvr6/q8fbu7Oo7W5MpKSpsmMdxcXLe1dG9vb2Sf25OTk/j3Nbl1cpMcKZFRUZkjsA2VJJ8fHwiPXJGaaB/f4Dd1dQqRXy3zuWZqsmOiodRYnllZmd1hqJpeZPW2dlchblzdXZBQUEPIDjFyM9WfbVghrBRdq6Xn6tpaWkSI0Oqu9qyxN+4vMJ0gZlteIgcNGANFyagprCgs9GAk7mSmKMeKjiwt75YfalXZoBBTmWssLaGipFBOS4XJCVfcIqBhIl9bFvGvrXAtKCOk5gKGS46JBilq7RVWFwjMyuVoOllb3lsXk8lFg5kdvp3hfK1r6qyo5Gax6iFkKViiJufk4alquKTjIs6cIRXNyiv1LqKpqjE38t8jLCzq6GGvZdrr35uroH7pmE0AAAAEnRSTlMA/u8g/v6/r/4gtyC/v7+wr69GtHW3AAAd4UlEQVR42sTYb0wTZxzAceaSLfvzYrlcr60pjacFet4oLRVE0ris3SQW2u2UIuCk8bBGaRFaqIaillqnlT+rUqUoAXVDQyIbDNwSFifBOaMuJmrYoi/IQjLiC7fwwhfGZVv2e55C/+yYDBj4FcqhJN7H3z3PXU1Keu31ly6/FNeOqc5HOxXpS9ylSD9G+07Qj9ClS1+eeumTQCDw1htJ0MuvLCMXsVdfBgc69S9wU6eNTxwXd+bXcddQ30b6HPrs88+m+3gq/A380bfXLp3/ieM4feANcCyTEotZ9rKXk17HjggEU6KKOMf1eMe1aQaWJChiGJBcBwjJ8/o3k5JezSYWN+krSZd3YIhwItiROBEMAUdMMjUNYSC5duonjud5LpCURBKL3TIMOX/v65m6+/Xd+G7GdyvWVzN269bNu6M8qnopICSCfPHdiriSI61ZAx/THYz2TrTW1tZd0NZIa6dat3a6ra0HO6xLC9lxT+jAlsQijoNCSMwBkGhrd3U0F/Jutztr6SZyL3cGSdxEkhEDIFBsIPEQgeJd+Nza2lwYhOIhu8X4b20LwGsFIdpNRMpm/yeI0IGbYiSvCPlwiRBwQALHu9OtbS5uRJCRGGRb9UkKvvSpurJOp/trKtKIrmrjvt27uw4UHTL6KVXRESYzq8tMmWuNJ2vmBcn914mAIzfX4bF5OI/NJpxIDJKoWL363XXNZxrtUHkUIjITqpMIQqzKJPz+AVEasc3ffcQ8FNj8oXlzLSGvbcuorTFfPX3EX2vOXNCllYwhiQNZkWuw8Dxj5NwdvpkhiQ5QoLasKxZAuovkZvhac2Bb0YHU0xfNFYS6oi0lbYBp22zO2EyI0vpWthUd8Ov9YSbt4nwWey4UHYlwIiYLz13RMnzQFz8SISTKwE1DWjAEV51Wm03MnqjtELzOExIbiWAiGKJVGnmbb03cNiyAJDhgJAjSYG/AkEVNCBFOBIpA0vUMQEKhkC8SWFqFA4k5lmNIA5SzlBBoSgGOaHGXFs8wHO9xOC0eWySPLxGS6Fi+/HkQkhFJRKoUkUilEhOS7BQim0gRkbIUgkghRATJwrfzhkAz7r+xXQsEFqfTY2dZBSpoO4ivrRgk3vFciIQkSLVRQa7UGI0MQZIMo2aMpDEdjtFvyFQaGTN/CPRPiOk+ZFqTHDI4LD6Lz2dxVjndVqlUWihtlNptsWvrORCr1SqciIykjGqVlFRQarGEkrIKmVpKkWqKgBcZQakYsXwBEKHENB6euG8ImXy+kNPncPgsJSUON6WQS6WKbMpuC9+OjOSdXbveAcmMECu0xGsElwhZkTvWZ9s3XHpooKCndKLA2Rtur9ndU7Cvb7h2qDL8oL3y+6sHh1vae0dKS+8nQJa/CEg/vLsFSEyCizhMYwPusH8g7DenFrA9qT2OAX9PWt/QxpPqLnO7d+I2hgxvfdg93otG8p8hKvgFqxqtbfgKC1wk+X8gvwMhnmIwIAuGUGW1RVn+NmWBtVTZYxkYK8jcfGRjZn17dS+GrBlu6d01NjY6ujYi2bJl9VlwxCCF1sINAkhWdhbLsmIxwZByWBxyjZols0npAiGXL1/+3RQLLAan0wA5nVXjB6jysvBJc4V+qOGGviBcGrjYE65IO91UWdkz8jA80hMua6nsKJmYGG9ubi4uLl63ZcuZM2fPRiEnrNbCGSCaDFKsJ0kZQBjSKBOTJMkyYvXCIMcRpKpK63JV4cDidGpR6fDJUQqWoliFtQH2n1JLlcOt2PvBe++RanfQ4wv5LBzalW0dvg6oGUI/VhgHKYSUAohIJVJJRSK4wuQSuUgigWOJSq9YIKS/v/+bKpfLtQHldQEFHCiNRo8hajWlUFihLosWIOe2792rZhV2uMs7mCYZUCB8n4GCdlYxI2T2JAtcI3f6jx//pgQ5cnJAguaCJ+Jy6fMBYldQYlLMIojcbtE63Qr5OQUL39s9cHsxHu2Uybjp4AoRq7efAwhOAFnM8EQAgh05q1aBxOXSolzKTflKDCFlIJFDAME3RDnc2OV2D6yi+qOdnQyDLCRKLBZCGhuXDtIfg6RugJR6SPnRpk0Iwqpl9VdISgGQoKUKIPBvHFEBJL3eCA8VDIcvLj4Ib9BhPcVDwNGoX3II5EUjwW2IQChW3PS+xohGonADhLcrcCxvMZicmveNxvpOBJle7cFESOOSQ0Di9ZbgqnCuDUoEcVNkvV5/hXPb7SxAtBYPj0IPkCYT7Afp7w8ODnZyNlBs3Vpc3Gx/8RDswAYTqsqFIBY3z1zR6+H8bbzbbYGt2eGw4BwIos/XDF64cGGwkww2FEMvEnJnerF7ESMykTgI73HAynca4Pxt6GoyoSdh2Gl9ITiGjS3/ws6dO/cflfENoICEkBNLBTmOtt8NUAkASrw5OS6XExzTE7HB2RtQEUiuwwc3C6u1oSOEIJv2YwgaSXMQgsUuj4egtHOBpIhmuduoVLNBvC4TQFrKc1KVWm0MEvSFsMMZKKrRwk84PEHWrQyMWGAk2vz9b//284X9g4NHGY7LKjqiVMP2uwBIoPTYsUoSeYqGqomZUj2TPwfyQ4kXdi0vLI+W8vKM1FTYuUpu3kIQxm0P2izQ2GGapj/9cAQg7gPDeXn0sRsjBq3m0Hpd3tBHRribpB3Oo+m84bYoZPXcIYE9vRW1ew6LxJXteXlFxExd+fPpv0PuIAjefHPKAZKRsWqV93Fd3ZNNH+kZimXdPEl20/T6snZaVwCQqzTdHgZXaZVTf6zg9sO8NoAOIQWy1CaukRMn5rDYh/I0BHEjL6u6oLYbIDM22UhEgmuwUHhpASQjAyjTkCd1T359jCBuiqLc6mqaLhOLmTKd7hfDbZoOe4KOME3/4gzQ46Gq9RtDoXFwtdg6Wnrp9cp4SLDhhD3nP0OKuiQEsTEvFQ7TAPLc3H8SfzyTPrUmPmvhSysaSB7V3XpcB5eWAz0K8ryfpgM8yT3Q6R4a+nR0Dfx3EDp2rmrvGR3T3Q8Z2uljLo7jO0ZoOu2fkPI57VqKY8PE7BDRU8mk6q8/rE+fSRIngiH4TuJtgfWe86jO+6guH/ZdB4ozD3UbORlzG05+xXjlkCMUWoOOnemZn+p0E6HQqE531cHISI9tmC6LXyNB+wlr1pwgN/bUzArBTUomJwlyUiWAYAeWwGMwnogG7h8oh4UjSU4mY9J0utHk5JADQR7CsVZTX5P5AG4s47QuM93YBA/17sT7CHuO2j4XCAAyCQFkloQQTMEvj+rgQ6ONOIyMDLdyPV0aSoa3ISBpWa8rNWjzBzVajZHjBnS63en1R5uamsTxkIbGc9s/YFfOAdKW10fMF3IHdi0vLPbI2xEXQFwuuK5+fVz3N/tmF9NWGcZxP5jOCzWetD1t0kDaA93BtqOlKcG0IqntKFQptoq6nDqQqCnMWqzSabuosMAMsiVKqpWULAtjYkwweCFzZMLFmA6FBAhMh0OdLmM6o3MzS7zx/76nX7A5SOa48gelb7suOb/zvM/7XZtJ9EDrikscGHhXFTj+DiDv/mwNHH8YIp84HJFC5aDV2htJdAJ7/b5sEYW7RbJ77SL+inflWSIlGEu3MVfFoGDYAtKvKFIiMzM5OROvwaJ0iwbzERtfigdyBLxmaqYikUgHGBhUqYq3U4vmByut1uoXXzA56Hyk8JEhldWLmRXaabY+PUOEyKPu+voWz9r7kYr9rX6/P5gS2dlW7e+G2ZXkKyRMrmGz3ihhUyInT54881UXiQYvcBzHaziNjf/iFcKvVKRZ73Cg6+74VKW6mNhOPR7+zmrdhLGMKZLACB5N2Ddoi3sIe5Ej2SKddne9sGaRwQrKoZSIvzjYzlwdli2QyAown2AyImeIiGaLyejR6TgNkeF/hQb4shlAhFCtUvUNJLaD5mbkdg1fVPTai8iPVx/b/s4bUyrVNw+8TUy+69uULVKvra/3rllEoaUoUCzQSsnASipfc47A40xlVyknuFxOp05nseg4j+sXUeRPcQ3CBeIq69YDHR1VhHiFdSvPcw1dpYJNT8bGj5kCqg8e6Ol5443mgKpmW5YIb+Nttesz+rWePDPzVVeRxemsc5bn5VmAx5WMyBceF/AAX4X1IWf0wCegozWg2sp5BE6tVhchfkVFQkRZg5D0vLp9S5+qwpstQhaV2tdH5P6TM2cg4iyvq6srdxIsHk8yRyxOTzTqwVvnA1bVQ09R8s7vJ+Wt4KmQjgLD6C6r6qO+QxiBDeYvE9GPjPSvV0RGZ4gIPIgEAc9//gKPMWeKamuaYCxdrFjME8ENCG7F6NiqCtQkIHJvWkQ/ojeuT0SQ7JOjokg5NARqgmvTjY2VZ1j8NE1epnwxmJfBGW6r6fs05K5fKbJeEZmZGaUi5fAQNEAQLE5ylzPkrQWnK4KVIYmhXguRe9NVq2rdqtaZmZljEMmDCPUQVUSTOoJzbSIWwWZDa8zKiAhMIPIWIoJ1loxIAZvPMjcEiOw/diwlwmkyJsiUumj0wIFoNFqXHZN/iZUFzZeG5/VKIvI4RAAVMdlMGRG5UmrAwCOfuS6ky15orypSmlERPC5HB4g4okgfJA2g7QCN07MidU4LPggEQYNZjMfR6SYij2dERkwjKyMik1+XR6yyss1vTNtsTudIWgS3VZNB4F0YY0UiEQfWSWCiA9AQJT589kPKs3UCr4/A1iVgOiZ4HANURDS5763Xt5l4/j/OkeJGjADU3UNtu9MuK0XKIYKYpOBdLkckKQITBKUcEoBKpEQsroTEblc6BARE8EQH7C2y199/HOAsCiJiq9LzqzS/CulVarxMIf93D0p+b+UmtTxLRDWZLbI8JA5KlIjUQYVqLFPB+9FOt9tt74hadE7kFBFpeh87cPilIiN60yoReel5M7Mc/eDOnZvGtcxySlgAjwyyxsqdhWmRQADN71iDDrdcx5GQXClCLODhrANwSstQwQOddrt9oCMKJSyeDgy4W5qeoJvu92HlcRtv2rLl2mMt5Sam+qUCJoN2507ikDu+IiZt1aB2Zeg0aZGKwI8QUetIEhQVpTSgRLKd4BIsOhELQBl9BoWKoWn7BKB1I4WOgQG7+2XsJgIcBexpwqpM6crRr1yazaBEKg0eUqdebvZVasSS5og0G59felXk6bGWdfLHs2azWm3GnwaYpEX4pAiHfwL4SIM5SAtJ4AQ5jrN4MNAELqMD0107u3fvW4QebMeNYZJ2zapV66dPjUMsfQ7XcJlp1ktMhqqhVQeNoA8XZyZAhJpwEAmFiUjE4QqGzWmq8WjvVwNzY2ORLuQTOIgAQeB5G3q/RKddwmLDp0nWJHuZlWyc2G2+1nxEXpMssEM+hikaDy/Lnt2Z7mJcupqIKhAIVJrVYkTQ9KTp97rUjT51q683Zg7G23tj3eHW1pg33tgdr437NFywUYjH/R5za6u5OxaMx9Vo4xIJpcTNGsg+8L592GuUXXx319nplSIKr9cbFnvFmJpJ4R08Ur0iLSpzU6WhwlV79gARUQPUK0KWiJHrjat94ZC/IdjdjmlnazUO6vlj3tri3laXS+3jfEGvQ9cbCwcb28PBWqUSFm5DS339PqAg7Dsfv/jumytFtKFQqL9t0Iw2tDK7EcplVsC2yZOSjczqImVlZYNYKIVGiqRIsb893h3yd4eK1eZYt/+l1t7ixuJWX3Vt3NsYdrl0Pk/c542EfLFQvDoYb7TYRYukh/izuHg+1pclUmLA1Se7+daa7kG8vBbe1hJCVeVqAxbaagX2DxaBBrDMBB0cqr7OaREzuxjJrdZZ8KvDsITMHMnc0eHwRJ3nY2iF3e4WaADqkmQxfP7vC9kiSjmTK2OScGFmFXybKNc4CMmmx1okIkVcUgQXq6PtEZpZmsQ6MjehBHVJ6GSY4omK5JVHO+wtokdLixvQF+D77xcX/84SUaB0I4DIX+CgBV0IkkRNPVIiFIigp8QbGYuUCOkrD6RAl57Ebe9EG2x3Uw5euPDbhfWZj1ScPn36oIVWLWKQlxRxUiP6jpOU0jVOAGJXGUmQZbsUdrfoQccrHfCgnDq1tPT5+ogE9pedPqgjFUs0EK/e4rRw4hIJRKCZ7eGBB1l8xEJqB1ZS7QQiQoEORDrdyWr2PfhtnUTKIFKkBmmRBswsoi6eoOGIUzr/aZYjFsAhPqEWoRoREUJS5JNOeFCWixgYeYG8wCBn5HKcLJUUyFn5ZqZKyjIFOHSK4nVAWy2INKQ8dGSix7sS2EQg6I0eOOFEB3LCSC89gUAk9DYe/T96cpcDJiQvUiIodzowtGcNoEX2G8jkCE6X8pJCPWPIL1QaZRJjYSGvwdlZJcMXkqMg1ycyOTpaUYmKlRQR9Ep6yEf2Ms43sE8/LRFhk9AXyiqbpkFtVnM8DI0JN+68OxUQpLoRy69Vj5E1bZb95ofq2KeZiBjYwlxWK92sxLNEppQZZFpGKzXIJaykimWvT2QUfNXVlUwRvgobHISmvdhxo4dk8JqcqMFfLexexZZPYZVpC91t1PBAL9Hm5stgIpKwlXZ1YcueHpF45A+wcJUckUpXLq9jJH+dIsdycg5jl4q2WUIVm68AuduaerB5iLEfHBQlAGebmiD3Ku70Y4VVIyZatxARnjcqceBGa0iadDpKx8Zg8uQLJtPIiO35msqhI+uT7Dlff3wzdqyoCOeQ5FONbU17e6hHE+IhiiBGe58hW+6FpD5jAdEGjEYs7j4iy1XkaluIBXA1jKVN+Ivn3tz1Xtl6nUW59Nns5Q0bd+3a9e3gccJPR6d+mp09vvv48RPTn01MHA0Fg6HQid/BxMTE4Yf6as4eGR8/e+j5Q89/dRYcGVr06IK7gwcpF8axcwQ2btgzPDx8+tz83Nye9RHZcOny7OU/cmYwCD50orZ2dnZ26kRte3//CcLU9JS3NoQvqoanP/uMaBxemN+TYRiP+T3f9bb3h0LBv08BqAQmJ3/8cXT0GFzmITI8DJFbmBsMRO4cnrv8xyXi8fPvR48iGD9NHZ0CKE/T1/g5+ju1WJhbOLychYXDE99MT+M/4NMHRU6dTJtsJCJ77r7pptsUzI2l5Pab7sjZcOnSxgDYMze8QJgbnpsjD9xLVA8RFMH8xqx4zBM2EDbmkL/zNUmTpcmUyZunz8HmDvJd3RLmRqK4hXzr+J4NZX99S9jx7Y4d3+JBilcQIOP9K98WP1mxY8f+wNLSKbC0tHRukqpMTj537tz9d8EDJrdLbiC33HbrTf/zTztn05q4Ewbw7/CYSSYoAVtTI8E0DZjT6qGXRRERAjkU9CoRLIJCTl5iEXuXqiAeFikeCgu97aW99XP9JxN1zeo22epm/S/7a5uXdtLpj8zL4xOnp0cEa+YjqCSKNfPRVBLJmvkoKolkzXwUlUQSPURRyd8rgpnMHnRYodNTziua2Fv0RESyyfgusx6sGNHzO+/ifHwP2omIXOVje0jDCo2e3oNLIr6vaOWERBp+NiJIELo+kZ2i8ZMSyZ8xPsprkVEy2fCLJNuMj2WwCE7JsoxhB1E8ukhSBR83a5EecfCLfGbARz1YhHutvjyz4KfUgYH5W0R0wYPHnghtV73ZjLSd+Gx2tyXiKxpGZKwDCG3ANh62tSvAj1qW7b8xjArM1NLhsa1dHE+Ef0163NprkX4y+cIw6VhsmWDULRF+si76GE6knpMSmRZWnpRxpXnDO0WjftWryo7FjueVr2jcNV7R0USELzGPeG0tUozFqnTUqmOALRHh07qoEbJpFZ9tT6SlwlKtcsCgQQ8ca/4MQivRkuFG/s0ib6JYicVueJGAPipCm1aCinxzRd444D0RKw06Famzv1nk02TymYy4E5dvyiEiQn04XYlo/bM3brgQHIupd6ZVFIGIj7uPivAWItvL/nyKDR2aOqr1zwClL6QrSKQ1EQwRauL/QSQU/0T+XpFCzGNmBYp8PmURbDQ9atyWSLnZ/BaLTabN9JYImm+KhhPBx/j4cKzlnxDPqciHYi2M0THA+A+LEA1R5w9FFxWEg0XiraqP25+LzMY7RQNERAzHAIsoWGSH/SIfeYWIEX8kER7hPyqipBDZif63NOkcvIcCgFhwkckhICCglBIgIvfSe8hRkfuXl6/p9HPxZUxFhL1Fz98VoSt14HE6ryHYwJnwc/RaBZSRYQCAZVdEZjSgIrK4IxISbx5pAdTW80hI/CKsKzC4gFGqPbXBbI6QOprauDa65i3HsEY78SLSNbjogIUASdDJKIwnwp6EiFkbOKjNjlKOaEqOmjBzl4rGGVBJZU3wkUiAqAHnMA86APAaBi6kiOBoDpG3NB8SgNVqTajI5bfWkoroI80HF/6OdNpDGLU12UKd3EjhzIEKTskEDTE2+MiUiAjZDacYQLQE8IuEj7UoW8mHFhD2d/a48QtNC0/VXrunEpHLoW3YjNNx2B0RChVh5znIogcjx5yMCO3sZKMiucQhAXhRyTI6cFmEdVBB5+FHMEtEVAAVl0olHpAerrNz1dZbV3t4a41nJLhqrXjWXNKt1ldN61CR24rWj/ttq7RQ9v3hl4ocTrAIIJRJxvPnSL6NxQfIQ1zEXRYIPcTjYzqPxMh57AdoIS1wHjmc4HmEwuVjjWvgiUhnXf9LjOCbECkfmBAFQZDZQ5HJbwkhItefyo5pJdcil6ZdJ23p6alomgvSO2zTeXK5I/fglR5t7k3h6Wn+vggvM5lS9kAyDMuHEAGM5UKMsBIhFoQ+xt9TptilRKLfBCbw97EVDsYQcEcwHAMUfEcowpctkRtv4Pp57lfciDQBgkRQRCIoKxHarwXC/VxyGRcKJJJcSJLXU2ZfCnXv4kmhrAJkpbY3WCcLhQdJSoUZtXiZfEF4ELvag8CyQqjOnv08IyRzOkEuz1yaulCluWuvK9wIugIUUgQD/2U2W+UpdH08m41CzCOXRsdCFxAa3ulYbqxZyeKrnGl5w2+oML5xTf1fYy4WHbU21DFss5tFCZ7Z59dwIdpqc9SGUHhBIyhn7p81SISd2Qkz0qgwoFbBRZMui/QgTwwLhdallPlB5JY2RNKuvhUKzRAivKWV0EOiBhaCYNRrRkvQ/3jsivAmDi1CIK0rs370MZ41+vSAJE/GguDkZ3d+kfijQJBvZrOpwKMQIgkkdlMPCQMsBYLhrkQaNHoi7QsIL0JVSrtPrNZh/P0PImdAQEtv1Aoh0jFzFTG0CKyCRkxFHDGcSLaQ97jdtJ9W/lMFQGTZdD6/oCIF9jtXRMRkWQRonM+PWFYM0dlBUDHoIAKGcIg6APlEmO5CdXbErUGwIsVxPH1iVeQ4gYrEk99x+3o+mSwBCBzXSiadUwka97NOPgAV2YO0yWudSNCIU/tAAP1GowguRr4xIzel4W5jjRX5LACfSo0beS1gZk+x3OGwwUFj5q6wy/0IgJUkFVz0K8nt72eSGY8lB9IKBPgtcGanIiqTOBRGlXnlg3ktHzsp061R61SCRlck7scTQaILAsCiOCQ5eVmUNiKKSEjdxOPW6QSNRCRv5ny8UpHuxKUPYE4mBSJ7T7YrEWU8IdyNcjnaroJHLZyb5zCEhGkD5GqG6F43B8EeiAGdPSgb789rUTZh/C8mH3ImzCVgGAA1AQGwZgXA0cFl8AwD4fwxYPiNRoTO7E0GkDIwjbbk2DkIQNQAHqwOBmDsHtnMr34hHaS/lT2Wwy2RpW2ny+WFYXfJ5G7ZhOkNLeTYLsSf/NgMI8IBxj0QHXX0KIYREbDBgTJl0iJw1nmASMDD0N1sPCGRpIXOgBI6jG93kJnVZHWeyUi1ECLKQDRUJA7bz8xQuLZPR0TJDTrADoyU2hkwEAB+BDjrtOEaAZjADUz+YJHkcnn/M5Hq8oZsz+ZG4n2RCGKtYBGP/SKFTWc/kaDxcJETCRrfE7nrufQXiyLd9hHoGv3Wgpz0upkQIog/TqaR59FhnR3WrxCHJD7RgULnESnUgx6aamRV7jBUNkVjxuhFjv+cHaFwIihd9Ph6vhaxi8WXft8mJYrFLgD3tZhGkOr2eyIVqfbTiSARCsYYHQzG+MC3y7bAB+nsDS58iELBcBT+vAglEpFGv+vjnoq0u11iNKHfaQJwle4IgfBcLepUpF5dkMtKJyWyi//9Wvs7u/cK8Z/I0UWYenmXZQ1gtHVeBcjelFsKeKBFeckA7pfLndMR2eZ/vTSJ8E/kT4pEsmY+ikoiWTMfRSWRrJmPopJI1sxHUcl/Q8/cLJgt7e8AAAAASUVORK5CYII=", "description": "Includes cards that display dynamic content based on data from one or more entities. It also includes static HTML cards.", "order": 2000, - "externalId": null, "name": "Cards" }, "widgetTypeFqns": [ @@ -14,6 +13,7 @@ "cards.aggregated_value_card", "simple_value_and_chart_card", "progress_bar", + "iot_svg", "cards.label_widget", "cards.dashboard_state_widget", "cards.qr_code", diff --git a/application/src/main/data/json/system/widget_types/iot_svg.json b/application/src/main/data/json/system/widget_types/iot_svg.json new file mode 100644 index 0000000000..fde568de95 --- /dev/null +++ b/application/src/main/data/json/system/widget_types/iot_svg.json @@ -0,0 +1,26 @@ +{ + "fqn": "iot_svg", + "name": "IoT SVG", + "deprecated": false, + "image": null, + "description": "IoT SVG", + "descriptor": { + "type": "rpc", + "sizeX": 3.5, + "sizeY": 3.5, + "resources": [], + "templateHtml": "\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n self.ctx.$scope.actionWidget.onInit();\n}\n\nself.typeParameters = function() {\n return {\n previewWidth: '280px',\n previewHeight: '280px',\n embedTitlePanel: true,\n displayRpcMessageToast: false\n };\n};\n\nself.onDestroy = function() {\n}\n", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "", + "hasBasicMode": true, + "basicModeDirective": "tb-iot-svg-basic-config", + "defaultConfig": "{\"showTitle\":true,\"backgroundColor\":\"#ffffff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{},\"title\":\"IoT SVG\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"actions\":{},\"widgetCss\":\"\",\"noDataDisplayMessage\":\"\",\"titleFont\":{\"size\":16,\"sizeUnit\":\"px\",\"family\":\"Roboto\",\"weight\":\"500\",\"style\":null,\"lineHeight\":\"1.6\"},\"showTitleIcon\":false,\"titleTooltip\":\"\",\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"pageSize\":1024,\"titleIcon\":\"mdi:lightbulb-outline\",\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"configMode\":\"basic\",\"targetDevice\":null,\"titleColor\":null,\"borderRadius\":null}" + }, + "tags": [ + "svg", + "scada" + ] +} \ No newline at end of file diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/button/action-button-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/button/action-button-basic-config.component.html index 6940cffc1f..49a50afc30 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/button/action-button-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/button/action-button-basic-config.component.html @@ -29,7 +29,7 @@
widgets.action-button.on-click
@@ -38,9 +38,9 @@
widgets.button-state.activated-state
widgets.button-state.disabled-state
widgets.command-button.on-click
widgets.button-state.disabled-state
widgets.rpc-state.initial-state
widgets.power-button.power-on
widgets.power-button.power-off
widgets.rpc-state.disabled-state
widgets.rpc-state.initial-state
widgets.toggle-button.check
widgets.toggle-button.uncheck
widgets.rpc-state.disabled-state
widgets.rpc-state.initial-state
widgets.rpc-state.disabled-state
widgets.rpc-state.initial-state
widgets.rpc-state.turn-on
widgets.rpc-state.turn-off
widgets.rpc-state.disabled-state
widgets.slider.initial-value
widgets.slider.on-value-change
widgets.rpc-state.disabled-state
widgets.button-state.activated-state
widgets.button-state.disabled-state
widgets.command-button.on-click
widgets.button-state.disabled-state
widgets.rpc-state.initial-state
widgets.power-button.power-on
widgets.power-button.power-off
widgets.rpc-state.disabled-state
widgets.rpc-state.initial-state
widgets.toggle-button.check
widgets.toggle-button.uncheck
widgets.rpc-state.disabled-state
-
{{ panelTitle | translate }}
+
{{ panelTitle }}
{{ 'widgets.value-action.action' | translate }}
@@ -122,7 +122,7 @@ helpId="widget/lib/rpc/parse_value_fn">
-
{{ 'widgets.value-action.state-when-result-is' | translate:{state: (stateLabel | translate)} }}
+
{{ 'widgets.value-action.state-when-result-is' | translate:{state: stateLabel} }}
-
{{ panelTitle | translate }}
+
{{ panelTitle }}
{{ 'widgets.value-action.action' | translate }}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings-panel.component.html index c5b4b50272..3fffd3f424 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings-panel.component.html @@ -16,7 +16,7 @@ -->
-
{{ panelTitle | translate }}
+
{{ panelTitle }}
- -
{{ metadata?.title }}
+ +
{{ metadata?.title | customTranslate }}
-
widgets.slider.behavior
+
iot-svg.behavior
-
{{ behaviour.name }}
+
{{ behaviour.name | customTranslate }}
@@ -51,15 +54,15 @@
- {{ propertyRow.label }} + {{ propertyRow.label | customTranslate }} -
{{ propertyRow.label }}
+
{{ propertyRow.label | customTranslate }}
-
{{ property.subLabel }}
+
{{ property.subLabel | customTranslate }}
-
{{ property.fieldSuffix }}
+
{{ property.fieldSuffix | customTranslate }}
+ settingsKey="{{ property.name | customTranslate }}"> -
{{ property.fieldSuffix }}
+
{{ property.fieldSuffix | customTranslate }}
, private fb: UntypedFormBuilder, @@ -105,8 +105,8 @@ export class IotSvgObjectSettingsComponent implements OnInit, OnChanges, Control } ngOnInit(): void { - this.scadaObjectSettingsFormGroup = this.fb.group({}); - this.scadaObjectSettingsFormGroup.valueChanges.subscribe(() => { + this.iotSvgObjectSettingsFormGroup = this.fb.group({}); + this.iotSvgObjectSettingsFormGroup.valueChanges.subscribe(() => { this.updateModel(); }); this.loadMetadata(); @@ -133,9 +133,9 @@ export class IotSvgObjectSettingsComponent implements OnInit, OnChanges, Control setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; if (isDisabled) { - this.scadaObjectSettingsFormGroup.disable({emitEvent: false}); + this.iotSvgObjectSettingsFormGroup.disable({emitEvent: false}); } else { - this.scadaObjectSettingsFormGroup.enable({emitEvent: false}); + this.iotSvgObjectSettingsFormGroup.enable({emitEvent: false}); this.updateValidators(); } } @@ -146,9 +146,9 @@ export class IotSvgObjectSettingsComponent implements OnInit, OnChanges, Control } validate(c: UntypedFormControl) { - const valid = this.scadaObjectSettingsFormGroup.valid; + const valid = this.iotSvgObjectSettingsFormGroup.valid; return valid ? null : { - scadaObject: { + iotSvgObject: { valid: false, }, }; @@ -162,13 +162,13 @@ export class IotSvgObjectSettingsComponent implements OnInit, OnChanges, Control this.validatorTriggers = []; this.http.get(this.svgPath, {responseType: 'text'}).subscribe( (svgContent) => { - this.metadata = parseScadaObjectMetadataFromContent(svgContent); + this.metadata = parseIotSvgMetadataFromContent(svgContent); this.propertyRows = toPropertyRows(this.metadata.properties); - for (const control of Object.keys(this.scadaObjectSettingsFormGroup.controls)) { - this.scadaObjectSettingsFormGroup.removeControl(control, {emitEvent: false}); + for (const control of Object.keys(this.iotSvgObjectSettingsFormGroup.controls)) { + this.iotSvgObjectSettingsFormGroup.removeControl(control, {emitEvent: false}); } for (const behaviour of this.metadata.behavior) { - this.scadaObjectSettingsFormGroup.addControl(behaviour.id, this.fb.control(null, []), {emitEvent: false}); + this.iotSvgObjectSettingsFormGroup.addControl(behaviour.id, this.fb.control(null, []), {emitEvent: false}); } for (const property of this.metadata.properties) { if (property.disableOnProperty) { @@ -188,12 +188,12 @@ export class IotSvgObjectSettingsComponent implements OnInit, OnChanges, Control validators.push(Validators.max(property.max)); } } - this.scadaObjectSettingsFormGroup.addControl(property.id, this.fb.control(null, validators), {emitEvent: false}); + this.iotSvgObjectSettingsFormGroup.addControl(property.id, this.fb.control(null, validators), {emitEvent: false}); } if (this.validatorTriggers.length) { const observables: Observable[] = []; for (const trigger of this.validatorTriggers) { - observables.push(this.scadaObjectSettingsFormGroup.get(trigger).valueChanges); + observables.push(this.iotSvgObjectSettingsFormGroup.get(trigger).valueChanges); } this.validatorSubscription = merge(...observables).subscribe(() => { this.updateValidators(); @@ -207,10 +207,10 @@ export class IotSvgObjectSettingsComponent implements OnInit, OnChanges, Control private updateValidators() { for (const trigger of this.validatorTriggers) { - const value: boolean = this.scadaObjectSettingsFormGroup.get(trigger).value; + const value: boolean = this.iotSvgObjectSettingsFormGroup.get(trigger).value; this.metadata.properties.filter(p => p.disableOnProperty === trigger).forEach( (p) => { - const control = this.scadaObjectSettingsFormGroup.get(p.id); + const control = this.iotSvgObjectSettingsFormGroup.get(p.id); if (value) { control.enable({emitEvent: false}); } else { @@ -223,9 +223,9 @@ export class IotSvgObjectSettingsComponent implements OnInit, OnChanges, Control private setupValue() { if (this.metadata) { - const defaults = defaultScadaObjectSettings(this.metadata); + const defaults = defaultIotSvgObjectSettings(this.metadata); this.modelValue = {...defaults, ...this.modelValue}; - this.scadaObjectSettingsFormGroup.patchValue( + this.iotSvgObjectSettingsFormGroup.patchValue( this.modelValue, {emitEvent: false} ); this.setDisabledState(this.disabled); @@ -233,7 +233,7 @@ export class IotSvgObjectSettingsComponent implements OnInit, OnChanges, Control } private updateModel() { - this.modelValue = this.scadaObjectSettingsFormGroup.getRawValue(); + this.modelValue = this.iotSvgObjectSettingsFormGroup.getRawValue(); this.propagateChange(this.modelValue); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/svg/iot-svg-object-settings.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/svg/iot-svg-object-settings.models.ts index 12231d2b1a..ebb8b908a9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/svg/iot-svg-object-settings.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/svg/iot-svg-object-settings.models.ts @@ -16,15 +16,15 @@ import { IotSvgProperty } from '@home/components/widget/lib/svg/iot-svg.models'; -export interface ScadaPropertyRow { +export interface IotSvgPropertyRow { label: string; properties: IotSvgProperty[]; switch?: IotSvgProperty; rowClass?: string; } -export const toPropertyRows = (properties: IotSvgProperty[]): ScadaPropertyRow[] => { - const result: ScadaPropertyRow[] = []; +export const toPropertyRows = (properties: IotSvgProperty[]): IotSvgPropertyRow[] => { + const result: IotSvgPropertyRow[] = []; for (const property of properties) { let propertyRow = result.find(r => r.label === property.name); if (!propertyRow) { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.html index 9208efd68a..db6d0371f9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.html @@ -21,11 +21,11 @@
widgets.rpc-state.initial-state
widgets.rpc-state.turn-on
widgets.rpc-state.turn-off
widgets.rpc-state.disabled-state
widgets.slider.initial-value
widgets.slider.on-value-change
widgets.rpc-state.disabled-state
widgets.rpc-state.initial-state
widgets.rpc-state.disabled-state
boolean; - actionType: ScadaObjectActionType; - updateValueId?: string; - updateValueType?: ScadaObjectActionUpdateValueType; - updateValueConstant?: any; - updateValueInc?: number; -} - export interface IotSvgMetadata { title: string; stateRenderFunction?: string; @@ -155,16 +149,16 @@ export const emptyMetadata: IotSvgMetadata = { }; -export const parseScadaObjectMetadataFromContent = (svgContent: string): IotSvgMetadata => { +export const parseIotSvgMetadataFromContent = (svgContent: string): IotSvgMetadata => { try { const svgDoc = new DOMParser().parseFromString(svgContent, 'image/svg+xml'); - return parseScadaObjectMetadataFromDom(svgDoc); + return parseIotSvgMetadataFromDom(svgDoc); } catch (_e) { return emptyMetadata; } }; -const parseScadaObjectMetadataFromDom = (svgDoc: Document): IotSvgMetadata => { +const parseIotSvgMetadataFromDom = (svgDoc: Document): IotSvgMetadata => { try { const elements = svgDoc.getElementsByTagName('tb:metadata'); if (elements.length) { @@ -232,7 +226,7 @@ const defaultWidgetActionSettings = (widgetAction: IotSvgBehavior): WidgetAction stateEntityParamName: null }); -export const defaultScadaObjectSettings = (metadata: IotSvgMetadata): IotSvgObjectSettings => { +export const defaultIotSvgObjectSettings = (metadata: IotSvgMetadata): IotSvgObjectSettings => { const settings: IotSvgObjectSettings = {}; for (const behavior of metadata.behavior) { if (behavior.type === IotSvgBehaviorType.value) { @@ -285,8 +279,8 @@ export class IotSvgObject { return this.ctx.http.get(this.svgPath, {responseType: 'text'}).pipe( map((inputSvgContent) => { const doc: XMLDocument = new DOMParser().parseFromString(inputSvgContent, 'image/svg+xml'); - this.metadata = parseScadaObjectMetadataFromDom(doc); - const defaults = defaultScadaObjectSettings(this.metadata); + this.metadata = parseIotSvgMetadataFromDom(doc); + const defaults = defaultIotSvgObjectSettings(this.metadata); this.settings = mergeDeep({}, defaults, this.inputSettings || {}); this.prepareMetadata(); this.prepareSvgShape(doc); @@ -613,6 +607,10 @@ export class IotSvgObject { if (isDefinedAndNotNull(value)) { if (property.type === 'color-settings') { return ColorProcessor.fromSettings(value); + } else if (property.type === 'string') { + const result = this.ctx.utilsService.customTranslation(value, value); + const entityInfo = this.ctx.defaultSubscription.getFirstEntityInfo(); + return createLabelFromSubscriptionEntityInfo(entityInfo, result); } return value; } else { diff --git a/ui-ngx/src/app/shared/components/value-input.component.html b/ui-ngx/src/app/shared/components/value-input.component.html index 269ec33a5d..e3b3e12b83 100644 --- a/ui-ngx/src/app/shared/components/value-input.component.html +++ b/ui-ngx/src/app/shared/components/value-input.component.html @@ -70,8 +70,8 @@ - {{ trueLabel | translate }} - {{ falseLabel | translate }} + {{ trueLabel }} + {{ falseLabel }}
diff --git a/ui-ngx/src/app/shared/components/value-input.component.ts b/ui-ngx/src/app/shared/components/value-input.component.ts index 0479b2f0df..83b6a4d432 100644 --- a/ui-ngx/src/app/shared/components/value-input.component.ts +++ b/ui-ngx/src/app/shared/components/value-input.component.ts @@ -35,6 +35,7 @@ import { } from '@shared/components/dialog/json-object-edit-dialog.component'; import { BreakpointObserver } from '@angular/cdk/layout'; import { Subscription } from 'rxjs'; +import { TranslateService } from '@ngx-translate/core'; type Layout = 'column' | 'row'; @@ -67,10 +68,10 @@ export class ValueInputComponent implements OnInit, OnDestroy, OnChanges, Contro valueType: ValueType; @Input() - trueLabel = 'value.true'; + trueLabel: string; @Input() - falseLabel = 'value.false'; + falseLabel: string; @Input() layout: ValueInputLayout | Layout = 'row'; @@ -96,12 +97,19 @@ export class ValueInputComponent implements OnInit, OnDestroy, OnChanges, Contro constructor( private breakpointObserver: BreakpointObserver, private cd: ChangeDetectorRef, + private translate: TranslateService, public dialog: MatDialog, ) { } ngOnInit(): void { + if (!this.trueLabel) { + this.trueLabel = this.translate.instant('value.true'); + } + if (!this.falseLabel) { + this.falseLabel = this.translate.instant('value.false'); + } this._subscription = new Subscription(); this.showValueType = !this.valueType; this.computedLayout = this._computeLayout(); diff --git a/ui-ngx/src/app/shared/pipe/custom-translate.pipe.ts b/ui-ngx/src/app/shared/pipe/custom-translate.pipe.ts new file mode 100644 index 0000000000..141d24015e --- /dev/null +++ b/ui-ngx/src/app/shared/pipe/custom-translate.pipe.ts @@ -0,0 +1,33 @@ +/// +/// Copyright ยฉ 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Pipe, PipeTransform } from '@angular/core'; +import { UtilsService } from '@core/services/utils.service'; + +@Pipe({ + name: 'customTranslate' +}) +export class CustomTranslatePipe implements PipeTransform { + + constructor(private utils: UtilsService) { } + + transform(translationValue: string, defaultValue?: string): string { + if (!defaultValue) { + defaultValue = translationValue; + } + return this.utils.customTranslation(translationValue, defaultValue); + } +} diff --git a/ui-ngx/src/app/shared/shared.module.ts b/ui-ngx/src/app/shared/shared.module.ts index 71f121ab17..45725f0331 100644 --- a/ui-ngx/src/app/shared/shared.module.ts +++ b/ui-ngx/src/app/shared/shared.module.ts @@ -218,6 +218,7 @@ import { EmbedImageDialogComponent } from '@shared/components/image/embed-image- import { ImageGalleryDialogComponent } from '@shared/components/image/image-gallery-dialog.component'; import { RuleChainSelectPanelComponent } from '@shared/components/rule-chain/rule-chain-select-panel.component'; import { WidgetButtonComponent } from '@shared/components/button/widget-button.component'; +import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe'; export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) { return markedOptionsService; @@ -236,6 +237,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) SafePipe, ShortNumberPipe, ImagePipe, + CustomTranslatePipe, { provide: FlowInjectionToken, useValue: Flow @@ -361,6 +363,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) FileSizePipe, DateAgoPipe, ImagePipe, + CustomTranslatePipe, SafePipe, ShortNumberPipe, SelectableColumnsPipe, @@ -614,6 +617,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) FileSizePipe, DateAgoPipe, ImagePipe, + CustomTranslatePipe, SafePipe, ShortNumberPipe, SelectableColumnsPipe, diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index b13d169cdc..2203545179 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -3216,6 +3216,9 @@ "error-entities": "There was an error creating {{count}} entities." } }, + "iot-svg": { + "behavior": "Behavior" + }, "item": { "selected": "Selected" }, diff --git a/ui-ngx/src/assets/widget/svg/drawing.svg b/ui-ngx/src/assets/widget/svg/drawing.svg index 91bfb8b47d..55d60d04c1 100644 --- a/ui-ngx/src/assets/widget/svg/drawing.svg +++ b/ui-ngx/src/assets/widget/svg/drawing.svg @@ -68,7 +68,9 @@ }, { "id": "disabledState", - "name": "Disabled state", + "name": "{i18n:widgets.rpc-state.disabled-state}", + "hint": "{i18n:widgets.rpc-state.disabled-state-hint}", + "stateLabel": "{i18n:widgets.rpc-state.disabled}", "type": "value", "valueType": "BOOLEAN", "defaultValue": false, @@ -77,6 +79,9 @@ { "id": "onState", "name": "On/Off state", + "trueLabel": "{i18n:widgets.rpc-state.on}", + "falseLabel": "{i18n:widgets.rpc-state.off}", + "stateLabel": "{i18n:widgets.rpc-state.on}", "type": "value", "valueType": "BOOLEAN", "defaultValue": false, @@ -84,7 +89,8 @@ }, { "id": "onUpdateState", - "name": "Power On", + "name": "{i18n:widgets.rpc-state.turn-on}", + "hint": "{i18n:widgets.rpc-state.turn-on-hint}", "type": "action", "valueType": "BOOLEAN", "valueToDataType": "CONSTANT", @@ -92,7 +98,8 @@ }, { "id": "offUpdateState", - "name": "Power Off", + "name": "{i18n:widgets.rpc-state.turn-off}", + "hint": "{i18n:widgets.rpc-state.turn-off-hint}", "type": "action", "valueType": "BOOLEAN", "valueToDataType": "CONSTANT", @@ -124,7 +131,7 @@ "id": "levelTitle", "name": "Level title", "type": "string", - "default": "Level", + "default": "{i18n:widgets.battery-level.value}", "disableOnProperty": "showLevelTitle", "fieldClass": "flex" }, From 7d0e5b330b95b7c85023925bc842eee5274cf908 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Wed, 8 May 2024 20:00:32 +0300 Subject: [PATCH 025/442] Introduce ResourceSubType for image resources. --- .../main/data/upgrade/3.7.0/schema_update.sql | 32 ++++++++++ .../controller/ControllerConstants.java | 2 + .../server/controller/ImageController.java | 28 ++++++++- .../install/ThingsboardInstallService.java | 4 ++ .../install/SqlDatabaseUpgradeService.java | 3 + .../controller/ImageControllerTest.java | 62 ++++++++++++++++++- .../server/dao/resource/ImageService.java | 5 +- .../server/common/data/ImageExportData.java | 1 + .../server/common/data/ResourceSubType.java | 6 ++ .../server/common/data/TbResourceInfo.java | 3 + .../common/data/TbResourceInfoFilter.java | 1 + .../server/common/util/ProtoUtils.java | 7 +++ common/proto/src/main/proto/queue.proto | 1 + .../server/dao/model/ModelConstants.java | 1 + .../dao/model/sql/TbResourceEntity.java | 9 +++ .../dao/model/sql/TbResourceInfoEntity.java | 9 +++ .../server/dao/resource/BaseImageService.java | 10 ++- .../dao/resource/BaseResourceService.java | 4 +- .../server/dao/resource/TbResourceDao.java | 3 + .../dao/sql/resource/JpaTbResourceDao.java | 5 ++ .../sql/resource/JpaTbResourceInfoDao.java | 7 +++ .../resource/TbResourceInfoRepository.java | 6 +- .../sql/resource/TbResourceRepository.java | 4 ++ .../main/resources/sql/schema-entities.sql | 1 + .../thingsboard/rest/client/RestClient.java | 12 +++- 25 files changed, 214 insertions(+), 12 deletions(-) create mode 100644 application/src/main/data/upgrade/3.7.0/schema_update.sql create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ResourceSubType.java diff --git a/application/src/main/data/upgrade/3.7.0/schema_update.sql b/application/src/main/data/upgrade/3.7.0/schema_update.sql new file mode 100644 index 0000000000..6ef2074ffe --- /dev/null +++ b/application/src/main/data/upgrade/3.7.0/schema_update.sql @@ -0,0 +1,32 @@ +-- +-- Copyright ยฉ 2016-2024 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +-- UPDATE RESOURCE SUB TYPE START + +DO +$$ + BEGIN + IF NOT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_name = 'resource' AND column_name = 'resource_sub_type' + ) THEN + ALTER TABLE resource ADD COLUMN resource_sub_type varchar(32); + UPDATE resource SET resource_sub_type = 'IMAGE' WHERE resource_type = 'IMAGE'; + END IF; + END; +$$; + +-- UPDATE RESOURCE SUB TYPE END diff --git a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java index d9232cda78..3a39eb5177 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java +++ b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java @@ -115,6 +115,8 @@ public class ControllerConstants { protected static final String RESOURCE_INFO_DESCRIPTION = "Resource Info is a lightweight object that includes main information about the Resource excluding the heavyweight data. "; protected static final String RESOURCE_DESCRIPTION = "Resource is a heavyweight object that includes main information about the Resource and also data. "; + protected static final String RESOURCE_IMAGE_SUB_TYPE_DESCRIPTION = "A string value representing resource sub-type."; + protected static final String RESOURCE_INCLUDE_SYSTEM_IMAGES_DESCRIPTION = "Use 'true' to include system images. Disabled by default. Ignored for requests by users with system administrator authority."; protected static final String RESOURCE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the resource title."; diff --git a/application/src/main/java/org/thingsboard/server/controller/ImageController.java b/application/src/main/java/org/thingsboard/server/controller/ImageController.java index c8d24d6081..b81dcd326d 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ImageController.java +++ b/application/src/main/java/org/thingsboard/server/controller/ImageController.java @@ -40,6 +40,7 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import org.thingsboard.server.common.data.ImageDescriptor; import org.thingsboard.server.common.data.ImageExportData; +import org.thingsboard.server.common.data.ResourceSubType; import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.TbImageDeleteResult; import org.thingsboard.server.common.data.TbResource; @@ -64,6 +65,7 @@ import java.util.concurrent.TimeUnit; import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.RESOURCE_IMAGE_SUB_TYPE_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.RESOURCE_INCLUDE_SYSTEM_IMAGES_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.RESOURCE_TEXT_SEARCH_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION; @@ -95,7 +97,8 @@ public class ImageController extends BaseController { @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @PostMapping("/api/image") public TbResourceInfo uploadImage(@RequestPart MultipartFile file, - @RequestPart(required = false) String title) throws Exception { + @RequestPart(required = false) String title, + @RequestPart(required = false) String imageSubType) throws Exception { SecurityUser user = getCurrentUser(); TbResource image = new TbResource(); image.setTenantId(user.getTenantId()); @@ -108,7 +111,14 @@ public class ImageController extends BaseController { } else { image.setTitle(file.getOriginalFilename()); } + + ResourceSubType subType = ResourceSubType.IMAGE; + if (StringUtils.isNotEmpty(imageSubType)) { + subType = ResourceSubType.valueOf(imageSubType); + } + image.setResourceType(ResourceType.IMAGE); + image.setResourceSubType(subType); ImageDescriptor descriptor = new ImageDescriptor(); descriptor.setMediaType(file.getContentType()); image.setDescriptorValue(descriptor); @@ -193,6 +203,7 @@ public class ImageController extends BaseController { .mediaType(descriptor.getMediaType()) .fileName(imageInfo.getFileName()) .title(imageInfo.getTitle()) + .subType(imageInfo.getResourceSubType().name()) .resourceKey(imageInfo.getResourceKey()) .isPublic(imageInfo.isPublic()) .publicResourceKey(imageInfo.getPublicResourceKey()) @@ -214,6 +225,11 @@ public class ImageController extends BaseController { } else { image.setTitle(imageData.getFileName()); } + if (StringUtils.isNotEmpty(imageData.getSubType())) { + image.setResourceSubType(ResourceSubType.valueOf(imageData.getSubType())); + } else { + image.setResourceSubType(ResourceSubType.IMAGE); + } image.setResourceType(ResourceType.IMAGE); image.setResourceKey(imageData.getResourceKey()); image.setPublic(imageData.isPublic()); @@ -250,6 +266,8 @@ public class ImageController extends BaseController { @RequestParam int pageSize, @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) @RequestParam int page, + @Parameter(description = RESOURCE_IMAGE_SUB_TYPE_DESCRIPTION, schema = @Schema(allowableValues = {"IMAGE", "IOT_SVG"})) + @RequestParam(required = false) String imageSubType, @Parameter(description = RESOURCE_INCLUDE_SYSTEM_IMAGES_DESCRIPTION) @RequestParam(required = false) boolean includeSystemImages, @Parameter(description = RESOURCE_TEXT_SEARCH_DESCRIPTION) @@ -261,10 +279,14 @@ public class ImageController extends BaseController { // PE: generic permission PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); TenantId tenantId = getTenantId(); + ResourceSubType subType = ResourceSubType.IMAGE; + if (StringUtils.isNotEmpty(imageSubType)) { + subType = ResourceSubType.valueOf(imageSubType); + } if (getCurrentUser().getAuthority() == Authority.SYS_ADMIN || !includeSystemImages) { - return checkNotNull(imageService.getImagesByTenantId(tenantId, pageLink)); + return checkNotNull(imageService.getImagesByTenantId(tenantId, subType, pageLink)); } else { - return checkNotNull(imageService.getAllImagesByTenantId(tenantId, pageLink)); + return checkNotNull(imageService.getAllImagesByTenantId(tenantId, subType, pageLink)); } } diff --git a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java index 33e2ddee01..eba35157c9 100644 --- a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java +++ b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java @@ -137,6 +137,10 @@ public class ThingsboardInstallService { entityDatabaseSchemaService.createCustomerTitleUniqueConstraintIfNotExists(); systemDataLoaderService.updateDefaultNotificationConfigs(false); systemDataLoaderService.updateJwtSettings(); + break; + case "3.7.0": + log.info("Upgrading ThingsBoard from version 3.7.0 to 3.7.1 ..."); + databaseEntitiesUpgradeService.upgradeDatabase("3.7.0"); //TODO DON'T FORGET to update switch statement in the CacheCleanupService if you need to clear the cache break; default: diff --git a/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java b/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java index 86d922c305..000c0536cc 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java @@ -121,6 +121,9 @@ public class SqlDatabaseUpgradeService implements DatabaseEntitiesUpgradeService case "3.6.4": updateSchema("3.6.4", 3006004, "3.7.0", 3007000, null); break; + case "3.7.0": + updateSchema("3.7.0", 3007000, "3.7.1", 3007001, null); + break; default: throw new RuntimeException("Unable to upgrade SQL database, unsupported fromVersion: " + fromVersion); } diff --git a/application/src/test/java/org/thingsboard/server/controller/ImageControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/ImageControllerTest.java index e272a40857..53b36c955b 100644 --- a/application/src/test/java/org/thingsboard/server/controller/ImageControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/ImageControllerTest.java @@ -23,11 +23,14 @@ import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpMethod; import org.springframework.mock.web.MockMultipartFile; +import org.springframework.mock.web.MockPart; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.ImageDescriptor; import org.thingsboard.server.common.data.ImageExportData; +import org.thingsboard.server.common.data.ResourceSubType; import org.thingsboard.server.common.data.ResourceType; +import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.SystemParams; import org.thingsboard.server.common.data.TbResourceInfo; import org.thingsboard.server.common.data.page.PageData; @@ -35,6 +38,7 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.dao.sql.resource.TbResourceRepository; +import java.nio.charset.StandardCharsets; import java.util.Base64; import java.util.List; @@ -53,6 +57,7 @@ public class ImageControllerTest extends AbstractControllerTest { @Before public void beforeEach() throws Exception { + resourceRepository.deleteAll(); loginTenantAdmin(); } @@ -66,6 +71,8 @@ public class ImageControllerTest extends AbstractControllerTest { String filename = "my_png_image.png"; TbResourceInfo imageInfo = uploadImage(HttpMethod.POST, "/api/image", filename, "image/png", PNG_IMAGE); + assertThat(imageInfo.getResourceSubType()).isEqualTo(ResourceSubType.IMAGE); + assertThat(imageInfo.getTitle()).isEqualTo(filename); assertThat(imageInfo.getResourceType()).isEqualTo(ResourceType.IMAGE); assertThat(imageInfo.getResourceKey()).isEqualTo(filename); @@ -85,6 +92,8 @@ public class ImageControllerTest extends AbstractControllerTest { String filename = "my_jpeg_image.jpg"; TbResourceInfo imageInfo = uploadImage(HttpMethod.POST, "/api/image", filename, "image/jpeg", JPEG_IMAGE); + assertThat(imageInfo.getResourceSubType()).isEqualTo(ResourceSubType.IMAGE); + ImageDescriptor imageDescriptor = imageInfo.getDescriptor(ImageDescriptor.class); checkJpegImageDescriptor(imageDescriptor); @@ -97,6 +106,22 @@ public class ImageControllerTest extends AbstractControllerTest { String filename = "my_svg_image.svg"; TbResourceInfo imageInfo = uploadImage(HttpMethod.POST, "/api/image", filename, "image/svg+xml", SVG_IMAGE); + assertThat(imageInfo.getResourceSubType()).isEqualTo(ResourceSubType.IMAGE); + + ImageDescriptor imageDescriptor = imageInfo.getDescriptor(ImageDescriptor.class); + checkSvgImageDescriptor(imageDescriptor); + + assertThat(downloadImage("tenant", filename)).containsExactly(SVG_IMAGE); + assertThat(downloadImagePreview("tenant", filename)).hasSize((int) imageDescriptor.getPreviewDescriptor().getSize()); + } + + @Test + public void testUploadIoTSvgImage() throws Exception { + String filename = "my_iot_svg_image.svg"; + TbResourceInfo imageInfo = uploadImage(HttpMethod.POST, "/api/image", ResourceSubType.IOT_SVG.name(), filename, "image/svg+xml", SVG_IMAGE); + + assertThat(imageInfo.getResourceSubType()).isEqualTo(ResourceSubType.IOT_SVG); + ImageDescriptor imageDescriptor = imageInfo.getDescriptor(ImageDescriptor.class); checkSvgImageDescriptor(imageDescriptor); @@ -175,6 +200,7 @@ public class ImageControllerTest extends AbstractControllerTest { assertThat(exportData.getMediaType()).isEqualTo("image/png"); assertThat(exportData.getFileName()).isEqualTo(filename); assertThat(exportData.getTitle()).isEqualTo(filename); + assertThat(exportData.getSubType()).isEqualTo(ResourceSubType.IMAGE.name()); assertThat(exportData.getResourceKey()).isEqualTo(filename); assertThat(exportData.getData()).isEqualTo(Base64.getEncoder().encodeToString(PNG_IMAGE)); assertThat(exportData.isPublic()).isTrue(); @@ -184,6 +210,7 @@ public class ImageControllerTest extends AbstractControllerTest { TbResourceInfo importedImageInfo = doPut("/api/image/import", exportData, TbResourceInfo.class); assertThat(importedImageInfo.getTitle()).isEqualTo(filename); + assertThat(exportData.getSubType()).isEqualTo(ResourceSubType.IMAGE.name()); assertThat(importedImageInfo.getResourceKey()).isEqualTo(filename); assertThat(importedImageInfo.getFileName()).isEqualTo(filename); assertThat(importedImageInfo.isPublic()).isTrue(); @@ -198,20 +225,37 @@ public class ImageControllerTest extends AbstractControllerTest { String systemImageName = "my_system_png_image.png"; TbResourceInfo systemImage = uploadImage(HttpMethod.POST, "/api/image", systemImageName, "image/png", PNG_IMAGE); + String systemIotSvgName = "my_system_iot_svg_image.svg"; + TbResourceInfo systemIotSvg = uploadImage(HttpMethod.POST, "/api/image", ResourceSubType.IOT_SVG.name(), systemIotSvgName, "image/svg+xml", SVG_IMAGE); + loginTenantAdmin(); String tenantImageName = "my_jpeg_image.jpg"; TbResourceInfo tenantImage = uploadImage(HttpMethod.POST, "/api/image", tenantImageName, "image/jpeg", JPEG_IMAGE); + String tenantIotSvgName = "my_iot_svg_image.svg"; + TbResourceInfo tenantIotSvg = uploadImage(HttpMethod.POST, "/api/image", ResourceSubType.IOT_SVG.name(), tenantIotSvgName, "image/svg+xml", SVG_IMAGE); + List tenantImages = getImages(null, false, 10); assertThat(tenantImages).containsOnly(tenantImage); + List tenantIotSvgs = getImages(null, ResourceSubType.IOT_SVG.name(), false, 10); + assertThat(tenantIotSvgs).containsOnly(tenantIotSvg); + List allImages = getImages(null, true, 10); assertThat(allImages).containsOnly(tenantImage, systemImage); + List allIotSvgs = getImages(null, ResourceSubType.IOT_SVG.name(), true, 10); + assertThat(allIotSvgs).containsOnly(tenantIotSvg, systemIotSvg); + assertThat(getImages("png", true, 10)) .containsOnly(systemImage); assertThat(getImages("jpg", true, 10)) .containsOnly(tenantImage); + + assertThat(getImages("my_system_iot", ResourceSubType.IOT_SVG.name(), true, 10)) + .containsOnly(systemIotSvg); + assertThat(getImages("my_iot_svg", ResourceSubType.IOT_SVG.name(),true, 10)) + .containsOnly(tenantIotSvg); } @Test @@ -312,7 +356,15 @@ public class ImageControllerTest extends AbstractControllerTest { } private List getImages(String searchText, boolean includeSystemImages, int limit) throws Exception { - PageData images = doGetTypedWithPageLink("/api/images?includeSystemImages=" + includeSystemImages + "&", new TypeReference<>() {}, new PageLink(limit, 0, searchText)); + return this.getImages(searchText, null, includeSystemImages, limit); + } + + private List getImages(String searchText, String imageSubType, boolean includeSystemImages, int limit) throws Exception { + var url = "/api/images?includeSystemImages=" + includeSystemImages + "&"; + if (StringUtils.isNotEmpty(imageSubType)) { + url += "imageSubType=" + imageSubType+ "&"; + } + PageData images = doGetTypedWithPageLink(url, new TypeReference<>() {}, new PageLink(limit, 0, searchText)); return images.getData(); } @@ -332,8 +384,16 @@ public class ImageControllerTest extends AbstractControllerTest { } private TbResourceInfo uploadImage(HttpMethod httpMethod, String url, String filename, String mediaType, byte[] content) throws Exception { + return this.uploadImage(httpMethod, url, null, filename, mediaType, content); + } + + private TbResourceInfo uploadImage(HttpMethod httpMethod, String url, String subType, String filename, String mediaType, byte[] content) throws Exception { MockMultipartFile file = new MockMultipartFile("file", filename, mediaType, content); var request = MockMvcRequestBuilders.multipart(httpMethod, url).file(file); + if (StringUtils.isNotEmpty(subType)) { + var imageSubTypePart = new MockPart("imageSubType", subType.getBytes(StandardCharsets.UTF_8)); + request.part(imageSubTypePart); + } setJwtToken(request); return readResponse(mockMvc.perform(request).andExpect(status().isOk()), TbResourceInfo.class); } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ImageService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ImageService.java index 9a65fcfdae..e1253ae568 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ImageService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ImageService.java @@ -17,6 +17,7 @@ package org.thingsboard.server.dao.resource; import org.thingsboard.server.common.data.Dashboard; import org.thingsboard.server.common.data.HasImage; +import org.thingsboard.server.common.data.ResourceSubType; import org.thingsboard.server.common.data.TbImageDeleteResult; import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.TbResourceInfo; @@ -36,9 +37,9 @@ public interface ImageService { TbResourceInfo getPublicImageInfoByKey(String publicResourceKey); - PageData getImagesByTenantId(TenantId tenantId, PageLink pageLink); + PageData getImagesByTenantId(TenantId tenantId, ResourceSubType imageSubType, PageLink pageLink); - PageData getAllImagesByTenantId(TenantId tenantId, PageLink pageLink); + PageData getAllImagesByTenantId(TenantId tenantId, ResourceSubType imageSubType, PageLink pageLink); byte[] getImageData(TenantId tenantId, TbResourceId imageId); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ImageExportData.java b/common/data/src/main/java/org/thingsboard/server/common/data/ImageExportData.java index b407563d24..3be7b6f92d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ImageExportData.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ImageExportData.java @@ -33,6 +33,7 @@ public class ImageExportData { private String mediaType; private String fileName; private String title; + private String subType; private String resourceKey; private boolean isPublic; private String publicResourceKey; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ResourceSubType.java b/common/data/src/main/java/org/thingsboard/server/common/data/ResourceSubType.java new file mode 100644 index 0000000000..e7e8b0747a --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ResourceSubType.java @@ -0,0 +1,6 @@ +package org.thingsboard.server.common.data; + +public enum ResourceSubType { + IMAGE, + IOT_SVG +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfo.java index f1318c2257..8537fe68ed 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfo.java @@ -46,6 +46,8 @@ public class TbResourceInfo extends BaseData implements HasName, H private String title; @Schema(description = "Resource type.", example = "LWM2M_MODEL", accessMode = Schema.AccessMode.READ_ONLY) private ResourceType resourceType; + @Schema(description = "Resource sub type.", example = "IOT_SVG", accessMode = Schema.AccessMode.READ_ONLY) + private ResourceSubType resourceSubType; @NoXss @Length(fieldName = "resourceKey") @Schema(description = "Resource key.", example = "19_1.0", accessMode = Schema.AccessMode.READ_ONLY) @@ -78,6 +80,7 @@ public class TbResourceInfo extends BaseData implements HasName, H this.tenantId = resourceInfo.tenantId; this.title = resourceInfo.title; this.resourceType = resourceInfo.resourceType; + this.resourceSubType = resourceInfo.resourceSubType; this.resourceKey = resourceInfo.resourceKey; this.searchText = resourceInfo.searchText; this.isPublic = resourceInfo.isPublic; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfoFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfoFilter.java index 87d9b69749..60583dbb96 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfoFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfoFilter.java @@ -27,5 +27,6 @@ public class TbResourceInfoFilter { private TenantId tenantId; private Set resourceTypes; + private Set resourceSubTypes; } diff --git a/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java b/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java index e89ce5634c..0ddc309b46 100644 --- a/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java +++ b/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java @@ -27,6 +27,7 @@ import org.thingsboard.server.common.data.DeviceProfileProvisionType; import org.thingsboard.server.common.data.DeviceProfileType; import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ResourceSubType; import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.Tenant; @@ -827,6 +828,9 @@ public class ProtoUtils { if (isNotNull(resource.getPreview())) { builder.setPreview(ByteString.copyFrom(resource.getPreview())); } + if (isNotNull(resource.getResourceSubType())) { + builder.setResourceSubType(resource.getResourceSubType().name()); + } return builder.build(); } @@ -858,6 +862,9 @@ public class ProtoUtils { if (proto.hasPreview()) { resource.setPreview(proto.getPreview().toByteArray()); } + if (proto.hasResourceSubType()) { + resource.setResourceSubType(ResourceSubType.valueOf(proto.getResourceSubType())); + } return resource; } diff --git a/common/proto/src/main/proto/queue.proto b/common/proto/src/main/proto/queue.proto index 7b2aa2fe1b..afc157e476 100644 --- a/common/proto/src/main/proto/queue.proto +++ b/common/proto/src/main/proto/queue.proto @@ -286,6 +286,7 @@ message TbResourceProto { optional int64 externalIdLSB = 16; optional bytes data = 17; optional bytes preview = 18; + optional string resourceSubType = 19; } message ApiUsageStateProto { diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index 8f0502efbf..fdaf7fa96c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -498,6 +498,7 @@ public class ModelConstants { public static final String RESOURCE_TABLE_NAME = "resource"; public static final String RESOURCE_TENANT_ID_COLUMN = TENANT_ID_COLUMN; public static final String RESOURCE_TYPE_COLUMN = "resource_type"; + public static final String RESOURCE_SUB_TYPE_COLUMN = "resource_sub_type"; public static final String RESOURCE_KEY_COLUMN = "resource_key"; public static final String RESOURCE_TITLE_COLUMN = TITLE_PROPERTY; public static final String RESOURCE_FILE_NAME_COLUMN = "file_name"; diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/TbResourceEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/TbResourceEntity.java index f507a8661f..ef12d656b6 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/TbResourceEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/TbResourceEntity.java @@ -20,6 +20,7 @@ import jakarta.persistence.Convert; import lombok.Data; import lombok.EqualsAndHashCode; import org.hibernate.annotations.Type; +import org.thingsboard.server.common.data.ResourceSubType; import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.id.TbResourceId; @@ -42,6 +43,7 @@ import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_IS_PUBLIC import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_KEY_COLUMN; import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_PREVIEW_COLUMN; import static org.thingsboard.server.dao.model.ModelConstants.PUBLIC_RESOURCE_KEY_COLUMN; +import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_SUB_TYPE_COLUMN; import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_TABLE_NAME; import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_TENANT_ID_COLUMN; import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_TITLE_COLUMN; @@ -63,6 +65,9 @@ public class TbResourceEntity extends BaseSqlEntity { @Column(name = RESOURCE_TYPE_COLUMN) private String resourceType; + @Column(name = RESOURCE_SUB_TYPE_COLUMN) + private String resourceSubType; + @Column(name = RESOURCE_KEY_COLUMN) private String resourceKey; @@ -107,6 +112,9 @@ public class TbResourceEntity extends BaseSqlEntity { } this.title = resource.getTitle(); this.resourceType = resource.getResourceType().name(); + if (resource.getResourceSubType() != null) { + this.resourceSubType = resource.getResourceSubType().name(); + } this.resourceKey = resource.getResourceKey(); this.searchText = resource.getSearchText(); this.fileName = resource.getFileName(); @@ -126,6 +134,7 @@ public class TbResourceEntity extends BaseSqlEntity { resource.setTenantId(TenantId.fromUUID(tenantId)); resource.setTitle(title); resource.setResourceType(ResourceType.valueOf(resourceType)); + resource.setResourceSubType(resourceSubType != null ? ResourceSubType.valueOf(resourceSubType) : null); resource.setResourceKey(resourceKey); resource.setSearchText(searchText); resource.setFileName(fileName); diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/TbResourceInfoEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/TbResourceInfoEntity.java index 8f525916bf..35394d5baa 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/TbResourceInfoEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/TbResourceInfoEntity.java @@ -19,6 +19,7 @@ import com.fasterxml.jackson.databind.JsonNode; import jakarta.persistence.Convert; import lombok.Data; import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.ResourceSubType; import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.TbResourceInfo; import org.thingsboard.server.common.data.id.TbResourceId; @@ -39,6 +40,7 @@ import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_FILE_NAME import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_IS_PUBLIC_COLUMN; import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_KEY_COLUMN; import static org.thingsboard.server.dao.model.ModelConstants.PUBLIC_RESOURCE_KEY_COLUMN; +import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_SUB_TYPE_COLUMN; import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_TABLE_NAME; import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_TENANT_ID_COLUMN; import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_TITLE_COLUMN; @@ -60,6 +62,9 @@ public class TbResourceInfoEntity extends BaseSqlEntity implemen @Column(name = RESOURCE_TYPE_COLUMN) private String resourceType; + @Column(name = RESOURCE_SUB_TYPE_COLUMN) + private String resourceSubType; + @Column(name = RESOURCE_KEY_COLUMN) private String resourceKey; @@ -96,6 +101,9 @@ public class TbResourceInfoEntity extends BaseSqlEntity implemen this.tenantId = resource.getTenantId().getId(); this.title = resource.getTitle(); this.resourceType = resource.getResourceType().name(); + if (resource.getResourceSubType() != null) { + this.resourceSubType = resource.getResourceSubType().name(); + } this.resourceKey = resource.getResourceKey(); this.searchText = resource.getSearchText(); this.etag = resource.getEtag(); @@ -113,6 +121,7 @@ public class TbResourceInfoEntity extends BaseSqlEntity implemen resource.setTenantId(TenantId.fromUUID(tenantId)); resource.setTitle(title); resource.setResourceType(ResourceType.valueOf(resourceType)); + resource.setResourceSubType(resourceSubType != null ? ResourceSubType.valueOf(resourceSubType) : null); resource.setResourceKey(resourceKey); resource.setSearchText(searchText); resource.setEtag(etag); diff --git a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java index 953037a864..28d00f8b5d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java @@ -36,6 +36,7 @@ import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.HasImage; import org.thingsboard.server.common.data.ImageDescriptor; +import org.thingsboard.server.common.data.ResourceSubType; import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.TbImageDeleteResult; import org.thingsboard.server.common.data.TbResource; @@ -140,6 +141,9 @@ public class BaseImageService extends BaseResourceService implements ImageServic if (image.getId() == null) { image.setResourceKey(getUniqueKey(image.getTenantId(), StringUtils.defaultIfEmpty(image.getResourceKey(), image.getFileName()))); } + if (image.getResourceSubType() == null) { + image.setResourceSubType(ResourceSubType.IMAGE); + } resourceValidator.validate(image, TbResourceInfo::getTenantId); ImageDescriptor descriptor = image.getDescriptor(ImageDescriptor.class); @@ -220,21 +224,23 @@ public class BaseImageService extends BaseResourceService implements ImageServic } @Override - public PageData getImagesByTenantId(TenantId tenantId, PageLink pageLink) { + public PageData getImagesByTenantId(TenantId tenantId, ResourceSubType imageSubType, PageLink pageLink) { log.trace("Executing getImagesByTenantId [{}]", tenantId); TbResourceInfoFilter filter = TbResourceInfoFilter.builder() .tenantId(tenantId) .resourceTypes(Set.of(ResourceType.IMAGE)) + .resourceSubTypes(Set.of(imageSubType)) .build(); return findTenantResourcesByTenantId(filter, pageLink); } @Override - public PageData getAllImagesByTenantId(TenantId tenantId, PageLink pageLink) { + public PageData getAllImagesByTenantId(TenantId tenantId, ResourceSubType imageSubType, PageLink pageLink) { log.trace("Executing getAllImagesByTenantId [{}]", tenantId); TbResourceInfoFilter filter = TbResourceInfoFilter.builder() .tenantId(tenantId) .resourceTypes(Set.of(ResourceType.IMAGE)) + .resourceSubTypes(Set.of(imageSubType)) .build(); return findAllTenantResourcesByTenantId(filter, pageLink); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java index 723d0e8861..93cc662472 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java @@ -184,7 +184,7 @@ public class BaseResourceService extends AbstractCachedEntityService findTenantResourcesByResourceTypeAndObjectIds(TenantId tenantId, ResourceType resourceType, String[] objectIds) { log.trace("Executing findTenantResourcesByResourceTypeAndObjectIds [{}][{}][{}]", tenantId, resourceType, objectIds); validateId(tenantId, id -> INCORRECT_TENANT_ID + id); - return resourceDao.findResourcesByTenantIdAndResourceType(tenantId, resourceType, objectIds, null); + return resourceDao.findResourcesByTenantIdAndResourceType(tenantId, resourceType, null, objectIds, null); } @Override @@ -198,7 +198,7 @@ public class BaseResourceService extends AbstractCachedEntityService findTenantResourcesByResourceTypeAndPageLink(TenantId tenantId, ResourceType resourceType, PageLink pageLink) { log.trace("Executing findTenantResourcesByResourceTypeAndPageLink [{}][{}][{}]", tenantId, resourceType, pageLink); validateId(tenantId, id -> INCORRECT_TENANT_ID + id); - return resourceDao.findResourcesByTenantIdAndResourceType(tenantId, resourceType, pageLink); + return resourceDao.findResourcesByTenantIdAndResourceType(tenantId, resourceType, null, pageLink); } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceDao.java b/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceDao.java index b776b49cc9..c236a4a5d5 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceDao.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.resource; +import org.thingsboard.server.common.data.ResourceSubType; import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.id.TbResourceId; @@ -35,10 +36,12 @@ public interface TbResourceDao extends Dao, TenantEntityWithDataDao, PageData findResourcesByTenantIdAndResourceType(TenantId tenantId, ResourceType resourceType, + ResourceSubType resourceSubType, PageLink pageLink); List findResourcesByTenantIdAndResourceType(TenantId tenantId, ResourceType resourceType, + ResourceSubType resourceSubType, String[] objectIds, String searchText); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/JpaTbResourceDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/JpaTbResourceDao.java index cb2ab5aa42..f8927aaa81 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/JpaTbResourceDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/JpaTbResourceDao.java @@ -19,6 +19,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ResourceSubType; import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.id.TbResourceId; @@ -68,11 +69,13 @@ public class JpaTbResourceDao extends JpaAbstractDao findResourcesByTenantIdAndResourceType(TenantId tenantId, ResourceType resourceType, + ResourceSubType resourceSubType, PageLink pageLink) { return DaoUtil.toPageData(resourceRepository.findResourcesPage( tenantId.getId(), TenantId.SYS_TENANT_ID.getId(), resourceType.name(), + resourceSubType != null ? resourceSubType.name() : null, pageLink.getTextSearch(), DaoUtil.toPageable(pageLink) )); @@ -80,6 +83,7 @@ public class JpaTbResourceDao extends JpaAbstractDao findResourcesByTenantIdAndResourceType(TenantId tenantId, ResourceType resourceType, + ResourceSubType resourceSubType, String[] objectIds, String searchText) { return objectIds == null ? @@ -87,6 +91,7 @@ public class JpaTbResourceDao extends JpaAbstractDao resourceSubTypes = filter.getResourceSubTypes(); return DaoUtil.toPageData(resourceInfoRepository .findAllTenantResourcesByTenantId( filter.getTenantId().getId(), TenantId.NULL_UUID, resourceTypes.stream().map(Enum::name).collect(Collectors.toList()), + CollectionsUtil.isEmpty(resourceSubTypes) ? null : + resourceSubTypes.stream().map(Enum::name).collect(Collectors.toList()), Objects.toString(pageLink.getTextSearch(), ""), DaoUtil.toPageable(pageLink))); } @@ -77,10 +81,13 @@ public class JpaTbResourceInfoDao extends JpaAbstractDao resourceSubTypes = filter.getResourceSubTypes(); return DaoUtil.toPageData(resourceInfoRepository .findTenantResourcesByTenantId( filter.getTenantId().getId(), resourceTypes.stream().map(Enum::name).collect(Collectors.toList()), + CollectionsUtil.isEmpty(resourceSubTypes) ? null : + resourceSubTypes.stream().map(Enum::name).collect(Collectors.toList()), pageLink.getTextSearch(), DaoUtil.toPageable(pageLink))); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceInfoRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceInfoRepository.java index c092ea3292..00c51b50a2 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceInfoRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceInfoRepository.java @@ -37,19 +37,23 @@ public interface TbResourceInfoRepository extends JpaRepository findAllTenantResourcesByTenantId(@Param("tenantId") UUID tenantId, @Param("systemTenantId") UUID systemTenantId, @Param("resourceTypes") List resourceTypes, + @Param("resourceSubTypes") List resourceSubTypes, @Param("searchText") String searchText, Pageable pageable); @Query("SELECT ri FROM TbResourceInfoEntity ri WHERE " + "ri.tenantId = :tenantId " + "AND ri.resourceType IN :resourceTypes " + + "AND (:resourceSubTypes IS NULL OR ri.resourceSubType IN :resourceSubTypes) " + "AND (:searchText IS NULL OR ilike(ri.title, CONCAT('%', :searchText, '%')) = true)") Page findTenantResourcesByTenantId(@Param("tenantId") UUID tenantId, @Param("resourceTypes") List resourceTypes, + @Param("resourceSubTypes") List resourceSubTypes, @Param("searchText") String searchText, Pageable pageable); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceRepository.java index f5a831ff64..ce2e6b890b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceRepository.java @@ -34,6 +34,7 @@ public interface TbResourceRepository extends JpaRepository findResources(@Param("tenantId") UUID tenantId, @Param("systemAdminId") UUID sysAdminId, @Param("resourceType") String resourceType, + @Param("resourceSubType") String resourceSubType, @Param("searchText") String searchText); @Query("SELECT tr FROM TbResourceEntity tr " + diff --git a/dao/src/main/resources/sql/schema-entities.sql b/dao/src/main/resources/sql/schema-entities.sql index 9c1841240b..375cd9f689 100644 --- a/dao/src/main/resources/sql/schema-entities.sql +++ b/dao/src/main/resources/sql/schema-entities.sql @@ -715,6 +715,7 @@ CREATE TABLE IF NOT EXISTS resource ( tenant_id uuid NOT NULL, title varchar(255) NOT NULL, resource_type varchar(32) NOT NULL, + resource_sub_type varchar(32), resource_key varchar(255) NOT NULL, search_text varchar(255), file_name varchar(255) NOT NULL, diff --git a/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java b/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java index 8783770be3..39d408464f 100644 --- a/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java +++ b/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java @@ -58,6 +58,7 @@ import org.thingsboard.server.common.data.EventInfo; import org.thingsboard.server.common.data.ImageExportData; import org.thingsboard.server.common.data.OtaPackage; import org.thingsboard.server.common.data.OtaPackageInfo; +import org.thingsboard.server.common.data.ResourceSubType; import org.thingsboard.server.common.data.SaveDeviceWithCredentialsRequest; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.SystemInfo; @@ -3603,10 +3604,19 @@ public class RestClient implements Closeable { } public PageData getImages(PageLink pageLink, boolean includeSystemImages) { + return this.getImages(pageLink, null, includeSystemImages); + } + + public PageData getImages(PageLink pageLink, ResourceSubType imageSubType, boolean includeSystemImages) { Map params = new HashMap<>(); + var url = baseURL + "/api/images?includeSystemImages={includeSystemImages}&"; addPageLinkToParam(params, pageLink); params.put("includeSystemImages", String.valueOf(includeSystemImages)); - return restTemplate.exchange(baseURL + "/api/images?includeSystemImages={includeSystemImages}&" + getUrlParams(pageLink), + if (imageSubType != null) { + url += "imageSubType={imageSubType}&"; + params.put("imageSubType", imageSubType.name()); + } + return restTemplate.exchange(url + getUrlParams(pageLink), HttpMethod.GET, HttpEntity.EMPTY, new ParameterizedTypeReference>() {}, From 69e1455e4089c1a7266c6cbc81de06141cfd9d00 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Wed, 8 May 2024 20:01:23 +0300 Subject: [PATCH 026/442] Update license header --- .../server/common/data/ResourceSubType.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ResourceSubType.java b/common/data/src/main/java/org/thingsboard/server/common/data/ResourceSubType.java index e7e8b0747a..13203f908f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ResourceSubType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ResourceSubType.java @@ -1,3 +1,18 @@ +/** + * Copyright ยฉ 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.thingsboard.server.common.data; public enum ResourceSubType { From a8647c71896b946173077ef6aa5320b05feda1eb Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 9 May 2024 09:55:42 +0300 Subject: [PATCH 027/442] Fix tests --- .../server/LwM2mVersionedModelProvider.java | 4 +-- .../lwm2m/server/client/LwM2mClient.java | 30 ++++++++++--------- .../server/store/util/LwM2MClientSerDes.java | 3 +- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mVersionedModelProvider.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mVersionedModelProvider.java index 78f76adc1d..ab52a024da 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mVersionedModelProvider.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mVersionedModelProvider.java @@ -108,9 +108,9 @@ public class LwM2mVersionedModelProvider implements LwM2mModelProvider { @Override public ObjectModel getObjectModel(int objectId) { LwM2mClient lwM2mClient = lwM2mClientContext.getClientByEndpoint(registration.getEndpoint()); - String version = lwM2mClient.getSupportedObjectVersion(objectId).toString(); + var version = lwM2mClient.getSupportedObjectVersion(objectId); if (version != null) { - return this.getObjectModelDynamic(objectId, version); + return this.getObjectModelDynamic(objectId, version.toString()); } return null; } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClient.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClient.java index e267561cd2..908b16a1db 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClient.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClient.java @@ -211,11 +211,11 @@ public class LwM2mClient { } private LwM2m.Version getObjectIDVerFromDeviceProfile(DeviceProfile deviceProfile) { - String defaultObjectIdVer = ((Lwm2mDeviceProfileTransportConfiguration)deviceProfile + String defaultObjectIdVer = deviceProfile != null ? ((Lwm2mDeviceProfileTransportConfiguration)deviceProfile .getProfileData() .getTransportConfiguration()) .getClientLwM2mSettings() - .getDefaultObjectIDVer(); + .getDefaultObjectIDVer() : null; return new Version(defaultObjectIdVer == null ? LWM2M_OBJECT_VERSION_DEFAULT : defaultObjectIdVer); } @@ -454,19 +454,21 @@ public class LwM2mClient { private void setSupportedClientObjects(){ this.supportedClientObjects = new ConcurrentHashMap<>(); for (Link link: this.registration.getSortedObjectLinks()) { - MixedLwM2mLink mixedLwM2mLink = (MixedLwM2mLink)link; - if(!mixedLwM2mLink.getPath().isRoot()){ - LwM2mPath lwM2mPath = mixedLwM2mLink.getPath(); - if (lwM2mPath.isObject()) { - LwM2m.Version ver; - if (mixedLwM2mLink.getAttributes().get("ver")!= null) { - ver = (Version) mixedLwM2mLink.getAttributes().get("ver").getValue(); - } else { - ver = getDefaultObjectIDVer(); + if (link instanceof MixedLwM2mLink) { + MixedLwM2mLink mixedLwM2mLink = (MixedLwM2mLink) link; + if (!mixedLwM2mLink.getPath().isRoot()) { + LwM2mPath lwM2mPath = mixedLwM2mLink.getPath(); + if (lwM2mPath.isObject()) { + LwM2m.Version ver; + if (mixedLwM2mLink.getAttributes().get("ver") != null) { + ver = (Version) mixedLwM2mLink.getAttributes().get("ver").getValue(); + } else { + ver = getDefaultObjectIDVer(); + } + this.supportedClientObjects.put(lwM2mPath.getObjectId(), ver); + } else if (this.supportedClientObjects.get(lwM2mPath.getObjectId()) == null) { + this.supportedClientObjects.put(lwM2mPath.getObjectId(), getDefaultObjectIDVer()); } - this.supportedClientObjects.put(lwM2mPath.getObjectId(), ver); - } else if (this.supportedClientObjects.get(lwM2mPath.getObjectId()) == null){ - this.supportedClientObjects.put(lwM2mPath.getObjectId(), getDefaultObjectIDVer()); } } } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/util/LwM2MClientSerDes.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/util/LwM2MClientSerDes.java index 21cdc356a4..ece90b2116 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/util/LwM2MClientSerDes.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/util/LwM2MClientSerDes.java @@ -23,6 +23,7 @@ import com.google.gson.JsonObject; import com.google.gson.JsonParser; import com.google.protobuf.util.JsonFormat; import lombok.SneakyThrows; +import org.eclipse.leshan.core.LwM2m; import org.eclipse.leshan.core.model.ResourceModel; import org.eclipse.leshan.core.node.LwM2mMultipleResource; import org.eclipse.leshan.core.node.LwM2mNodeException; @@ -345,7 +346,7 @@ public class LwM2MClientSerDes { if (defaultObjectIDVer != null) { Field defaultObjectIDVerField = lwM2mClientClass.getDeclaredField("defaultObjectIDVer"); defaultObjectIDVerField.setAccessible(true); - defaultObjectIDVerField.set(lwM2mClient, defaultObjectIDVer.getAsString()); + defaultObjectIDVerField.set(lwM2mClient, new LwM2m.Version(defaultObjectIDVer.getAsString())); } JsonElement registration = o.get("registration"); From 25d733324ff640a32f2dd7c24a8a4aedfdfdc559 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 9 May 2024 11:14:55 +0300 Subject: [PATCH 028/442] Update test --- .../org/thingsboard/server/controller/ImageControllerTest.java | 1 - 1 file changed, 1 deletion(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/ImageControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/ImageControllerTest.java index 53b36c955b..cf43f54df9 100644 --- a/application/src/test/java/org/thingsboard/server/controller/ImageControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/ImageControllerTest.java @@ -57,7 +57,6 @@ public class ImageControllerTest extends AbstractControllerTest { @Before public void beforeEach() throws Exception { - resourceRepository.deleteAll(); loginTenantAdmin(); } From 667536aba76d2af3f3e66c036fa309eb81ac6945 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 9 May 2024 14:49:05 +0300 Subject: [PATCH 029/442] Fix install script --- .../thingsboard/server/install/ThingsboardInstallService.java | 1 - 1 file changed, 1 deletion(-) diff --git a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java index eba35157c9..bab9e1fbc9 100644 --- a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java +++ b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java @@ -137,7 +137,6 @@ public class ThingsboardInstallService { entityDatabaseSchemaService.createCustomerTitleUniqueConstraintIfNotExists(); systemDataLoaderService.updateDefaultNotificationConfigs(false); systemDataLoaderService.updateJwtSettings(); - break; case "3.7.0": log.info("Upgrading ThingsBoard from version 3.7.0 to 3.7.1 ..."); databaseEntitiesUpgradeService.upgradeDatabase("3.7.0"); From ecb11b6a3ad1d9dec7d8bcafc2ce8bab7cdd710e Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 9 May 2024 18:56:31 +0300 Subject: [PATCH 030/442] UI: SCADA symbol edit page. --- ui-ngx/src/app/app.module.ts | 21 +++- ui-ngx/src/app/core/http/image.service.ts | 39 ++++++- ui-ngx/src/app/core/services/menu.service.ts | 14 +++ ui-ngx/src/app/core/utils.ts | 7 ++ .../components/details-panel.component.html | 2 +- .../components/details-panel.component.ts | 5 + .../widget/lib/svg/iot-svg.models.ts | 50 +++++++++ .../home/pages/admin/admin-routing.module.ts | 67 +++++++++++- .../modules/home/pages/home-pages.module.ts | 4 +- .../scada-symbol/scada-symbol.component.html | 42 ++++++++ .../scada-symbol/scada-symbol.component.scss | 35 ++++++ .../scada-symbol/scada-symbol.component.ts | 102 ++++++++++++++++++ .../pages/scada-symbol/scada-symbol.models.ts | 22 ++++ .../pages/scada-symbol/scada-symbol.module.ts | 34 ++++++ .../components/image-input.component.html | 2 +- .../components/image-input.component.ts | 38 +++++-- .../image/gallery-image-input.component.ts | 15 ++- .../image/image-dialog.component.ts | 1 + .../image/image-gallery-dialog.component.html | 1 + .../image/image-gallery-dialog.component.ts | 23 ++-- .../image/image-gallery.component.html | 54 +++++----- .../image/image-gallery.component.ts | 71 ++++++++---- .../components/image/images-datasource.ts | 10 +- .../multiple-gallery-image-input.component.ts | 15 ++- .../image/upload-image-dialog.component.html | 6 +- .../image/upload-image-dialog.component.ts | 9 +- .../src/app/shared/models/resource.models.ts | 7 ++ .../assets/locale/locale.constant-en_US.json | 20 ++++ 28 files changed, 610 insertions(+), 106 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.models.ts create mode 100644 ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.module.ts diff --git a/ui-ngx/src/app/app.module.ts b/ui-ngx/src/app/app.module.ts index e18c909c9a..1ebd87c119 100644 --- a/ui-ngx/src/app/app.module.ts +++ b/ui-ngx/src/app/app.module.ts @@ -27,6 +27,23 @@ import { AppComponent } from './app.component'; import { DashboardRoutingModule } from '@modules/dashboard/dashboard-routing.module'; import { RouterModule, Routes } from '@angular/router'; +import { DefaultUrlSerializer, UrlSerializer, UrlTree } from '@angular/router'; + +export default class TbUrlSerializer implements UrlSerializer { + private _defaultUrlSerializer: DefaultUrlSerializer = new DefaultUrlSerializer(); + + parse(url: string): UrlTree { + // Encode parentheses + url = url.replace(/\(/g, '%28').replace(/\)/g, '%29'); + // Use the default serializer. + return this._defaultUrlSerializer.parse(url) + } + + serialize(tree: UrlTree): string { + return this._defaultUrlSerializer.serialize(tree).replace(/%28/g, '(').replace(/%29/g, ')'); + } +} + const routes: Routes = [ { path: '**', redirectTo: 'home' @@ -55,7 +72,9 @@ export class PageNotFoundRoutingModule { } DashboardRoutingModule, PageNotFoundRoutingModule ], - providers: [], + providers: [ + { provide: UrlSerializer, useClass: TbUrlSerializer } + ], bootstrap: [AppComponent] }) export class AppModule { } diff --git a/ui-ngx/src/app/core/http/image.service.ts b/ui-ngx/src/app/core/http/image.service.ts index 2339801e2b..eb15b658e5 100644 --- a/ui-ngx/src/app/core/http/image.service.ts +++ b/ui-ngx/src/app/core/http/image.service.ts @@ -25,11 +25,11 @@ import { ImageResourceInfo, imageResourceType, ImageResourceType, - IMAGES_URL_PREFIX, isImageResourceUrl, ImageExportData, removeTbImagePrefix + IMAGES_URL_PREFIX, isImageResourceUrl, ImageExportData, removeTbImagePrefix, ResourceSubType } from '@shared/models/resource.models'; import { catchError, map, switchMap } from 'rxjs/operators'; import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; -import { blobToBase64 } from '@core/utils'; +import { blobToBase64, blobToText } from '@core/utils'; import { ResourcesService } from '@core/services/resources.service'; @Injectable({ @@ -46,13 +46,14 @@ export class ImageService { ) { } - public uploadImage(file: File, title: string, config?: RequestConfig): Observable { + public uploadImage(file: File, title: string, imageSubType: ResourceSubType, config?: RequestConfig): Observable { if (!config) { config = {}; } const formData = new FormData(); formData.append('file', file); formData.append('title', title); + formData.append('imageSubType', imageSubType); return this.http.post('/api/image', formData, defaultHttpUploadOptions(config.ignoreLoading, config.ignoreErrors, config.resendRequest)); } @@ -81,9 +82,10 @@ export class ImageService { imageInfo, defaultHttpOptionsFromConfig(config)); } - public getImages(pageLink: PageLink, includeSystemImages = false, config?: RequestConfig): Observable> { + public getImages(pageLink: PageLink, imageSubType: ResourceSubType, + includeSystemImages = false, config?: RequestConfig): Observable> { return this.http.get>( - `${IMAGES_URL_PREFIX}${pageLink.toQuery()}&includeSystemImages=${includeSystemImages}`, + `${IMAGES_URL_PREFIX}${pageLink.toQuery()}&imageSubType=${imageSubType}&includeSystemImages=${includeSystemImages}`, defaultHttpOptionsFromConfig(config)); } @@ -130,6 +132,33 @@ export class ImageService { ); } + public getImageString(imageUrl: string): Observable { + imageUrl = removeTbImagePrefix(imageUrl); + let request: ReplaySubject; + if (this.imagesLoading[imageUrl]) { + request = this.imagesLoading[imageUrl]; + } else { + request = new ReplaySubject(1); + this.imagesLoading[imageUrl] = request; + const options = defaultHttpOptionsFromConfig({ignoreLoading: true, ignoreErrors: true}); + this.http.get(imageUrl, {...options, ...{ responseType: 'blob' } }).subscribe({ + next: (value) => { + request.next(value); + request.complete(); + }, + error: err => { + request.error(err); + }, + complete: () => { + delete this.imagesLoading[imageUrl]; + } + }); + } + return request.pipe( + switchMap(val => blobToText(val)) + ); + } + public resolveImageUrl(imageUrl: string, preview = false, asString = false, emptyUrl = NO_IMAGE_DATA_URI): Observable { imageUrl = removeTbImagePrefix(imageUrl); if (isImageResourceUrl(imageUrl)) { diff --git a/ui-ngx/src/app/core/services/menu.service.ts b/ui-ngx/src/app/core/services/menu.service.ts index 1f2d76a8b8..256c1031ea 100644 --- a/ui-ngx/src/app/core/services/menu.service.ts +++ b/ui-ngx/src/app/core/services/menu.service.ts @@ -150,6 +150,13 @@ export class MenuService { path: '/resources/images', icon: 'filter' }, + { + id: 'scada_symbols', + name: 'scada.symbols', + type: 'link', + path: '/resources/scada-symbols', + icon: 'precision_manufacturing' + }, { id: 'resources_library', name: 'resource.resources-library', @@ -541,6 +548,13 @@ export class MenuService { path: '/resources/images', icon: 'filter' }, + { + id: 'scada_symbols', + name: 'scada.symbols', + type: 'link', + path: '/resources/scada-symbols', + icon: 'precision_manufacturing' + }, { id: 'resources_library', name: 'resource.resources-library', diff --git a/ui-ngx/src/app/core/utils.ts b/ui-ngx/src/app/core/utils.ts index dc9ed5004b..c9aebfc39b 100644 --- a/ui-ngx/src/app/core/utils.ts +++ b/ui-ngx/src/app/core/utils.ts @@ -217,6 +217,13 @@ export const blobToBase64 = (blob: Blob): Observable => from(new Promise } )); +export const blobToText = (blob: Blob): Observable => from(new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.readAsText(blob); + } +)); + const scrollRegex = /(auto|scroll)/; function parentNodes(node: Node, nodes: Node[]): Node[] { diff --git a/ui-ngx/src/app/modules/home/components/details-panel.component.html b/ui-ngx/src/app/modules/home/components/details-panel.component.html index d3e50ca1d7..2483a5b5e3 100644 --- a/ui-ngx/src/app/modules/home/components/details-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/details-panel.component.html @@ -40,7 +40,7 @@ search -
diff --git a/ui-ngx/src/app/modules/home/components/details-panel.component.ts b/ui-ngx/src/app/modules/home/components/details-panel.component.ts index bdcfbd636b..3b536c809e 100644 --- a/ui-ngx/src/app/modules/home/components/details-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/details-panel.component.ts @@ -28,6 +28,7 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { UntypedFormGroup } from '@angular/forms'; import { Subscription } from 'rxjs'; +import { coerceBoolean } from '@shared/decorators/coercion'; @Component({ selector: 'tb-details-panel', @@ -44,6 +45,10 @@ export class DetailsPanelComponent extends PageComponent implements OnDestroy { @Input() isShowSearch = false; @Input() backgroundColor = '#FFF'; + @Input() + @coerceBoolean() + showCloseDetails = true; + private theFormValue: UntypedFormGroup; private formSubscription: Subscription = null; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts index 4a19709228..8e369cf580 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts @@ -40,6 +40,7 @@ import { ColorProcessor, constantColor, Font } from '@shared/models/widget-setti import { AttributeScope } from '@shared/models/telemetry/telemetry.models'; import { UtilsService } from '@core/services/utils.service'; import { WidgetAction, WidgetActionType } from '@shared/models/widget.models'; +import { ResizeObserver } from '@juggle/resize-observer'; export interface IotSvgApi { formatValue: (value: any, dec?: number, units?: string, showZeroDecimals?: boolean) => string | undefined; @@ -248,6 +249,55 @@ export type IotSvgObjectSettings = {[id: string]: any}; const parseError = (ctx: WidgetContext, err: any): string => ctx.$injector.get(UtilsService).parseException(err).message || 'Unknown Error'; +export class IotSvgEditObject { + + private svgShape: Svg; + private box: Box; + + private shapeResize$: ResizeObserver; + constructor(private rootElement: HTMLElement) { + this.shapeResize$ = new ResizeObserver(() => { + this.resize(); + }); + this.shapeResize$.observe(this.rootElement); + } + + public setContent(svgContent: string) { + if (this.svgShape) { + this.svgShape.remove(); + } + const doc: XMLDocument = new DOMParser().parseFromString(svgContent, 'image/svg+xml'); + this.svgShape = SVG().svg(doc.documentElement.innerHTML); + this.svgShape.node.style.overflow = 'visible'; + this.svgShape.node.style['user-select'] = 'none'; + this.box = this.svgShape.bbox(); + this.svgShape.size(this.box.width, this.box.height); + this.svgShape.addTo(this.rootElement); + this.resize(); + } + + public destroy() { + if (this.shapeResize$) { + this.shapeResize$.disconnect(); + } + } + + private resize() { + if (this.svgShape) { + const targetWidth = this.rootElement.getBoundingClientRect().width; + const targetHeight = this.rootElement.getBoundingClientRect().height; + let scale: number; + if (targetWidth < targetHeight) { + scale = targetWidth / this.box.width; + } else { + scale = targetHeight / this.box.height; + } + this.svgShape.node.style.transform = `scale(${scale})`; + } + } + +} + export class IotSvgObject { private metadata: IotSvgMetadata; diff --git a/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts b/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts index 63fb9a14de..e292f3cfbb 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts @@ -14,8 +14,8 @@ /// limitations under the License. /// -import { Injectable, NgModule } from '@angular/core'; -import { Resolve, RouterModule, Routes } from '@angular/router'; +import { inject, Injectable, NgModule } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, ResolveFn, RouterModule, RouterStateSnapshot, Routes } from '@angular/router'; import { MailServerComponent } from '@modules/home/pages/admin/mail-server.component'; import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard'; @@ -23,7 +23,7 @@ import { Authority } from '@shared/models/authority.enum'; import { GeneralSettingsComponent } from '@modules/home/pages/admin/general-settings.component'; import { SecuritySettingsComponent } from '@modules/home/pages/admin/security-settings.component'; import { OAuth2SettingsComponent } from '@home/pages/admin/oauth2-settings.component'; -import { Observable } from 'rxjs'; +import { forkJoin, Observable } from 'rxjs'; import { OAuth2Service } from '@core/http/oauth2.service'; import { SmsProviderComponent } from '@home/pages/admin/sms-provider.component'; import { HomeSettingsComponent } from '@home/pages/admin/home-settings.component'; @@ -31,7 +31,7 @@ import { EntitiesTableComponent } from '@home/components/entity/entities-table.c import { ResourcesLibraryTableConfigResolver } from '@home/pages/admin/resource/resources-library-table-config.resolve'; import { EntityDetailsPageComponent } from '@home/components/entity/entity-details-page.component'; import { entityDetailsPageBreadcrumbLabelFunction } from '@home/pages/home-pages.models'; -import { BreadCrumbConfig } from '@shared/components/breadcrumb'; +import { BreadCrumbConfig, BreadCrumbLabelFunction } from '@shared/components/breadcrumb'; import { QueuesTableConfigResolver } from '@home/pages/admin/queue/queues-table-config.resolver'; import { RepositoryAdminSettingsComponent } from '@home/pages/admin/repository-admin-settings.component'; import { AutoCommitAdminSettingsComponent } from '@home/pages/admin/auto-commit-admin-settings.component'; @@ -40,6 +40,10 @@ import { widgetsLibraryRoutes } from '@home/pages/widget/widget-library-routing. import { RouterTabsComponent } from '@home/components/router-tabs.component'; import { auditLogsRoutes } from '@home/pages/audit-log/audit-log-routing.module'; import { ImageGalleryComponent } from '@shared/components/image/image-gallery.component'; +import { ImageResourceType, IMAGES_URL_PREFIX, ResourceSubType } from '@shared/models/resource.models'; +import { ScadaSymbolComponent } from '@home/pages/scada-symbol/scada-symbol.component'; +import { ImageService } from '@core/http/image.service'; +import { ScadaSymbolData } from '@home/pages/scada-symbol/scada-symbol.models'; @Injectable() export class OAuth2LoginProcessingUrlResolver implements Resolve { @@ -52,6 +56,22 @@ export class OAuth2LoginProcessingUrlResolver implements Resolve { } } +export const scadaSymbolResolver: ResolveFn = + (route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + imageService = inject(ImageService)) => { + const type: ImageResourceType = route.params.type; + const key = decodeURIComponent(route.params.key); + return forkJoin({ + imageResource: imageService.getImageInfo(type, key), + svgContent: imageService.getImageString(`${IMAGES_URL_PREFIX}/${type}/${encodeURIComponent(key)}`) + }); +}; + +export const scadaSymbolBreadcumbLabelFunction: BreadCrumbLabelFunction + = ((route, translate, component) => + component.symbolData?.imageResource?.title); + const routes: Routes = [ { path: 'resources', @@ -87,8 +107,45 @@ const routes: Routes = [ data: { auth: [Authority.TENANT_ADMIN, Authority.SYS_ADMIN], title: 'image.gallery', - }, + imageSubType: ResourceSubType.IMAGE + } + } + ] + }, + { + path: 'scada-symbols', + data: { + breadcrumb: { + label: 'scada.symbols', + icon: 'precision_manufacturing' } + }, + children: [ + { + path: '', + component: ImageGalleryComponent, + data: { + auth: [Authority.TENANT_ADMIN, Authority.SYS_ADMIN], + title: 'scada.symbols', + imageSubType: ResourceSubType.IOT_SVG + } + }, + { + path: ':type/:key', + component: ScadaSymbolComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + breadcrumb: { + labelFunction: scadaSymbolBreadcumbLabelFunction, + icon: 'precision_manufacturing' + } as BreadCrumbConfig, + auth: [Authority.TENANT_ADMIN, Authority.SYS_ADMIN], + title: 'scada.symbol' + }, + resolve: { + symbolData: scadaSymbolResolver + } + }, ] }, { diff --git a/ui-ngx/src/app/modules/home/pages/home-pages.module.ts b/ui-ngx/src/app/modules/home/pages/home-pages.module.ts index 63a3aace21..0b1a70996e 100644 --- a/ui-ngx/src/app/modules/home/pages/home-pages.module.ts +++ b/ui-ngx/src/app/modules/home/pages/home-pages.module.ts @@ -43,6 +43,7 @@ import { EntitiesModule } from '@home/pages/entities/entities.module'; import { FeaturesModule } from '@home/pages/features/features.module'; import { NotificationModule } from '@home/pages/notification/notification.module'; import { AccountModule } from '@home/pages/account/account.module'; +import { ScadaSymbolModule } from '@home/pages/scada-symbol/scada-symbol.module'; @NgModule({ exports: [ @@ -72,7 +73,8 @@ import { AccountModule } from '@home/pages/account/account.module'; OtaUpdateModule, UserModule, VcModule, - AccountModule + AccountModule, + ScadaSymbolModule ] }) export class HomePagesModule { } diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html new file mode 100644 index 0000000000..cd366abbde --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html @@ -0,0 +1,42 @@ + +
+
+
+ + + +
+ {{ metadata | json }} +
+
+
+ +
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.scss b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.scss new file mode 100644 index 0000000000..783e2d842b --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.scss @@ -0,0 +1,35 @@ +/** + * Copyright ยฉ 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +.tb-scada-symbol { + width: 100%; + height: 100%; + .tb-scada-symbol-details-drawer { + width: 50%; + } + .tb-scada-symbol-content { + flex: 1; + min-width: 0; + min-height: 0; + background: #fff; + .tb-scada-symbol-shape { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.ts new file mode 100644 index 0000000000..d75ac137e6 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.ts @@ -0,0 +1,102 @@ +/// +/// Copyright ยฉ 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + AfterViewInit, + Component, + ElementRef, + HostBinding, + OnDestroy, + OnInit, + ViewChild, + ViewEncapsulation +} from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { ActivatedRoute, Router } from '@angular/router'; +import { takeUntil } from 'rxjs/operators'; +import { Subject } from 'rxjs'; +import { ScadaSymbolData } from '@home/pages/scada-symbol/scada-symbol.models'; +import { + IotSvgEditObject, + IotSvgMetadata, + parseIotSvgMetadataFromContent +} from '@home/components/widget/lib/svg/iot-svg.models'; + +@Component({ + selector: 'tb-scada-symbol', + templateUrl: './scada-symbol.component.html', + styleUrls: ['./scada-symbol.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class ScadaSymbolComponent extends PageComponent implements OnInit, OnDestroy, AfterViewInit { + + @HostBinding('style.width') width = '100%'; + @HostBinding('style.height') height = '100%'; + + @ViewChild('iotSvgShape', {static: false}) + iotSvgShape: ElementRef; + + symbolData: ScadaSymbolData; + + iotSvgObjectEdit: IotSvgEditObject; + metadata: IotSvgMetadata; + + private destroy$ = new Subject(); + + constructor(protected store: Store, + private router: Router, + private route: ActivatedRoute) { + super(store); + this.route.data.pipe( + takeUntil(this.destroy$) + ).subscribe( + () => { + this.reset(); + this.init(); + } + ); + } + + ngOnInit(): void { + } + + ngAfterViewInit() { + this.iotSvgObjectEdit = new IotSvgEditObject(this.iotSvgShape.nativeElement); + if (this.symbolData) { + this.iotSvgObjectEdit.setContent(this.symbolData.svgContent); + } + } + + ngOnDestroy() { + super.ngOnDestroy(); + this.destroy$.next(); + this.destroy$.complete(); + } + + private reset(): void { + } + + private init() { + this.symbolData = this.route.snapshot.data.symbolData; + this.metadata = parseIotSvgMetadataFromContent(this.symbolData.svgContent); + if (this.iotSvgObjectEdit) { + this.iotSvgObjectEdit.setContent(this.symbolData.svgContent); + } + } +} + diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.models.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.models.ts new file mode 100644 index 0000000000..32b05f2265 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.models.ts @@ -0,0 +1,22 @@ +/// +/// Copyright ยฉ 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { ImageResourceInfo } from '@shared/models/resource.models'; + +export interface ScadaSymbolData { + imageResource: ImageResourceInfo; + svgContent: string; +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.module.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.module.ts new file mode 100644 index 0000000000..2ed9a0bb4c --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.module.ts @@ -0,0 +1,34 @@ +/// +/// Copyright ยฉ 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@app/shared/shared.module'; +import { HomeComponentsModule } from '@modules/home/components/home-components.module'; +import { ScadaSymbolComponent } from '@home/pages/scada-symbol/scada-symbol.component'; + +@NgModule({ + declarations: + [ + ScadaSymbolComponent + ], + imports: [ + CommonModule, + SharedModule, + HomeComponentsModule + ] +}) +export class ScadaSymbolModule { } diff --git a/ui-ngx/src/app/shared/components/image-input.component.html b/ui-ngx/src/app/shared/components/image-input.component.html index 6a657f70b1..3b7130b634 100644 --- a/ui-ngx/src/app/shared/components/image-input.component.html +++ b/ui-ngx/src/app/shared/components/image-input.component.html @@ -47,7 +47,7 @@ - +
diff --git a/ui-ngx/src/app/shared/components/image-input.component.ts b/ui-ngx/src/app/shared/components/image-input.component.ts index acbff9844f..5bf98c65a9 100644 --- a/ui-ngx/src/app/shared/components/image-input.component.ts +++ b/ui-ngx/src/app/shared/components/image-input.component.ts @@ -53,6 +53,9 @@ import { ImagePipe } from '@shared/pipe/image.pipe'; }) export class ImageInputComponent extends PageComponent implements AfterViewInit, OnDestroy, ControlValueAccessor { + @Input() + accept = 'image/*'; + @Input() label: string; @@ -85,6 +88,9 @@ export class ImageInputComponent extends PageComponent implements AfterViewInit, @Input() inputId = this.utils.guid(); + @Input() + allowedExtensions: string; + @Input() @coerceBoolean() processImageApiLink = false; @@ -141,17 +147,19 @@ export class ImageInputComponent extends PageComponent implements AfterViewInit, ); return false; } - const reader = new FileReader(); - reader.onload = (_loadEvent) => { - if (typeof reader.result === 'string' && reader.result.startsWith('data:image/')) { - this.imageUrl = reader.result; - this.safeImageUrl = this.sanitizer.bypassSecurityTrustUrl(this.imageUrl); - this.file = file; - this.fileName = fileName; - this.updateModel(); - } - }; - reader.readAsDataURL(file); + if (this.filterFile(flowFile)) { + const reader = new FileReader(); + reader.onload = (_loadEvent) => { + if (typeof reader.result === 'string' && reader.result.startsWith('data:image/')) { + this.imageUrl = reader.result; + this.safeImageUrl = this.sanitizer.bypassSecurityTrustUrl(this.imageUrl); + this.file = file; + this.fileName = fileName; + this.updateModel(); + } + }; + reader.readAsDataURL(file); + } } }); } @@ -198,6 +206,14 @@ export class ImageInputComponent extends PageComponent implements AfterViewInit, this.fileNameChanged.emit(this.fileName); } + private filterFile(file: flowjs.FlowFile): boolean { + if (this.allowedExtensions) { + return this.allowedExtensions.split(',').indexOf(file.getExtension()) > -1; + } else { + return true; + } + } + clearImage() { this.imageUrl = null; this.safeImageUrl = null; diff --git a/ui-ngx/src/app/shared/components/image/gallery-image-input.component.ts b/ui-ngx/src/app/shared/components/image/gallery-image-input.component.ts index 9b81a0eaba..fd44b6cfec 100644 --- a/ui-ngx/src/app/shared/components/image/gallery-image-input.component.ts +++ b/ui-ngx/src/app/shared/components/image/gallery-image-input.component.ts @@ -26,11 +26,15 @@ import { isBase64DataImageUrl, isImageResourceUrl, prependTbImagePrefix, - removeTbImagePrefix + removeTbImagePrefix, + ResourceSubType } from '@shared/models/resource.models'; import { ImageService } from '@core/http/image.service'; import { MatDialog } from '@angular/material/dialog'; -import { ImageGalleryDialogComponent } from '@shared/components/image/image-gallery-dialog.component'; +import { + ImageGalleryDialogComponent, + ImageGalleryDialogData +} from '@shared/components/image/image-gallery-dialog.component'; export enum ImageLinkType { none = 'none', @@ -188,11 +192,14 @@ export class GalleryImageInputComponent extends PageComponent implements OnInit, if ($event) { $event.stopPropagation(); } - this.dialog.open(ImageGalleryDialogComponent, { autoFocus: false, disableClose: false, - panelClass: ['tb-dialog', 'tb-fullscreen-dialog'] + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + imageSubType: ResourceSubType.IMAGE + } }).afterClosed().subscribe((image) => { if (image) { this.linkType = ImageLinkType.resource; diff --git a/ui-ngx/src/app/shared/components/image/image-dialog.component.ts b/ui-ngx/src/app/shared/components/image/image-dialog.component.ts index 40e7d2ec89..fd2b941382 100644 --- a/ui-ngx/src/app/shared/components/image/image-dialog.component.ts +++ b/ui-ngx/src/app/shared/components/image/image-dialog.component.ts @@ -146,6 +146,7 @@ export class ImageDialogComponent extends disableClose: true, panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], data: { + imageSubType: this.image.resourceSubType, image: this.image } }).afterClosed().subscribe((result) => { diff --git a/ui-ngx/src/app/shared/components/image/image-gallery-dialog.component.html b/ui-ngx/src/app/shared/components/image/image-gallery-dialog.component.html index 8e24e2ffb2..3c4b6b62fd 100644 --- a/ui-ngx/src/app/shared/components/image/image-gallery-dialog.component.html +++ b/ui-ngx/src/app/shared/components/image/image-gallery-dialog.component.html @@ -24,6 +24,7 @@ , protected router: Router, - private imageService: ImageService, - private dialog: MatDialog, + @Inject(MAT_DIALOG_DATA) public data: ImageGalleryDialogData, public dialogRef: MatDialogRef) { super(store, router, dialogRef); } diff --git a/ui-ngx/src/app/shared/components/image/image-gallery.component.html b/ui-ngx/src/app/shared/components/image/image-gallery.component.html index 21b10e8c66..d9768baf8a 100644 --- a/ui-ngx/src/app/shared/components/image/image-gallery.component.html +++ b/ui-ngx/src/app/shared/components/image/image-gallery.component.html @@ -22,7 +22,7 @@ [fxShow]="!textSearchMode && (mode === 'grid' || dataSource?.selection.isEmpty())" [ngClass.lt-lg]="{'multi-row': !isSysAdmin}">
- image.gallery + {{ (isScada ? 'scada.symbols' : 'image.gallery') | translate }}
@@ -46,7 +46,7 @@ {{ 'image.include-system-images' | translate }} + (ngModelChange)="includeSystemImagesChanged($event)">{{ (isScada ? 'scada.include-system-symbols' : 'image.include-system-images') | translate }}
@@ -68,7 +68,7 @@ @@ -77,7 +77,7 @@ mat-icon-button color="primary" (click)="uploadImage()" - matTooltip="{{'image.upload-image' | translate }}" + matTooltip="{{ (isScada ? 'scada.upload-symbol' : 'image.upload-image' ) | translate }}" matTooltipPosition="above"> add @@ -87,18 +87,18 @@ mat-flat-button color="primary" (click)="uploadImage()"> - {{ 'image.upload-image' | translate }} + {{ (isScada ? 'scada.upload-symbol' : 'image.upload-image' ) | translate }}
{{ 'image.include-system-images' | translate }} + (ngModelChange)="includeSystemImagesChanged($event)">{{ (isScada ? 'scada.include-system-symbols' : 'image.include-system-images') | translate }}
@@ -106,7 +106,7 @@   + placeholder="{{ (isScada ? 'scada.search' : 'image.search' ) | translate }}"/> - -
@@ -318,7 +318,7 @@
-
image.no-images
+
{{ (isScada ? 'scada.no-symbols' : 'image.no-images') | translate }}
@@ -326,18 +326,18 @@
- +
`); + const addTagButton = addTagPanel.find('.add-tag-button'); + addTagButton.on('click', () => { + this.setupAddTagInputPanel(); + }); + this.tooltip.content(addTagPanel); + this.tooltip.off('closing'); + } + + private setupAddTagInputPanel() { + const addTagInputPanel = + $(`
+ Enter tag: + + done + close +
`); + const tagInput = addTagInputPanel.find('input.tag-input'); + const applyTagButton = addTagInputPanel.find('span.apply-icon'); + const closeButton = addTagInputPanel.find('span.close-icon'); + textTooltip(applyTagButton, 'Apply'); + textTooltip(closeButton, 'Cancel'); + + tagInput.on('keypress', (event) => { + if (event.which === 13) { + const newTag: string = tagInput.val() as string; + if (newTag) { + this.setTag(newTag); + } + } + }); + applyTagButton.on('click', () => { + const newTag: string = tagInput.val() as string; + if (newTag) { + this.setTag(newTag); + } else { + this.tooltip.close(); + } + }); + closeButton.on('click', () => { + this.tooltip.close(); + }); + this.tooltip.content(addTagInputPanel); + this.tooltip.on('closing', () => { + this.setupAddTagPanel(); + }); + tagInput.trigger('focus'); + } + + private innerTooltipPosition(instance: ITooltipsterInstance, helper: ITooltipsterHelper, position: ITooltipPosition): ITooltipPosition { + if (!this.isGroup()) { + const clientRect = helper.origin.getBoundingClientRect(); + position.coord.top = clientRect.top + (clientRect.height - position.size.height) / 2 + + this.innerTooltipOffset; + position.coord.left = clientRect.left + (clientRect.width - position.size.width) / 2; + } + return position; + } + + private hasTag() { + return !!this.tag; + } + + private isGroup() { + return this.element.type === 'g'; + } + } export class IotSvgObject { From b034cd1532175adf5b7283ee9a73ec58223305cd Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 16 May 2024 18:07:53 +0300 Subject: [PATCH 033/442] UI: Improve scada symbol editor --- ui-ngx/package.json | 1 + .../widget/lib/svg/iot-svg.models.ts | 379 +++++++++++++++--- .../scada-symbol/scada-symbol.component.html | 1 + ui-ngx/src/styles.scss | 143 +++++++ ui-ngx/yarn.lock | 9 +- 5 files changed, 482 insertions(+), 51 deletions(-) diff --git a/ui-ngx/package.json b/ui-ngx/package.json index b8a8e4c9a9..dc1c00f024 100644 --- a/ui-ngx/package.json +++ b/ui-ngx/package.json @@ -45,6 +45,7 @@ "@ngx-translate/core": "^14.0.0", "@svgdotjs/svg.filter.js": "^3.0.8", "@svgdotjs/svg.js": "^3.2.0", + "@svgdotjs/svg.panzoom.js": "^2.1.2", "@tinymce/tinymce-angular": "^7.0.0", "ace-builds": "1.4.13", "ace-diff": "^3.0.3", diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts index fbab201b72..e128a26675 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts @@ -16,7 +16,8 @@ import { ValueType } from '@shared/models/constants'; import * as svgjs from '@svgdotjs/svg.js'; -import { Box, Element, Rect, Runner, SVG, Svg, Text } from '@svgdotjs/svg.js'; +import { Box, Element, Rect, Runner, SVG, Svg, Text, Timeline, Style } from '@svgdotjs/svg.js'; +import '@svgdotjs/svg.panzoom.js'; import { DataToValueType, GetValueAction, @@ -45,6 +46,7 @@ import { ResizeObserver } from '@juggle/resize-observer'; import ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance; import ITooltipPosition = JQueryTooltipster.ITooltipPosition; import ITooltipsterHelper = JQueryTooltipster.ITooltipsterHelper; +import TooltipPositioningSide = JQueryTooltipster.TooltipPositioningSide; export interface IotSvgApi { formatValue: (value: any, dec?: number, units?: string, showZeroDecimals?: boolean) => string | undefined; @@ -281,30 +283,36 @@ export class IotSvgEditObject { private box: Box; private elements: IotSvgElement[] = []; private readonly shapeResize$: ResizeObserver; + private performSetup = false; + private hoverFilterStyle: Style; public scale = 1; constructor(private rootElement: HTMLElement) { this.shapeResize$ = new ResizeObserver(() => { this.resize(); }); - this.shapeResize$.observe(this.rootElement); } public setContent(svgContent: string) { + this.shapeResize$.unobserve(this.rootElement); if (this.svgShape) { + this.elements.length = 0; this.svgShape.remove(); } const doc: XMLDocument = new DOMParser().parseFromString(svgContent, 'image/svg+xml'); - this.svgShape = SVG().svg(doc.documentElement.innerHTML); + this.svgShape = SVG().addTo(this.rootElement).svg(doc.documentElement.innerHTML); this.svgShape.node.style.overflow = 'visible'; this.svgShape.node.style['user-select'] = 'none'; this.box = this.svgShape.bbox(); this.svgShape.size(this.box.width, this.box.height); - this.svgShape.addTo(this.rootElement); - this.resize(); - //this.svgShape.cre - this.svgShape.style().rule('.hovered', {filter: 'drop-shadow(0px 0px 1px #FFC107)'}); - //this.svgShape.style().rule('.hovered', {filter: 'opacity(50%)'}); + this.svgShape.viewbox(`0 0 ${this.box.width} ${this.box.height}`); this.svgShape.style().rule('.tb-element', {cursor: 'pointer', transition: '0.2s filter ease-in-out'}); + this.updateHoverFilterStyle(); + this.performSetup = true; + this.shapeResize$.observe(this.rootElement); + } + + private doSetup() { + this.setupZoomPan(0); (window as any).SVG = svgjs; forkJoin([ from(import('tooltipster')), @@ -314,6 +322,49 @@ export class IotSvgEditObject { }); } + private setupZoomPan(margin: number) { + this.svgShape.on('zoom', (e) => { + const { + detail: { level, focus } + } = e as any; + this.svgShape.zoom(level, focus); + const box = this.restrictToMargins(this.svgShape.viewbox(), margin); + this.svgShape.viewbox(box); + setTimeout(() => { + this.updateTooltipPositions(); + }); + e.preventDefault(); + }); + this.svgShape.on('panning', (e) => { + const box = (e as any).detail.box; + this.svgShape.viewbox(this.restrictToMargins(box, margin)); + setTimeout(() => { + this.updateTooltipPositions(); + }); + e.preventDefault(); + }); + this.svgShape.on('panStart', (e) => { + this.svgShape.node.style.cursor = 'grab'; + }); + this.svgShape.on('panEnd', (e) => { + this.svgShape.node.style.cursor = 'default'; + }); + } + + private restrictToMargins(box: Box, margin: number): Box { + if (box.x < -margin) { + box.x = -margin; + } else if ((box.x + box.width) > (this.box.width + margin)) { + box.x = this.box.width + margin - box.width; + } + if (box.y < -margin) { + box.y = -margin; + } else if ((box.y + box.height) > (this.box.height + margin)) { + box.y = this.box.height + margin - box.height; + } + return box; + } + private setupElements() { this.svgShape.children().forEach(child => { this.addElement(child); @@ -343,9 +394,20 @@ export class IotSvgEditObject { } } for (const group of overlappingGroups) { - let offset = - (elementTooltipMinHeight * group.length) / 2 + elementTooltipMinHeight / 2; + const centers = group.map(e => e.box.cy); + const center = centers.reduce((a, b) => a + b, 0) / centers.length; + const textElement = group.find(e => e.isText()); + const slots = textElement ? group.length % 2 === 0 ? (group.length + 1) : group.length : group.length; + if (textElement) { + textElement.setInnerTooltipOffset(0, center); + group.splice(group.indexOf(textElement), 1); + } + let offset = - (elementTooltipMinHeight * slots) / 2 + elementTooltipMinHeight / 2; for (const element of group) { - element.innerTooltipOffset = offset; + if (textElement && offset === 0) { + offset += elementTooltipMinHeight; + } + element.setInnerTooltipOffset(offset, center); offset += elementTooltipMinHeight; } } @@ -376,12 +438,55 @@ export class IotSvgEditObject { if (this.svgShape) { const targetWidth = this.rootElement.getBoundingClientRect().width; const targetHeight = this.rootElement.getBoundingClientRect().height; + let scale: number; if (targetWidth < targetHeight) { - this.scale = targetWidth / this.box.width; + scale = targetWidth / this.box.width; } else { - this.scale = targetHeight / this.box.height; + scale = targetHeight / this.box.height; + } + if (this.scale !== scale) { + this.scale = scale; + this.svgShape.node.style.transform = `scale(${this.scale})`; + this.updateHoverFilterStyle(); + this.updateZoomOptions(); + this.updateTooltipPositions(); + } + if (this.performSetup) { + this.performSetup = false; + this.doSetup(); } - this.svgShape.node.style.transform = `scale(${this.scale})`; + } + } + + private updateHoverFilterStyle() { + if (this.hoverFilterStyle) { + this.hoverFilterStyle.remove(); + } + const whiteBlur = (2.8 / this.scale).toFixed(2); + const blackBlur = (1.2 / this.scale).toFixed(2); + this.hoverFilterStyle = + this.svgShape.style().rule('.hovered', + { + filter: + `drop-shadow(0px 0px ${whiteBlur}px white) drop-shadow(0px 0px ${whiteBlur}px white) + drop-shadow(0px 0px ${whiteBlur}px white) drop-shadow(0px 0px ${whiteBlur}px white) + drop-shadow(0px 0px ${blackBlur}px black)` + } + ); + } + + private updateZoomOptions() { + this.svgShape.panZoom({ + zoomMin: 1, + zoomMax: 4, + zoomFactor: 2 / this.scale + }); + } + + private updateTooltipPositions() { + const container = this.rootElement.getBoundingClientRect(); + for (const e of this.elements) { + e.updateTooltipPosition(container); } } @@ -390,8 +495,8 @@ export class IotSvgEditObject { const hasBBox = (e: Element): boolean => { try { if (e.bbox) { - e.bbox(); - return true; + const box = e.bbox(); + return !!box.width || !!box.height; } else { return false; } @@ -402,31 +507,45 @@ const hasBBox = (e: Element): boolean => { const textTooltip = (el: JQuery, text: string) => { el.tooltipster({ - theme: ['tooltipster-tb'], + theme: ['iot-svg'], trigger: 'hover', content: text }); }; +const isDomRectContained = (target: DOMRect, container: DOMRect, horizontalGap = 0, verticalGap = 0): boolean => ( + target.left >= container.left - horizontalGap && + target.left + target.width <= container.left + container.width + horizontalGap && + target.top >= container.top - verticalGap && + target.top + target.height <= container.top + container.height + verticalGap +); + const elementTooltipMinHeight = 36 + 8; const elementTooltipMinWidth = 100; -const groupRectStroke = 10; +const groupRectStroke = 2; +const groupRectPadding = 2; class IotSvgElement { private highlightRect: Rect; + private highlightRectTimeline: Timeline; private tooltip: ITooltipsterInstance; private tag: string; - public innerTooltipOffset = 0; + private isEditing = false; + + private innerTooltipOffset = 0; public readonly box: Box; private highlighted = false; + private tooltipMouseX: number; + private tooltipMouseY: number; + constructor(private editObject: IotSvgEditObject, private element: Element) { this.tag = element.attr('tb:tag'); @@ -437,10 +556,18 @@ class IotSvgElement { if (this.isGroup()) { this.highlightRect = this.editObject.svgShape - .rect(this.box.width + this.unscaled(groupRectStroke * 4), this.box.height + this.unscaled(groupRectStroke * 4)) - .x(this.box.x - this.unscaled(groupRectStroke * 2)) - .y(this.box.y - this.unscaled(groupRectStroke * 2)) - .attr({fill: 'none', stroke: '#ccc', 'stroke-width': this.unscaled(groupRectStroke), opacity: 0}); + .rect(this.box.width + groupRectPadding * 2, this.box.height + groupRectPadding * 2) + .x(this.box.x - groupRectPadding) + .y(this.box.y - groupRectPadding) + .attr({ + fill: 'none', + rx: this.unscaled(6), + stroke: 'rgba(0, 0, 0, 0.38)', + 'stroke-dasharray': '1', + 'stroke-width': this.unscaled(groupRectStroke), + opacity: 0}); + this.highlightRectTimeline = new Timeline(); + this.highlightRect.timeline(this.highlightRectTimeline); this.highlightRect.hide(); } else { this.element.addClass('tb-element'); @@ -470,18 +597,22 @@ class IotSvgElement { if (!this.highlighted) { this.highlighted = true; if (this.isGroup()) { - this.highlightRect.width(this.box.width + this.unscaled(groupRectStroke * 4)) - .height(this.box.height + this.unscaled(groupRectStroke * 4)) - .x(this.box.x - this.unscaled(groupRectStroke * 2)) - .y(this.box.y - this.unscaled(groupRectStroke * 2)) - .attr({'stroke-width': this.unscaled(groupRectStroke)}); + this.highlightRectTimeline.finish(); + this.highlightRect + .attr({ + rx: this.unscaled(6), + 'stroke-width': this.unscaled(groupRectStroke) + }); this.highlightRect.show(); this.highlightRect.animate(300).attr({opacity: 1}); } else { this.element.addClass('hovered'); } if (this.hasTag()) { - this.tooltip.reposition(); + if (!this.isEditing) { + this.tooltip.reposition(); + } + $(this.tooltip.elementTooltip()).addClass('tb-active'); } } } @@ -490,12 +621,16 @@ class IotSvgElement { if (this.highlighted) { this.highlighted = false; if (this.isGroup()) { + this.highlightRectTimeline.finish(); this.highlightRect.animate(300).attr({opacity: 0}).after(() => { this.highlightRect.hide(); }); } else { this.element.removeClass('hovered'); } + if (this.hasTag()) { + $(this.tooltip.elementTooltip()).removeClass('tb-active'); + } } } @@ -503,6 +638,7 @@ class IotSvgElement { this.tooltip.destroy(); this.tag = null; this.element.attr('tb:tag', null); + this.unhighlight(); this.createAddTagTooltip(); } @@ -513,8 +649,31 @@ class IotSvgElement { this.createTagTooltip(); } + public updateTooltipPosition(container: DOMRect) { + if (this.tooltip && !this.tooltip.status().destroyed) { + this.tooltip.reposition(); + const tooltipElement = this.tooltip.elementTooltip(); + if (tooltipElement) { + if (isDomRectContained(tooltipElement.getBoundingClientRect(), container, + elementTooltipMinWidth, elementTooltipMinHeight)) { + tooltipElement.style.visibility = null; + } else { + tooltipElement.style.visibility = 'hidden'; + } + } + } + } + + public setInnerTooltipOffset(offset: number, center: number) { + this.innerTooltipOffset = offset + (center - this.box.cy) * this.editObject.scale; + } + private unscaled(size: number): number { - return size / this.editObject.scale; + return size / (this.editObject.scale * this.editObject.svgShape.zoom()); + } + + private scaled(size: number): number { + return size * (this.editObject.scale * this.editObject.svgShape.zoom()); } private createTagTooltip() { @@ -522,17 +681,26 @@ class IotSvgElement { el.tooltipster( { arrow: this.isGroup(), - distance: this.isGroup() ? 20 : 6, - theme: ['tooltipster-tb'], + distance: this.isGroup() ? (this.scaled(groupRectPadding) + groupRectStroke) : 6, + theme: ['iot-svg'], delay: 0, animationDuration: 0, interactive: true, trigger: 'custom', side: 'top', - trackOrigin: true, + trackOrigin: false, content: '', functionPosition: (instance, helper, position) => - this.innerTooltipPosition(instance, helper, position) + this.innerTagTooltipPosition(instance, helper, position), + functionReady: (instance, helper) => { + const tooltipEl = $(helper.tooltip); + tooltipEl.on('mouseenter', () => { + this.highlight(); + }); + tooltipEl.on('mouseleave', () => { + this.unhighlight(); + }); + } } ); this.tooltip = el.tooltipster('instance'); @@ -540,6 +708,8 @@ class IotSvgElement { } private setupTagPanel() { + this.isEditing = false; + this.unhighlight(); const tagPanel = $(`
${this.element.type}: @@ -547,12 +717,6 @@ class IotSvgElement { edit delete
`); - tagPanel.on('mouseenter', () => { - this.highlight(); - }); - tagPanel.on('mouseleave', () => { - this.unhighlight(); - }); const updateTagButton = tagPanel.find('.edit-icon'); textTooltip(updateTagButton, 'Update tag'); updateTagButton.on('click', () => { @@ -568,6 +732,7 @@ class IotSvgElement { } private setupEditTagPanel() { + this.isEditing = true; const editTagInputPanel = $(`
Update tag: @@ -621,17 +786,38 @@ class IotSvgElement { const el = $(this.element.node); el.tooltipster( { - arrow: this.isGroup(), - distance: this.isGroup() ? 20 : 6, - theme: ['tooltipster-tb'], - delay: 200, + arrow: true, + theme: ['iot-svg', 'tb-active'], + delay: [0, 300], interactive: true, trigger: 'hover', - side: 'top', - trackOrigin: true, + side: ['top', 'left', 'bottom', 'right'], + trackOrigin: false, content: '', + functionBefore: (instance, helper) => { + const mouseEvent = (helper.event as MouseEvent); + this.tooltipMouseX = mouseEvent.clientX; + this.tooltipMouseY = mouseEvent.clientY; + let side: TooltipPositioningSide; + if (this.isGroup()) { + side = 'top'; + } else { + side = this.calculateTooltipSide(helper.origin.getBoundingClientRect(), + mouseEvent.clientX, mouseEvent.clientY); + } + instance.option('side', side); + }, functionPosition: (instance, helper, position) => - this.innerTooltipPosition(instance, helper, position) + this.innerAddTagTooltipPosition(instance, helper, position), + functionReady: (instance, helper) => { + const tooltipEl = $(helper.tooltip); + tooltipEl.on('mouseenter', () => { + this.highlight(); + }); + tooltipEl.on('mouseleave', () => { + this.unhighlight(); + }); + } } ); this.tooltip = el.tooltipster('instance'); @@ -639,6 +825,7 @@ class IotSvgElement { } private setupAddTagPanel() { + this.isEditing = false; const addTagPanel = $(`
${this.element.type}: @@ -653,6 +840,7 @@ class IotSvgElement { } private setupAddTagInputPanel() { + this.isEditing = true; const addTagInputPanel = $(`
Enter tag: @@ -666,50 +854,141 @@ class IotSvgElement { textTooltip(applyTagButton, 'Apply'); textTooltip(closeButton, 'Cancel'); + let addPanelClosed = false; tagInput.on('keypress', (event) => { if (event.which === 13) { const newTag: string = tagInput.val() as string; if (newTag) { + addPanelClosed = true; this.setTag(newTag); } } }); applyTagButton.on('click', () => { const newTag: string = tagInput.val() as string; + addPanelClosed = true; if (newTag) { this.setTag(newTag); } else { + this.unhighlight(); this.tooltip.close(); } }); closeButton.on('click', () => { + addPanelClosed = true; + this.unhighlight(); this.tooltip.close(); }); + tagInput.on('blur', () => { + setTimeout(() => { + if (!addPanelClosed) { + addPanelClosed = true; + this.tooltip.close(); + } + }); + }); this.tooltip.content(addTagInputPanel); + this.tooltip.option('delay', [0, 10000000]); this.tooltip.on('closing', () => { + this.tooltip.option('delay', [0, 300]); this.setupAddTagPanel(); }); tagInput.trigger('focus'); } - private innerTooltipPosition(instance: ITooltipsterInstance, helper: ITooltipsterHelper, position: ITooltipPosition): ITooltipPosition { + private innerTagTooltipPosition(instance: ITooltipsterInstance, helper: ITooltipsterHelper, + position: ITooltipPosition): ITooltipPosition { + const clientRect = helper.origin.getBoundingClientRect(); if (!this.isGroup()) { - const clientRect = helper.origin.getBoundingClientRect(); position.coord.top = clientRect.top + (clientRect.height - position.size.height) / 2 - + this.innerTooltipOffset; + + this.innerTooltipOffset * this.editObject.svgShape.zoom(); position.coord.left = clientRect.left + (clientRect.width - position.size.width) / 2; + } else { + position.distance = this.scaled(groupRectPadding) + groupRectStroke; + position.coord.top = clientRect.top - position.size.height - (this.scaled(groupRectPadding) + groupRectStroke); + } + return position; + } + + private innerAddTagTooltipPosition(instance: ITooltipsterInstance, + helper: ITooltipsterHelper, position: ITooltipPosition): ITooltipPosition { + const distance = 10; + switch (position.side) { + case 'right': + position.coord.top = this.tooltipMouseY - position.size.height / 2; + position.coord.left = this.tooltipMouseX + distance; + position.target = this.tooltipMouseY; + break; + case 'top': + position.coord.top = this.tooltipMouseY - position.size.height - distance; + position.coord.left = this.tooltipMouseX - position.size.width / 2; + position.target = this.tooltipMouseX; + if (this.isGroup()) { + position.coord.top -= elementTooltipMinHeight; + } + break; + case 'left': + position.coord.top = this.tooltipMouseY - position.size.height / 2; + position.coord.left = this.tooltipMouseX - position.size.width - distance; + position.target = this.tooltipMouseY; + break; + case 'bottom': + position.coord.top = this.tooltipMouseY + distance; + position.coord.left = this.tooltipMouseX - position.size.width / 2; + position.target = this.tooltipMouseX; + break; } return position; } + private calculateTooltipSide(clientRect: DOMRect, mouseX: number, mouseY: number): TooltipPositioningSide { + let side: TooltipPositioningSide; + const cx = clientRect.left + clientRect.width / 2; + const cy = clientRect.top + clientRect.height / 2; + if (clientRect.width > clientRect.height) { + if (Math.abs(cx - mouseX) > clientRect.width / 4) { + if (mouseX < cx) { + side = 'left'; + } else { + side = 'right'; + } + } else { + if (mouseY < cy) { + side = 'top'; + } else { + side = 'bottom'; + } + } + } else { + if (Math.abs(cy - mouseY) > clientRect.height / 4) { + if (mouseY < cy) { + side = 'top'; + } else { + side = 'bottom'; + } + } else { + if (mouseX < cx) { + side = 'left'; + } else { + side = 'right'; + } + } + } + return side; + } + private hasTag() { return !!this.tag; } - private isGroup() { + public isGroup() { return this.element.type === 'g'; } + public isText() { + return this.element.type === 'text'; + } + } export class IotSvgObject { diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html index cd366abbde..0aa5a589f3 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html @@ -27,6 +27,7 @@
{{ metadata | json }} diff --git a/ui-ngx/src/styles.scss b/ui-ngx/src/styles.scss index 4d5ae09a15..0c59e74ee5 100644 --- a/ui-ngx/src/styles.scss +++ b/ui-ngx/src/styles.scss @@ -460,6 +460,149 @@ mat-icon { } } +.tooltipster-sidetip.iot-svg { + .tooltipster-box { + user-select: none; + background: rgba(0, 0, 0, 0.54); + border: 1px solid rgba(0, 0, 0, 0); + border-radius: 8px; + .tooltipster-content { + padding: 4px 8px; + font-size: 12px; + line-height: 12px; + font-weight: 500; + color: #ffffff; + } + } + &.tb-active { + .tooltipster-box { + background: #fff; + border: 1px solid rgba(0, 0, 0, 0.38); + box-shadow: 0 0 10px 6px rgba(0, 0, 0, .2); + .tooltipster-content { + color: #000; + } + } + .tooltipster-arrow { + .tooltipster-arrow-uncropped { + .tooltipster-arrow-background { + border-width: 12px; + } + } + } + &.tooltipster-top { + .tooltipster-arrow { + bottom: -1px; + .tooltipster-arrow-uncropped { + .tooltipster-arrow-border { + border-top-color: rgba(0, 0, 0, 0.38); + } + .tooltipster-arrow-background { + border-top-color: #fff; + left: -2px; + } + } + } + } + &.tooltipster-bottom { + .tooltipster-arrow { + top: -1px; + .tooltipster-arrow-uncropped { + .tooltipster-arrow-border { + border-bottom-color: rgba(0, 0, 0, 0.38); + } + .tooltipster-arrow-background { + border-bottom-color: #fff; + left: -2px; + top: -1px; + } + } + } + } + &.tooltipster-left { + .tooltipster-arrow { + right: -1px; + .tooltipster-arrow-uncropped { + .tooltipster-arrow-border { + border-left-color: rgba(0, 0, 0, 0.38); + } + .tooltipster-arrow-background { + border-left-color: #fff; + top: -2px; + } + } + } + } + &.tooltipster-right { + .tooltipster-arrow { + left: -1px; + .tooltipster-arrow-uncropped { + .tooltipster-arrow-border { + border-right-color: rgba(0, 0, 0, 0.38); + } + .tooltipster-arrow-background { + border-right-color: #fff; + top: -2px; + left: -1px; + } + } + } + } + } + &.tooltipster-top { + .tooltipster-arrow { + bottom: -2px; + .tooltipster-arrow-uncropped { + .tooltipster-arrow-border { + border-top-color: rgba(0, 0, 0, 0.54); + } + .tooltipster-arrow-background { + border-top-color: transparent; + } + } + } + } + &.tooltipster-bottom { + .tooltipster-arrow { + top: -2px; + .tooltipster-arrow-uncropped { + .tooltipster-arrow-border { + border-bottom-color: rgba(0, 0, 0, 0.54); + } + .tooltipster-arrow-background { + border-bottom-color: transparent; + } + } + } + } + &.tooltipster-left { + .tooltipster-arrow { + right: -2px; + .tooltipster-arrow-uncropped { + .tooltipster-arrow-border { + border-left-color: rgba(0, 0, 0, 0.54); + } + .tooltipster-arrow-background { + border-left-color: transparent; + } + } + } + } + &.tooltipster-right { + .tooltipster-arrow { + left: -2px; + .tooltipster-arrow-uncropped { + .tooltipster-arrow-border { + border-right-color: rgba(0, 0, 0, 0.54); + } + .tooltipster-arrow-background { + border-right-color: transparent; + } + } + } + } +} + .tb-default, .tb-dark { /********************************* diff --git a/ui-ngx/yarn.lock b/ui-ngx/yarn.lock index 68e9ea2523..0f46efe018 100644 --- a/ui-ngx/yarn.lock +++ b/ui-ngx/yarn.lock @@ -2695,11 +2695,18 @@ dependencies: "@svgdotjs/svg.js" "^3.1.1" -"@svgdotjs/svg.js@^3.1.1", "@svgdotjs/svg.js@^3.2.0": +"@svgdotjs/svg.js@^3.0.16", "@svgdotjs/svg.js@^3.1.1", "@svgdotjs/svg.js@^3.2.0": version "3.2.0" resolved "https://registry.yarnpkg.com/@svgdotjs/svg.js/-/svg.js-3.2.0.tgz#6baa8cef6778a93818ac18faa2055222e60aa644" integrity sha512-Tr8p+QVP7y+QT1GBlq1Tt57IvedVH8zCPoYxdHLX0Oof3a/PqnC/tXAkVufv1JQJfsDHlH/UrjcDfgxSofqSNA== +"@svgdotjs/svg.panzoom.js@^2.1.2": + version "2.1.2" + resolved "https://registry.yarnpkg.com/@svgdotjs/svg.panzoom.js/-/svg.panzoom.js-2.1.2.tgz#50e66a861f7c4f9e3992707f8e62e6e8da5c5223" + integrity sha512-0Nzo2TRlTebW3pzfAPtHx8Ye7Y3kuMEkK7hwVJi0SgQUB/vstjg7fvCJxB++EqsuDEetP0/SC+4CpLMVm6Lh2g== + dependencies: + "@svgdotjs/svg.js" "^3.0.16" + "@tinymce/tinymce-angular@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/@tinymce/tinymce-angular/-/tinymce-angular-7.0.0.tgz#010de497d5774a8bdc5d5936bf4fb976adf05f56" From 97adcde9d18667e9f9ec3eb40a47bd8c2f85253b Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Fri, 17 May 2024 19:31:35 +0300 Subject: [PATCH 034/442] UI: Edit SCADA symbol UI refactoring. --- .../widget/lib/svg/iot-svg.models.ts | 724 +--------------- .../scada-symbol-editor.component.html | 18 + .../scada-symbol-editor.component.scss | 22 + .../scada-symbol-editor.component.ts | 83 ++ .../scada-symbol-tooltip.component.scss | 52 ++ .../scada-symbol-tooltip.components.ts | 295 +++++++ .../scada-symbol/scada-symbol.component.html | 2 +- .../scada-symbol/scada-symbol.component.scss | 7 - .../scada-symbol/scada-symbol.component.ts | 29 +- .../pages/scada-symbol/scada-symbol.models.ts | 799 ++++++++++++++++++ .../pages/scada-symbol/scada-symbol.module.ts | 6 +- ui-ngx/src/styles.scss | 9 +- 12 files changed, 1287 insertions(+), 759 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.components.ts diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts index e128a26675..db649d8ad1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts @@ -15,8 +15,7 @@ /// import { ValueType } from '@shared/models/constants'; -import * as svgjs from '@svgdotjs/svg.js'; -import { Box, Element, Rect, Runner, SVG, Svg, Text, Timeline, Style } from '@svgdotjs/svg.js'; +import { Box, Element, Runner, SVG, Svg, Text } from '@svgdotjs/svg.js'; import '@svgdotjs/svg.panzoom.js'; import { DataToValueType, @@ -34,7 +33,7 @@ import { mergeDeep, parseFunction } from '@core/utils'; -import { BehaviorSubject, forkJoin, from, Observable, Observer } from 'rxjs'; +import { BehaviorSubject, forkJoin, Observable, Observer } from 'rxjs'; import { map, share } from 'rxjs/operators'; import { ValueAction, ValueGetter, ValueSetter } from '@home/components/widget/lib/action/action-widget.models'; import { WidgetContext } from '@home/models/widget-component.models'; @@ -42,11 +41,6 @@ import { ColorProcessor, constantColor, Font } from '@shared/models/widget-setti import { AttributeScope } from '@shared/models/telemetry/telemetry.models'; import { UtilsService } from '@core/services/utils.service'; import { WidgetAction, WidgetActionType } from '@shared/models/widget.models'; -import { ResizeObserver } from '@juggle/resize-observer'; -import ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance; -import ITooltipPosition = JQueryTooltipster.ITooltipPosition; -import ITooltipsterHelper = JQueryTooltipster.ITooltipsterHelper; -import TooltipPositioningSide = JQueryTooltipster.TooltipPositioningSide; export interface IotSvgApi { formatValue: (value: any, dec?: number, units?: string, showZeroDecimals?: boolean) => string | undefined; @@ -277,720 +271,6 @@ export type IotSvgObjectSettings = {[id: string]: any}; const parseError = (ctx: WidgetContext, err: any): string => ctx.$injector.get(UtilsService).parseException(err).message || 'Unknown Error'; -export class IotSvgEditObject { - - public svgShape: Svg; - private box: Box; - private elements: IotSvgElement[] = []; - private readonly shapeResize$: ResizeObserver; - private performSetup = false; - private hoverFilterStyle: Style; - public scale = 1; - constructor(private rootElement: HTMLElement) { - this.shapeResize$ = new ResizeObserver(() => { - this.resize(); - }); - } - - public setContent(svgContent: string) { - this.shapeResize$.unobserve(this.rootElement); - if (this.svgShape) { - this.elements.length = 0; - this.svgShape.remove(); - } - const doc: XMLDocument = new DOMParser().parseFromString(svgContent, 'image/svg+xml'); - this.svgShape = SVG().addTo(this.rootElement).svg(doc.documentElement.innerHTML); - this.svgShape.node.style.overflow = 'visible'; - this.svgShape.node.style['user-select'] = 'none'; - this.box = this.svgShape.bbox(); - this.svgShape.size(this.box.width, this.box.height); - this.svgShape.viewbox(`0 0 ${this.box.width} ${this.box.height}`); - this.svgShape.style().rule('.tb-element', {cursor: 'pointer', transition: '0.2s filter ease-in-out'}); - this.updateHoverFilterStyle(); - this.performSetup = true; - this.shapeResize$.observe(this.rootElement); - } - - private doSetup() { - this.setupZoomPan(0); - (window as any).SVG = svgjs; - forkJoin([ - from(import('tooltipster')), - from(import('tooltipster/dist/js/plugins/tooltipster/SVG/tooltipster-SVG.min.js')) - ]).subscribe(() => { - this.setupElements(); - }); - } - - private setupZoomPan(margin: number) { - this.svgShape.on('zoom', (e) => { - const { - detail: { level, focus } - } = e as any; - this.svgShape.zoom(level, focus); - const box = this.restrictToMargins(this.svgShape.viewbox(), margin); - this.svgShape.viewbox(box); - setTimeout(() => { - this.updateTooltipPositions(); - }); - e.preventDefault(); - }); - this.svgShape.on('panning', (e) => { - const box = (e as any).detail.box; - this.svgShape.viewbox(this.restrictToMargins(box, margin)); - setTimeout(() => { - this.updateTooltipPositions(); - }); - e.preventDefault(); - }); - this.svgShape.on('panStart', (e) => { - this.svgShape.node.style.cursor = 'grab'; - }); - this.svgShape.on('panEnd', (e) => { - this.svgShape.node.style.cursor = 'default'; - }); - } - - private restrictToMargins(box: Box, margin: number): Box { - if (box.x < -margin) { - box.x = -margin; - } else if ((box.x + box.width) > (this.box.width + margin)) { - box.x = this.box.width + margin - box.width; - } - if (box.y < -margin) { - box.y = -margin; - } else if ((box.y + box.height) > (this.box.height + margin)) { - box.y = this.box.height + margin - box.height; - } - return box; - } - - private setupElements() { - this.svgShape.children().forEach(child => { - this.addElement(child); - }); - const overlappingGroups: IotSvgElement[][] = []; - for (const el of this.elements) { - for (const other of this.elements) { - if (el !== other && el.overlappingCenters(other)) { - let overlappingGroup: IotSvgElement[]; - for (const list of overlappingGroups) { - if (list.includes(other) || list.includes(el)) { - overlappingGroup = list; - break; - } - } - if (!overlappingGroup) { - overlappingGroup = [el, other]; - overlappingGroups.push(overlappingGroup); - } else { - if (!overlappingGroup.includes(el)) { - overlappingGroup.push(el); - } else if (!overlappingGroup.includes(other)){ - overlappingGroup.push(other); - } - } - } - } - } - for (const group of overlappingGroups) { - const centers = group.map(e => e.box.cy); - const center = centers.reduce((a, b) => a + b, 0) / centers.length; - const textElement = group.find(e => e.isText()); - const slots = textElement ? group.length % 2 === 0 ? (group.length + 1) : group.length : group.length; - if (textElement) { - textElement.setInnerTooltipOffset(0, center); - group.splice(group.indexOf(textElement), 1); - } - let offset = - (elementTooltipMinHeight * slots) / 2 + elementTooltipMinHeight / 2; - for (const element of group) { - if (textElement && offset === 0) { - offset += elementTooltipMinHeight; - } - element.setInnerTooltipOffset(offset, center); - offset += elementTooltipMinHeight; - } - } - for (const el of this.elements) { - el.init(); - } - } - - private addElement(e: Element) { - if (hasBBox(e)) { - const iotSvgElement = new IotSvgElement(this, e); - this.elements.push(iotSvgElement); - e.children().forEach(child => { - if (!(child.type === 'tspan' && e.type === 'text')) { - this.addElement(child); - } - }, true); - } - } - - public destroy() { - if (this.shapeResize$) { - this.shapeResize$.disconnect(); - } - } - - private resize() { - if (this.svgShape) { - const targetWidth = this.rootElement.getBoundingClientRect().width; - const targetHeight = this.rootElement.getBoundingClientRect().height; - let scale: number; - if (targetWidth < targetHeight) { - scale = targetWidth / this.box.width; - } else { - scale = targetHeight / this.box.height; - } - if (this.scale !== scale) { - this.scale = scale; - this.svgShape.node.style.transform = `scale(${this.scale})`; - this.updateHoverFilterStyle(); - this.updateZoomOptions(); - this.updateTooltipPositions(); - } - if (this.performSetup) { - this.performSetup = false; - this.doSetup(); - } - } - } - - private updateHoverFilterStyle() { - if (this.hoverFilterStyle) { - this.hoverFilterStyle.remove(); - } - const whiteBlur = (2.8 / this.scale).toFixed(2); - const blackBlur = (1.2 / this.scale).toFixed(2); - this.hoverFilterStyle = - this.svgShape.style().rule('.hovered', - { - filter: - `drop-shadow(0px 0px ${whiteBlur}px white) drop-shadow(0px 0px ${whiteBlur}px white) - drop-shadow(0px 0px ${whiteBlur}px white) drop-shadow(0px 0px ${whiteBlur}px white) - drop-shadow(0px 0px ${blackBlur}px black)` - } - ); - } - - private updateZoomOptions() { - this.svgShape.panZoom({ - zoomMin: 1, - zoomMax: 4, - zoomFactor: 2 / this.scale - }); - } - - private updateTooltipPositions() { - const container = this.rootElement.getBoundingClientRect(); - for (const e of this.elements) { - e.updateTooltipPosition(container); - } - } - -} - -const hasBBox = (e: Element): boolean => { - try { - if (e.bbox) { - const box = e.bbox(); - return !!box.width || !!box.height; - } else { - return false; - } - } catch (_e) { - return false; - } -}; - -const textTooltip = (el: JQuery, text: string) => { - el.tooltipster({ - theme: ['iot-svg'], - trigger: 'hover', - content: text - }); -}; - -const isDomRectContained = (target: DOMRect, container: DOMRect, horizontalGap = 0, verticalGap = 0): boolean => ( - target.left >= container.left - horizontalGap && - target.left + target.width <= container.left + container.width + horizontalGap && - target.top >= container.top - verticalGap && - target.top + target.height <= container.top + container.height + verticalGap -); - -const elementTooltipMinHeight = 36 + 8; -const elementTooltipMinWidth = 100; - -const groupRectStroke = 2; -const groupRectPadding = 2; - -class IotSvgElement { - - private highlightRect: Rect; - private highlightRectTimeline: Timeline; - - private tooltip: ITooltipsterInstance; - - private tag: string; - - private isEditing = false; - - private innerTooltipOffset = 0; - - public readonly box: Box; - - private highlighted = false; - - private tooltipMouseX: number; - private tooltipMouseY: number; - - constructor(private editObject: IotSvgEditObject, - private element: Element) { - this.tag = element.attr('tb:tag'); - this.box = element.rbox(this.editObject.svgShape); - } - - public init() { - if (this.isGroup()) { - this.highlightRect = - this.editObject.svgShape - .rect(this.box.width + groupRectPadding * 2, this.box.height + groupRectPadding * 2) - .x(this.box.x - groupRectPadding) - .y(this.box.y - groupRectPadding) - .attr({ - fill: 'none', - rx: this.unscaled(6), - stroke: 'rgba(0, 0, 0, 0.38)', - 'stroke-dasharray': '1', - 'stroke-width': this.unscaled(groupRectStroke), - opacity: 0}); - this.highlightRectTimeline = new Timeline(); - this.highlightRect.timeline(this.highlightRectTimeline); - this.highlightRect.hide(); - } else { - this.element.addClass('tb-element'); - } - this.element.on('mouseenter', (event) => { - this.highlight(); - }); - this.element.on('mouseleave', (event) => { - this.unhighlight(); - }); - if (this.hasTag()) { - this.createTagTooltip(); - } else { - this.createAddTagTooltip(); - } - } - - public overlappingCenters(otherElement: IotSvgElement): boolean { - if (this.isGroup() || otherElement.isGroup()) { - return false; - } - return Math.abs(this.box.cx - otherElement.box.cx) * this.editObject.scale < elementTooltipMinWidth && - Math.abs(this.box.cy - otherElement.box.cy) * this.editObject.scale < elementTooltipMinHeight; - } - - public highlight() { - if (!this.highlighted) { - this.highlighted = true; - if (this.isGroup()) { - this.highlightRectTimeline.finish(); - this.highlightRect - .attr({ - rx: this.unscaled(6), - 'stroke-width': this.unscaled(groupRectStroke) - }); - this.highlightRect.show(); - this.highlightRect.animate(300).attr({opacity: 1}); - } else { - this.element.addClass('hovered'); - } - if (this.hasTag()) { - if (!this.isEditing) { - this.tooltip.reposition(); - } - $(this.tooltip.elementTooltip()).addClass('tb-active'); - } - } - } - - public unhighlight() { - if (this.highlighted) { - this.highlighted = false; - if (this.isGroup()) { - this.highlightRectTimeline.finish(); - this.highlightRect.animate(300).attr({opacity: 0}).after(() => { - this.highlightRect.hide(); - }); - } else { - this.element.removeClass('hovered'); - } - if (this.hasTag()) { - $(this.tooltip.elementTooltip()).removeClass('tb-active'); - } - } - } - - public clearTag() { - this.tooltip.destroy(); - this.tag = null; - this.element.attr('tb:tag', null); - this.unhighlight(); - this.createAddTagTooltip(); - } - - public setTag(tag: string) { - this.tooltip.destroy(); - this.tag = tag; - this.element.attr('tb:tag', tag); - this.createTagTooltip(); - } - - public updateTooltipPosition(container: DOMRect) { - if (this.tooltip && !this.tooltip.status().destroyed) { - this.tooltip.reposition(); - const tooltipElement = this.tooltip.elementTooltip(); - if (tooltipElement) { - if (isDomRectContained(tooltipElement.getBoundingClientRect(), container, - elementTooltipMinWidth, elementTooltipMinHeight)) { - tooltipElement.style.visibility = null; - } else { - tooltipElement.style.visibility = 'hidden'; - } - } - } - } - - public setInnerTooltipOffset(offset: number, center: number) { - this.innerTooltipOffset = offset + (center - this.box.cy) * this.editObject.scale; - } - - private unscaled(size: number): number { - return size / (this.editObject.scale * this.editObject.svgShape.zoom()); - } - - private scaled(size: number): number { - return size * (this.editObject.scale * this.editObject.svgShape.zoom()); - } - - private createTagTooltip() { - const el = $(this.element.node); - el.tooltipster( - { - arrow: this.isGroup(), - distance: this.isGroup() ? (this.scaled(groupRectPadding) + groupRectStroke) : 6, - theme: ['iot-svg'], - delay: 0, - animationDuration: 0, - interactive: true, - trigger: 'custom', - side: 'top', - trackOrigin: false, - content: '', - functionPosition: (instance, helper, position) => - this.innerTagTooltipPosition(instance, helper, position), - functionReady: (instance, helper) => { - const tooltipEl = $(helper.tooltip); - tooltipEl.on('mouseenter', () => { - this.highlight(); - }); - tooltipEl.on('mouseleave', () => { - this.unhighlight(); - }); - } - } - ); - this.tooltip = el.tooltipster('instance'); - this.setupTagPanel(); - } - - private setupTagPanel() { - this.isEditing = false; - this.unhighlight(); - const tagPanel = - $(`
- ${this.element.type}: - ${this.tag} - edit - delete -
`); - const updateTagButton = tagPanel.find('.edit-icon'); - textTooltip(updateTagButton, 'Update tag'); - updateTagButton.on('click', () => { - this.setupEditTagPanel(); - }); - const deleteButton = tagPanel.find('.delete-icon'); - textTooltip(deleteButton, 'Remove tag'); - deleteButton.on('click', () => { - this.clearTag(); - }); - this.tooltip.content(tagPanel); - this.tooltip.open(); - } - - private setupEditTagPanel() { - this.isEditing = true; - const editTagInputPanel = - $(`
- Update tag: - - done - close -
`); - const tagInput = editTagInputPanel.find('input.tag-input'); - const applyTagButton = editTagInputPanel.find('span.apply-icon'); - const closeButton = editTagInputPanel.find('span.close-icon'); - textTooltip(applyTagButton, 'Apply'); - textTooltip(closeButton, 'Cancel'); - tagInput.val(this.tag); - let editPanelClosed = false; - - tagInput.on('keypress', (event) => { - if (event.which === 13) { - const newTag: string = tagInput.val() as string; - if (newTag) { - editPanelClosed = true; - this.setTag(newTag); - } - } - }); - applyTagButton.on('click', () => { - const newTag: string = tagInput.val() as string; - editPanelClosed = true; - if (newTag) { - this.setTag(newTag); - } else { - this.setupTagPanel(); - } - }); - closeButton.on('click', () => { - editPanelClosed = true; - this.setupTagPanel(); - }); - tagInput.on('blur', () => { - setTimeout(() => { - if (!editPanelClosed) { - editPanelClosed = true; - this.setupTagPanel(); - } - }); - }); - this.tooltip.content(editTagInputPanel); - tagInput.trigger('focus'); - } - - private createAddTagTooltip() { - const el = $(this.element.node); - el.tooltipster( - { - arrow: true, - theme: ['iot-svg', 'tb-active'], - delay: [0, 300], - interactive: true, - trigger: 'hover', - side: ['top', 'left', 'bottom', 'right'], - trackOrigin: false, - content: '', - functionBefore: (instance, helper) => { - const mouseEvent = (helper.event as MouseEvent); - this.tooltipMouseX = mouseEvent.clientX; - this.tooltipMouseY = mouseEvent.clientY; - let side: TooltipPositioningSide; - if (this.isGroup()) { - side = 'top'; - } else { - side = this.calculateTooltipSide(helper.origin.getBoundingClientRect(), - mouseEvent.clientX, mouseEvent.clientY); - } - instance.option('side', side); - }, - functionPosition: (instance, helper, position) => - this.innerAddTagTooltipPosition(instance, helper, position), - functionReady: (instance, helper) => { - const tooltipEl = $(helper.tooltip); - tooltipEl.on('mouseenter', () => { - this.highlight(); - }); - tooltipEl.on('mouseleave', () => { - this.unhighlight(); - }); - } - } - ); - this.tooltip = el.tooltipster('instance'); - this.setupAddTagPanel(); - } - - private setupAddTagPanel() { - this.isEditing = false; - const addTagPanel = - $(`
- ${this.element.type}: - -
`); - const addTagButton = addTagPanel.find('.add-tag-button'); - addTagButton.on('click', () => { - this.setupAddTagInputPanel(); - }); - this.tooltip.content(addTagPanel); - this.tooltip.off('closing'); - } - - private setupAddTagInputPanel() { - this.isEditing = true; - const addTagInputPanel = - $(`
- Enter tag: - - done - close -
`); - const tagInput = addTagInputPanel.find('input.tag-input'); - const applyTagButton = addTagInputPanel.find('span.apply-icon'); - const closeButton = addTagInputPanel.find('span.close-icon'); - textTooltip(applyTagButton, 'Apply'); - textTooltip(closeButton, 'Cancel'); - - let addPanelClosed = false; - tagInput.on('keypress', (event) => { - if (event.which === 13) { - const newTag: string = tagInput.val() as string; - if (newTag) { - addPanelClosed = true; - this.setTag(newTag); - } - } - }); - applyTagButton.on('click', () => { - const newTag: string = tagInput.val() as string; - addPanelClosed = true; - if (newTag) { - this.setTag(newTag); - } else { - this.unhighlight(); - this.tooltip.close(); - } - }); - closeButton.on('click', () => { - addPanelClosed = true; - this.unhighlight(); - this.tooltip.close(); - }); - tagInput.on('blur', () => { - setTimeout(() => { - if (!addPanelClosed) { - addPanelClosed = true; - this.tooltip.close(); - } - }); - }); - this.tooltip.content(addTagInputPanel); - this.tooltip.option('delay', [0, 10000000]); - this.tooltip.on('closing', () => { - this.tooltip.option('delay', [0, 300]); - this.setupAddTagPanel(); - }); - tagInput.trigger('focus'); - } - - private innerTagTooltipPosition(instance: ITooltipsterInstance, helper: ITooltipsterHelper, - position: ITooltipPosition): ITooltipPosition { - const clientRect = helper.origin.getBoundingClientRect(); - if (!this.isGroup()) { - position.coord.top = clientRect.top + (clientRect.height - position.size.height) / 2 - + this.innerTooltipOffset * this.editObject.svgShape.zoom(); - position.coord.left = clientRect.left + (clientRect.width - position.size.width) / 2; - } else { - position.distance = this.scaled(groupRectPadding) + groupRectStroke; - position.coord.top = clientRect.top - position.size.height - (this.scaled(groupRectPadding) + groupRectStroke); - } - return position; - } - - private innerAddTagTooltipPosition(instance: ITooltipsterInstance, - helper: ITooltipsterHelper, position: ITooltipPosition): ITooltipPosition { - const distance = 10; - switch (position.side) { - case 'right': - position.coord.top = this.tooltipMouseY - position.size.height / 2; - position.coord.left = this.tooltipMouseX + distance; - position.target = this.tooltipMouseY; - break; - case 'top': - position.coord.top = this.tooltipMouseY - position.size.height - distance; - position.coord.left = this.tooltipMouseX - position.size.width / 2; - position.target = this.tooltipMouseX; - if (this.isGroup()) { - position.coord.top -= elementTooltipMinHeight; - } - break; - case 'left': - position.coord.top = this.tooltipMouseY - position.size.height / 2; - position.coord.left = this.tooltipMouseX - position.size.width - distance; - position.target = this.tooltipMouseY; - break; - case 'bottom': - position.coord.top = this.tooltipMouseY + distance; - position.coord.left = this.tooltipMouseX - position.size.width / 2; - position.target = this.tooltipMouseX; - break; - } - return position; - } - - private calculateTooltipSide(clientRect: DOMRect, mouseX: number, mouseY: number): TooltipPositioningSide { - let side: TooltipPositioningSide; - const cx = clientRect.left + clientRect.width / 2; - const cy = clientRect.top + clientRect.height / 2; - if (clientRect.width > clientRect.height) { - if (Math.abs(cx - mouseX) > clientRect.width / 4) { - if (mouseX < cx) { - side = 'left'; - } else { - side = 'right'; - } - } else { - if (mouseY < cy) { - side = 'top'; - } else { - side = 'bottom'; - } - } - } else { - if (Math.abs(cy - mouseY) > clientRect.height / 4) { - if (mouseY < cy) { - side = 'top'; - } else { - side = 'bottom'; - } - } else { - if (mouseX < cx) { - side = 'left'; - } else { - side = 'right'; - } - } - } - return side; - } - - private hasTag() { - return !!this.tag; - } - - public isGroup() { - return this.element.type === 'g'; - } - - public isText() { - return this.element.type === 'text'; - } - -} - export class IotSvgObject { private metadata: IotSvgMetadata; diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.html new file mode 100644 index 0000000000..96e1018eec --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.html @@ -0,0 +1,18 @@ + +
diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.scss b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.scss new file mode 100644 index 0000000000..7bad327b9a --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.scss @@ -0,0 +1,22 @@ +/** + * Copyright ยฉ 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +.tb-scada-symbol-shape { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.ts new file mode 100644 index 0000000000..6f73ae7ac4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.ts @@ -0,0 +1,83 @@ +/// +/// Copyright ยฉ 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + AfterViewInit, + Component, + ElementRef, + Input, + OnChanges, + OnDestroy, + OnInit, + SimpleChanges, + ViewChild, + ViewContainerRef, + ViewEncapsulation +} from '@angular/core'; +import { Subject } from 'rxjs'; +import { ScadaSymbolEditObject } from '@home/pages/scada-symbol/scada-symbol.models'; + +@Component({ + selector: 'tb-scada-symbol-editor', + templateUrl: './scada-symbol-editor.component.html', + styleUrls: ['./scada-symbol-editor.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class ScadaSymbolEditorComponent implements OnInit, OnDestroy, AfterViewInit, OnChanges { + + @ViewChild('iotSvgShape', {static: false}) + iotSvgShape: ElementRef; + + @Input() + svgContent: string; + + scadaSymbolEditObject: ScadaSymbolEditObject; + + private destroy$ = new Subject(); + + constructor(private viewContainerRef: ViewContainerRef) { + } + + ngOnInit(): void { + } + + ngAfterViewInit() { + this.scadaSymbolEditObject = new ScadaSymbolEditObject(this.iotSvgShape.nativeElement, + this.viewContainerRef); + if (this.svgContent) { + this.scadaSymbolEditObject.setContent(this.svgContent); + } + } + + ngOnChanges(changes: SimpleChanges): void { + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (!change.firstChange && change.currentValue !== change.previousValue) { + if (propName === 'svgContent') { + if (this.scadaSymbolEditObject) { + this.scadaSymbolEditObject.setContent(this.svgContent); + } + } + } + } + } + + ngOnDestroy() { + this.scadaSymbolEditObject.destroy(); + } + +} + diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.component.scss b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.component.scss new file mode 100644 index 0000000000..c7fc243dc0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.component.scss @@ -0,0 +1,52 @@ +/** + * Copyright ยฉ 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +.tb-scada-symbol-tooltip-panel { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + + .mdc-button { + line-height: 20px; + font-size: 12px; + &.mat-mdc-button-base { + height: 20px; + .mat-mdc-button-touch-target { + height: 20px; + } + } + } + + .mat-mdc-form-field.tb-inline-field { + font-size: 12px; + line-height: 12px; + .mat-mdc-text-field-wrapper { + padding: 0 8px; + .mat-mdc-form-field-flex { + .mat-mdc-form-field-infix { + padding-top: 4px; + padding-bottom: 4px; + min-height: 20px; + .mat-mdc-input-element { + font-size: 12px; + line-height: 12px; + height: 12px; + } + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.components.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.components.ts new file mode 100644 index 0000000000..cf38552f7a --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.components.ts @@ -0,0 +1,295 @@ +/// +/// Copyright ยฉ 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + AfterViewInit, + Component, + ComponentRef, + Directive, + ElementRef, + EventEmitter, + Input, + NgModule, + Output, + Type, + ViewChild, + ViewContainerRef, + ViewEncapsulation +} from '@angular/core'; +import { ScadaSymbolElement } from '@home/pages/scada-symbol/scada-symbol.models'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { ENTER } from '@angular/cdk/keycodes'; + +@Directive() +abstract class ScadaSymbolPanelComponent implements AfterViewInit { + + @Input() + symbolElement: ScadaSymbolElement; + + @Output() + viewInited = new EventEmitter(); + + protected constructor(public element: ElementRef) { + } + + ngAfterViewInit() { + this.viewInited.emit(); + } +} + +@Component({ + template: `
+ {{ symbolElement?.element?.type }}: + +
`, + styleUrls: ['./scada-symbol-tooltip.component.scss'], + encapsulation: ViewEncapsulation.None +}) +class ScadaSymbolAddTagPanelComponent extends ScadaSymbolPanelComponent { + + @Output() + addTag = new EventEmitter(); + + constructor(public element: ElementRef) { + super(element); + } + + public onAddTag() { + this.addTag.emit(); + } + +} + +@Component({ + template: `
+ {{ isAdd ? 'Enter tag:' : 'Update tag:' }} + + + + + +
`, + styleUrls: ['./scada-symbol-tooltip.component.scss'], + encapsulation: ViewEncapsulation.None +}) +class ScadaSymbolTagInputPanelComponent extends ScadaSymbolPanelComponent implements AfterViewInit { + + @ViewChild('tagField') + tagField: ElementRef; + + @Input() + isAdd: boolean; + + @Input() + tag: string; + + @Output() + apply = new EventEmitter(); + + @Output() + cancel = new EventEmitter(); + + private closed = false; + + constructor(public element: ElementRef) { + super(element); + } + + ngAfterViewInit() { + super.ngAfterViewInit(); + setTimeout(() => { + this.tagField.nativeElement.focus(); + }); + } + + public tagEnter($event: KeyboardEvent) { + if ($event.keyCode === ENTER) { + $event.preventDefault(); + if (this.tag) { + this.closed = true; + this.apply.emit(this.tag); + } + } + } + + public onApply() { + this.closed = true; + if (this.tag) { + this.apply.emit(this.tag); + } else { + this.cancel.emit(); + } + } + + public onCancel() { + this.closed = true; + this.cancel.emit(); + } + + public onBlur() { + setTimeout(() => { + if (!this.closed) { + this.closed = true; + this.cancel.emit(); + } + }, 300); + } + +} + +@Component({ + template: `
+ {{ symbolElement?.element?.type }}: + {{ symbolElement?.tag }} + + +
`, + styleUrls: ['./scada-symbol-tooltip.component.scss'], + encapsulation: ViewEncapsulation.None +}) +class ScadaSymbolTagPanelComponent extends ScadaSymbolPanelComponent implements AfterViewInit { + + @Output() + updateTag = new EventEmitter(); + + @Output() + removeTag = new EventEmitter(); + + constructor(public element: ElementRef) { + super(element); + } + + public onUpdateTag() { + this.updateTag.emit(); + } + + public onRemoveTag() { + this.removeTag.emit(); + } +} + +@NgModule({ + declarations: + [ + ScadaSymbolAddTagPanelComponent, + ScadaSymbolTagInputPanelComponent, + ScadaSymbolTagPanelComponent + ], + imports: [ + CommonModule, + SharedModule + ] +}) +export class ScadaSymbolTooltipComponentsModule { } + +export const setupAddTagPanelTooltip = (symbolElement: ScadaSymbolElement, container: ViewContainerRef) => { + symbolElement.setEditing(false); + symbolElement.tooltip.off('close'); + const componentRef = setTooltipComponent(symbolElement, container, ScadaSymbolAddTagPanelComponent); + componentRef.instance.addTag.subscribe(() => { + componentRef.destroy(); + setupTagInputPanelTooltip(symbolElement, container, true); + }); +}; + +export const setupTagPanelTooltip = (symbolElement: ScadaSymbolElement, container: ViewContainerRef) => { + symbolElement.setEditing(false); + symbolElement.unhighlight(); + const componentRef = setTooltipComponent(symbolElement, container, ScadaSymbolTagPanelComponent); + componentRef.instance.updateTag.subscribe(() => { + componentRef.destroy(); + setupTagInputPanelTooltip(symbolElement, container, false); + }); + componentRef.instance.removeTag.subscribe(() => { + componentRef.destroy(); + symbolElement.clearTag(); + }); + symbolElement.tooltip.open(); +}; + +const setupTagInputPanelTooltip = (symbolElement: ScadaSymbolElement, container: ViewContainerRef, isAdd: boolean) => { + symbolElement.setEditing(true); + const componentRef = setTooltipComponent(symbolElement, container, ScadaSymbolTagInputPanelComponent); + + componentRef.instance.isAdd = isAdd; + if (!isAdd) { + componentRef.instance.tag = symbolElement.tag; + } + componentRef.instance.apply.subscribe((newTag) => { + componentRef.destroy(); + if (isAdd) { + symbolElement.tooltip.off('close'); + } + symbolElement.setTag(newTag); + }); + componentRef.instance.cancel.subscribe(() => { + if (isAdd) { + symbolElement.unhighlight(); + symbolElement.tooltip.close(); + } else { + componentRef.destroy(); + setupTagPanelTooltip(symbolElement, container); + } + }); + if (isAdd) { + symbolElement.tooltip.option('delay', [0, 10000000]); + symbolElement.tooltip.on('close', () => { + componentRef.destroy(); + symbolElement.tooltip.option('delay', [0, 300]); + setupAddTagPanelTooltip(symbolElement, container); + }); + } +}; + +const setTooltipComponent = (symbolElement: ScadaSymbolElement, + container: ViewContainerRef, componentType: Type): ComponentRef => { + const componentRef = container.createComponent(componentType); + componentRef.instance.symbolElement = symbolElement; + componentRef.instance.viewInited.subscribe(() => { + if (symbolElement.tooltip.status().open) { + symbolElement.repositionTooltip(); + } + }); + const parentElement = componentRef.instance.element.nativeElement; + const content = parentElement.firstChild; + parentElement.removeChild(content); + parentElement.style.display = 'none'; + symbolElement.tooltip.content(content); + return componentRef; +}; diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html index 0aa5a589f3..dc4e620f94 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html @@ -35,7 +35,7 @@ -
+
diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.scss b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.scss index 783e2d842b..1f3208cc91 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.scss +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.scss @@ -24,12 +24,5 @@ min-width: 0; min-height: 0; background: #fff; - .tb-scada-symbol-shape { - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - } } } diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.ts index d75ac137e6..0d521d0a4f 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.ts +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.ts @@ -14,16 +14,7 @@ /// limitations under the License. /// -import { - AfterViewInit, - Component, - ElementRef, - HostBinding, - OnDestroy, - OnInit, - ViewChild, - ViewEncapsulation -} from '@angular/core'; +import { AfterViewInit, Component, HostBinding, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; import { PageComponent } from '@shared/components/page.component'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @@ -31,11 +22,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { takeUntil } from 'rxjs/operators'; import { Subject } from 'rxjs'; import { ScadaSymbolData } from '@home/pages/scada-symbol/scada-symbol.models'; -import { - IotSvgEditObject, - IotSvgMetadata, - parseIotSvgMetadataFromContent -} from '@home/components/widget/lib/svg/iot-svg.models'; +import { IotSvgMetadata, parseIotSvgMetadataFromContent } from '@home/components/widget/lib/svg/iot-svg.models'; @Component({ selector: 'tb-scada-symbol', @@ -48,12 +35,7 @@ export class ScadaSymbolComponent extends PageComponent implements OnInit, OnDes @HostBinding('style.width') width = '100%'; @HostBinding('style.height') height = '100%'; - @ViewChild('iotSvgShape', {static: false}) - iotSvgShape: ElementRef; - symbolData: ScadaSymbolData; - - iotSvgObjectEdit: IotSvgEditObject; metadata: IotSvgMetadata; private destroy$ = new Subject(); @@ -76,10 +58,6 @@ export class ScadaSymbolComponent extends PageComponent implements OnInit, OnDes } ngAfterViewInit() { - this.iotSvgObjectEdit = new IotSvgEditObject(this.iotSvgShape.nativeElement); - if (this.symbolData) { - this.iotSvgObjectEdit.setContent(this.symbolData.svgContent); - } } ngOnDestroy() { @@ -94,9 +72,6 @@ export class ScadaSymbolComponent extends PageComponent implements OnInit, OnDes private init() { this.symbolData = this.route.snapshot.data.symbolData; this.metadata = parseIotSvgMetadataFromContent(this.symbolData.svgContent); - if (this.iotSvgObjectEdit) { - this.iotSvgObjectEdit.setContent(this.symbolData.svgContent); - } } } diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.models.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.models.ts index 32b05f2265..d8ee8c1a64 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.models.ts +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.models.ts @@ -15,8 +15,807 @@ /// import { ImageResourceInfo } from '@shared/models/resource.models'; +import * as svgjs from '@svgdotjs/svg.js'; +import { Box, Element, Rect, Style, SVG, Svg, Timeline } from '@svgdotjs/svg.js'; +import { ResizeObserver } from '@juggle/resize-observer'; +import { DynamicComponentFactoryService } from '@core/services/dynamic-component-factory.service'; +import { Directive, ElementRef, OnDestroy, Type, ViewContainerRef } from '@angular/core'; +import { forkJoin, from } from 'rxjs'; +import { TbInject } from '@shared/decorators/tb-inject'; +import { + setupAddTagPanelTooltip, + setupTagPanelTooltip +} from '@home/pages/scada-symbol/scada-symbol-tooltip.components'; +import ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance; +import TooltipPositioningSide = JQueryTooltipster.TooltipPositioningSide; +import ITooltipsterHelper = JQueryTooltipster.ITooltipsterHelper; +import ITooltipPosition = JQueryTooltipster.ITooltipPosition; export interface ScadaSymbolData { imageResource: ImageResourceInfo; svgContent: string; } + +export class ScadaSymbolEditObject { + + public svgShape: Svg; + private box: Box; + private elements: ScadaSymbolElement[] = []; + private readonly shapeResize$: ResizeObserver; + private performSetup = false; + private hoverFilterStyle: Style; + public scale = 1; + constructor(private rootElement: HTMLElement, + public viewContainerRef: ViewContainerRef) { + this.shapeResize$ = new ResizeObserver(() => { + this.resize(); + }); + } + + public setContent(svgContent: string) { + this.shapeResize$.unobserve(this.rootElement); + if (this.svgShape) { + this.elements.length = 0; + this.svgShape.remove(); + } + const doc: XMLDocument = new DOMParser().parseFromString(svgContent, 'image/svg+xml'); + this.svgShape = SVG().addTo(this.rootElement).svg(doc.documentElement.innerHTML); + this.svgShape.node.style.overflow = 'visible'; + this.svgShape.node.style['user-select'] = 'none'; + this.box = this.svgShape.bbox(); + this.svgShape.size(this.box.width, this.box.height); + this.svgShape.viewbox(`0 0 ${this.box.width} ${this.box.height}`); + this.svgShape.style().rule('.tb-element', {cursor: 'pointer', transition: '0.2s filter ease-in-out'}); + this.updateHoverFilterStyle(); + this.performSetup = true; + this.shapeResize$.observe(this.rootElement); + } + + public editingElement() { + return this.elements.find(e => e.isEditing()); + } + + private doSetup() { + this.setupZoomPan(0); + (window as any).SVG = svgjs; + forkJoin([ + from(import('tooltipster')), + from(import('tooltipster/dist/js/plugins/tooltipster/SVG/tooltipster-SVG.min.js')) + ]).subscribe(() => { + this.setupElements(); + }); + } + + private setupZoomPan(margin: number) { + this.svgShape.on('zoom', (e) => { + const { + detail: { level, focus } + } = e as any; + this.svgShape.zoom(level, focus); + const box = this.restrictToMargins(this.svgShape.viewbox(), margin); + this.svgShape.viewbox(box); + setTimeout(() => { + this.updateTooltipPositions(); + }); + e.preventDefault(); + }); + this.svgShape.on('panning', (e) => { + const box = (e as any).detail.box; + this.svgShape.viewbox(this.restrictToMargins(box, margin)); + setTimeout(() => { + this.updateTooltipPositions(); + }); + e.preventDefault(); + }); + this.svgShape.on('panStart', (e) => { + this.svgShape.node.style.cursor = 'grab'; + }); + this.svgShape.on('panEnd', (e) => { + this.svgShape.node.style.cursor = 'default'; + }); + } + + private restrictToMargins(box: Box, margin: number): Box { + if (box.x < -margin) { + box.x = -margin; + } else if ((box.x + box.width) > (this.box.width + margin)) { + box.x = this.box.width + margin - box.width; + } + if (box.y < -margin) { + box.y = -margin; + } else if ((box.y + box.height) > (this.box.height + margin)) { + box.y = this.box.height + margin - box.height; + } + return box; + } + + private setupElements() { + this.svgShape.children().forEach(child => { + this.addElement(child); + }); + const overlappingGroups: ScadaSymbolElement[][] = []; + for (const el of this.elements) { + for (const other of this.elements) { + if (el !== other && el.overlappingCenters(other)) { + let overlappingGroup: ScadaSymbolElement[]; + for (const list of overlappingGroups) { + if (list.includes(other) || list.includes(el)) { + overlappingGroup = list; + break; + } + } + if (!overlappingGroup) { + overlappingGroup = [el, other]; + overlappingGroups.push(overlappingGroup); + } else { + if (!overlappingGroup.includes(el)) { + overlappingGroup.push(el); + } else if (!overlappingGroup.includes(other)){ + overlappingGroup.push(other); + } + } + } + } + } + for (const group of overlappingGroups) { + const centers = group.map(e => e.box.cy); + const center = centers.reduce((a, b) => a + b, 0) / centers.length; + const textElement = group.find(e => e.isText()); + const slots = textElement ? group.length % 2 === 0 ? (group.length + 1) : group.length : group.length; + if (textElement) { + textElement.setInnerTooltipOffset(0, center); + group.splice(group.indexOf(textElement), 1); + } + let offset = - (elementTooltipMinHeight * slots) / 2 + elementTooltipMinHeight / 2; + for (const element of group) { + if (textElement && offset === 0) { + offset += elementTooltipMinHeight; + } + element.setInnerTooltipOffset(offset, center); + offset += elementTooltipMinHeight; + } + } + for (const el of this.elements) { + el.init(); + } + } + + private addElement(e: Element) { + if (hasBBox(e)) { + const scadaSymbolElement = new ScadaSymbolElement(this, e); + this.elements.push(scadaSymbolElement); + e.children().forEach(child => { + if (!(child.type === 'tspan' && e.type === 'text')) { + this.addElement(child); + } + }, true); + } + } + + public destroy() { + if (this.shapeResize$) { + this.shapeResize$.disconnect(); + } + } + + private resize() { + if (this.svgShape) { + const targetWidth = this.rootElement.getBoundingClientRect().width; + const targetHeight = this.rootElement.getBoundingClientRect().height; + let scale: number; + if (targetWidth < targetHeight) { + scale = targetWidth / this.box.width; + } else { + scale = targetHeight / this.box.height; + } + if (this.scale !== scale) { + this.scale = scale; + this.svgShape.node.style.transform = `scale(${this.scale})`; + this.updateHoverFilterStyle(); + this.updateZoomOptions(); + this.updateTooltipPositions(); + } + if (this.performSetup) { + this.performSetup = false; + this.doSetup(); + } + } + } + + private updateHoverFilterStyle() { + if (this.hoverFilterStyle) { + this.hoverFilterStyle.remove(); + } + const whiteBlur = (2.8 / this.scale).toFixed(2); + const blackBlur = (1.2 / this.scale).toFixed(2); + this.hoverFilterStyle = + this.svgShape.style().rule('.hovered', + { + filter: + `drop-shadow(0px 0px ${whiteBlur}px white) drop-shadow(0px 0px ${whiteBlur}px white) + drop-shadow(0px 0px ${whiteBlur}px white) drop-shadow(0px 0px ${whiteBlur}px white) + drop-shadow(0px 0px ${blackBlur}px black)` + } + ); + } + + private updateZoomOptions() { + this.svgShape.panZoom({ + zoomMin: 1, + zoomMax: 4, + zoomFactor: 2 / this.scale + }); + } + + private updateTooltipPositions() { + const container = this.rootElement.getBoundingClientRect(); + for (const e of this.elements) { + e.updateTooltipPosition(container); + } + } + +} + +const hasBBox = (e: Element): boolean => { + try { + if (e.bbox) { + const box = e.bbox(); + return !!box.width || !!box.height; + } else { + return false; + } + } catch (_e) { + return false; + } +}; + +const textTooltip = (el: JQuery, text: string) => { + el.tooltipster({ + theme: ['iot-svg'], + trigger: 'hover', + content: text + }); +}; + +const isDomRectContained = (target: DOMRect, container: DOMRect, horizontalGap = 0, verticalGap = 0): boolean => ( + target.left >= container.left - horizontalGap && + target.left + target.width <= container.left + container.width + horizontalGap && + target.top >= container.top - verticalGap && + target.top + target.height <= container.top + container.height + verticalGap +); + +const elementTooltipMinHeight = 36 + 8; +const elementTooltipMinWidth = 100; + +const groupRectStroke = 2; +const groupRectPadding = 2; + +export class ScadaSymbolElement { + + private highlightRect: Rect; + private highlightRectTimeline: Timeline; + + public tooltip: ITooltipsterInstance; + + public tag: string; + + private editing = false; + + private innerTooltipOffset = 0; + + public readonly box: Box; + + private highlighted = false; + + private tooltipMouseX: number; + private tooltipMouseY: number; + + constructor(private editObject: ScadaSymbolEditObject, + public element: Element) { + this.tag = element.attr('tb:tag'); + this.box = element.rbox(this.editObject.svgShape); + } + + public init() { + if (this.isGroup()) { + this.highlightRect = + this.editObject.svgShape + .rect(this.box.width + groupRectPadding * 2, this.box.height + groupRectPadding * 2) + .x(this.box.x - groupRectPadding) + .y(this.box.y - groupRectPadding) + .attr({ + fill: 'none', + rx: this.unscaled(6), + stroke: 'rgba(0, 0, 0, 0.38)', + 'stroke-dasharray': '1', + 'stroke-width': this.unscaled(groupRectStroke), + opacity: 0}); + this.highlightRectTimeline = new Timeline(); + this.highlightRect.timeline(this.highlightRectTimeline); + this.highlightRect.hide(); + } else { + this.element.addClass('tb-element'); + } + this.element.on('mouseenter', (event) => { + this.highlight(); + }); + this.element.on('mouseleave', (event) => { + this.unhighlight(); + }); + if (this.hasTag()) { + this.createTagTooltip(); + } else { + this.createAddTagTooltip(); + } + } + + public overlappingCenters(otherElement: ScadaSymbolElement): boolean { + if (this.isGroup() || otherElement.isGroup()) { + return false; + } + return Math.abs(this.box.cx - otherElement.box.cx) * this.editObject.scale < elementTooltipMinWidth && + Math.abs(this.box.cy - otherElement.box.cy) * this.editObject.scale < elementTooltipMinHeight; + } + + public highlight() { + if (!this.highlighted) { + this.highlighted = true; + if (this.isGroup()) { + this.highlightRectTimeline.finish(); + this.highlightRect + .attr({ + rx: this.unscaled(6), + 'stroke-width': this.unscaled(groupRectStroke) + }); + this.highlightRect.show(); + this.highlightRect.animate(300).attr({opacity: 1}); + } else { + this.element.addClass('hovered'); + } + if (this.hasTag()) { + this.repositionTooltip(); + $(this.tooltip.elementTooltip()).addClass('tb-active'); + } + } + } + + public unhighlight() { + if (this.highlighted) { + this.highlighted = false; + if (this.isGroup()) { + this.highlightRectTimeline.finish(); + this.highlightRect.animate(300).attr({opacity: 0}).after(() => { + this.highlightRect.hide(); + }); + } else { + this.element.removeClass('hovered'); + } + if (this.hasTag() && !this.editing) { + $(this.tooltip.elementTooltip()).removeClass('tb-active'); + } + } + } + + public repositionTooltip() { + const editingElement = this.editObject.editingElement(); + if (!editingElement || editingElement === this) { + this.tooltip.reposition(); + } + } + + public clearTag() { + this.tooltip.destroy(); + this.tag = null; + this.element.attr('tb:tag', null); + this.unhighlight(); + this.createAddTagTooltip(); + } + + public setTag(tag: string) { + this.tooltip.destroy(); + this.tag = tag; + this.element.attr('tb:tag', tag); + this.createTagTooltip(); + } + + public setEditing(editing: boolean) { + this.editing = editing; + if (this.hasTag() && !this.editing && !this.highlighted) { + $(this.tooltip.elementTooltip()).removeClass('tb-active'); + } + } + + public isEditing() { + return this.editing; + } + + public updateTooltipPosition(container: DOMRect) { + if (this.tooltip && !this.tooltip.status().destroyed) { + this.tooltip.reposition(); + const tooltipElement = this.tooltip.elementTooltip(); + if (tooltipElement) { + if (isDomRectContained(tooltipElement.getBoundingClientRect(), container, + elementTooltipMinWidth, elementTooltipMinHeight)) { + tooltipElement.style.visibility = null; + } else { + tooltipElement.style.visibility = 'hidden'; + } + } + } + } + + public setInnerTooltipOffset(offset: number, center: number) { + this.innerTooltipOffset = offset + (center - this.box.cy) * this.editObject.scale; + } + + private unscaled(size: number): number { + return size / (this.editObject.scale * this.editObject.svgShape.zoom()); + } + + private scaled(size: number): number { + return size * (this.editObject.scale * this.editObject.svgShape.zoom()); + } + + private createTagTooltip() { + const el = $(this.element.node); + el.tooltipster( + { + zIndex: 100, + arrow: this.isGroup(), + distance: this.isGroup() ? (this.scaled(groupRectPadding) + groupRectStroke) : 6, + theme: ['iot-svg'], + delay: 0, + animationDuration: 0, + interactive: true, + trigger: 'custom', + side: 'top', + trackOrigin: false, + content: '', + functionPosition: (instance, helper, position) => + this.innerTagTooltipPosition(instance, helper, position), + functionReady: (instance, helper) => { + const tooltipEl = $(helper.tooltip); + tooltipEl.on('mouseenter', () => { + this.highlight(); + }); + tooltipEl.on('mouseleave', () => { + this.unhighlight(); + }); + } + } + ); + this.tooltip = el.tooltipster('instance'); + this.setupTagPanel(); + } + + private setupTagPanel() { + setupTagPanelTooltip(this, this.editObject.viewContainerRef); + /*this.isEditing = false; + this.unhighlight(); + const tagPanel = + $(`
+ ${this.element.type}: + ${this.tag} + edit + delete +
`); + const updateTagButton = tagPanel.find('.edit-icon'); + textTooltip(updateTagButton, 'Update tag'); + updateTagButton.on('click', () => { + this.setupEditTagPanel(); + }); + const deleteButton = tagPanel.find('.delete-icon'); + textTooltip(deleteButton, 'Remove tag'); + deleteButton.on('click', () => { + this.clearTag(); + }); + this.tooltip.content(tagPanel); + this.tooltip.open();*/ + } + + private setupEditTagPanel() { + this.editing = true; + const editTagInputPanel = + $(`
+ Update tag: + + done + close +
`); + const tagInput = editTagInputPanel.find('input.tag-input'); + const applyTagButton = editTagInputPanel.find('span.apply-icon'); + const closeButton = editTagInputPanel.find('span.close-icon'); + textTooltip(applyTagButton, 'Apply'); + textTooltip(closeButton, 'Cancel'); + tagInput.val(this.tag); + let editPanelClosed = false; + + tagInput.on('keypress', (event) => { + if (event.which === 13) { + const newTag: string = tagInput.val() as string; + if (newTag) { + editPanelClosed = true; + this.setTag(newTag); + } + } + }); + applyTagButton.on('click', () => { + const newTag: string = tagInput.val() as string; + editPanelClosed = true; + if (newTag) { + this.setTag(newTag); + } else { + this.setupTagPanel(); + } + }); + closeButton.on('click', () => { + editPanelClosed = true; + this.setupTagPanel(); + }); + tagInput.on('blur', () => { + setTimeout(() => { + if (!editPanelClosed) { + editPanelClosed = true; + this.setupTagPanel(); + } + }); + }); + this.tooltip.content(editTagInputPanel); + tagInput.trigger('focus'); + } + + private createAddTagTooltip() { + const el = $(this.element.node); + el.tooltipster( + { + zIndex: 100, + arrow: true, + theme: ['iot-svg', 'tb-active'], + delay: [0, 300], + interactive: true, + trigger: 'hover', + side: ['top', 'left', 'bottom', 'right'], + trackOrigin: false, + content: '', + functionBefore: (instance, helper) => { + const mouseEvent = (helper.event as MouseEvent); + this.tooltipMouseX = mouseEvent.clientX; + this.tooltipMouseY = mouseEvent.clientY; + let side: TooltipPositioningSide; + if (this.isGroup()) { + side = 'top'; + } else { + side = this.calculateTooltipSide(helper.origin.getBoundingClientRect(), + mouseEvent.clientX, mouseEvent.clientY); + } + instance.option('side', side); + }, + functionPosition: (instance, helper, position) => + this.innerAddTagTooltipPosition(instance, helper, position), + functionReady: (instance, helper) => { + const tooltipEl = $(helper.tooltip); + tooltipEl.on('mouseenter', () => { + this.highlight(); + }); + tooltipEl.on('mouseleave', () => { + this.unhighlight(); + }); + } + } + ); + this.tooltip = el.tooltipster('instance'); + this.setupAddTagPanel(); + } + + private setupAddTagPanel() { + setupAddTagPanelTooltip(this, this.editObject.viewContainerRef); + /*this.isEditing = false; + this.tooltip.off('close'); + this.editObject.dynamicComponentFactoryService.createDynamicComponent( + class TbTooltipComponentInstance extends TbTooltipContent {}, + `
+ ${this.element.type}: + +
`, + [this.editObject.sharedModule] + ).subscribe(componentData => { + const tooltipComponentRef = + this.editObject.viewContainerRef.createComponent(componentData.componentType, + {index: 0, ngModuleRef: componentData.componentModuleRef}); + tooltipComponentRef.instance.componentType = componentData.componentType; + tooltipComponentRef.instance.dynamicComponentFactoryService = this.editObject.dynamicComponentFactoryService; + tooltipComponentRef.instance.addTag = new EventEmitter(); + tooltipComponentRef.instance.addTag.subscribe(() => { + tooltipComponentRef.destroy(); + this.setupAddTagInputPanel(); + }); + const parentElement = tooltipComponentRef.instance.element.nativeElement; + const content = parentElement.firstChild; + parentElement.removeChild(content); + parentElement.style.display = 'none'; + this.tooltip.content(content); + });*/ + /* + const addTagPanel = + $(`
+ ${this.element.type}: + +
`); + const addTagButton = addTagPanel.find('.add-tag-button'); + addTagButton.on('click', () => { + this.setupAddTagInputPanel(); + }); + this.tooltip.content(addTagPanel); + */ + // this.tooltip.off('closing'); + } + + private setupAddTagInputPanel() { + this.editing = true; + const addTagInputPanel = + $(`
+ Enter tag: + + done + close +
`); + const tagInput = addTagInputPanel.find('input.tag-input'); + const applyTagButton = addTagInputPanel.find('span.apply-icon'); + const closeButton = addTagInputPanel.find('span.close-icon'); + textTooltip(applyTagButton, 'Apply'); + textTooltip(closeButton, 'Cancel'); + + let addPanelClosed = false; + tagInput.on('keypress', (event) => { + if (event.which === 13) { + const newTag: string = tagInput.val() as string; + if (newTag) { + addPanelClosed = true; + this.tooltip.off('close'); + this.setTag(newTag); + } + } + }); + applyTagButton.on('click', () => { + const newTag: string = tagInput.val() as string; + addPanelClosed = true; + if (newTag) { + this.tooltip.off('close'); + this.setTag(newTag); + } else { + this.unhighlight(); + this.tooltip.close(); + } + }); + closeButton.on('click', () => { + addPanelClosed = true; + this.unhighlight(); + this.tooltip.close(); + }); + tagInput.on('blur', () => { + setTimeout(() => { + if (!addPanelClosed) { + addPanelClosed = true; + this.tooltip.close(); + } + }); + }); + this.tooltip.content(addTagInputPanel); + this.tooltip.option('delay', [0, 10000000]); + this.tooltip.on('close', () => { + this.tooltip.option('delay', [0, 300]); + this.setupAddTagPanel(); + }); + tagInput.trigger('focus'); + } + + private innerTagTooltipPosition(instance: ITooltipsterInstance, helper: ITooltipsterHelper, + position: ITooltipPosition): ITooltipPosition { + const clientRect = helper.origin.getBoundingClientRect(); + if (!this.isGroup()) { + position.coord.top = clientRect.top + (clientRect.height - position.size.height) / 2 + + this.innerTooltipOffset * this.editObject.svgShape.zoom(); + position.coord.left = clientRect.left + (clientRect.width - position.size.width) / 2; + } else { + position.distance = this.scaled(groupRectPadding) + groupRectStroke; + position.coord.top = clientRect.top - position.size.height - (this.scaled(groupRectPadding) + groupRectStroke); + } + return position; + } + + private innerAddTagTooltipPosition(instance: ITooltipsterInstance, + helper: ITooltipsterHelper, position: ITooltipPosition): ITooltipPosition { + const distance = 10; + switch (position.side) { + case 'right': + position.coord.top = this.tooltipMouseY - position.size.height / 2; + position.coord.left = this.tooltipMouseX + distance; + position.target = this.tooltipMouseY; + break; + case 'top': + position.coord.top = this.tooltipMouseY - position.size.height - distance; + position.coord.left = this.tooltipMouseX - position.size.width / 2; + position.target = this.tooltipMouseX; + if (this.isGroup()) { + position.coord.top -= elementTooltipMinHeight; + } + break; + case 'left': + position.coord.top = this.tooltipMouseY - position.size.height / 2; + position.coord.left = this.tooltipMouseX - position.size.width - distance; + position.target = this.tooltipMouseY; + break; + case 'bottom': + position.coord.top = this.tooltipMouseY + distance; + position.coord.left = this.tooltipMouseX - position.size.width / 2; + position.target = this.tooltipMouseX; + break; + } + return position; + } + + private calculateTooltipSide(clientRect: DOMRect, mouseX: number, mouseY: number): TooltipPositioningSide { + let side: TooltipPositioningSide; + const cx = clientRect.left + clientRect.width / 2; + const cy = clientRect.top + clientRect.height / 2; + if (clientRect.width > clientRect.height) { + if (Math.abs(cx - mouseX) > clientRect.width / 4) { + if (mouseX < cx) { + side = 'left'; + } else { + side = 'right'; + } + } else { + if (mouseY < cy) { + side = 'top'; + } else { + side = 'bottom'; + } + } + } else { + if (Math.abs(cy - mouseY) > clientRect.height / 4) { + if (mouseY < cy) { + side = 'top'; + } else { + side = 'bottom'; + } + } else { + if (mouseX < cx) { + side = 'left'; + } else { + side = 'right'; + } + } + } + return side; + } + + private hasTag() { + return !!this.tag; + } + + public isGroup() { + return this.element.type === 'g'; + } + + public isText() { + return this.element.type === 'text'; + } + +} + +@Directive() +class TbTooltipContent implements OnDestroy { + + componentType: Type; + dynamicComponentFactoryService: DynamicComponentFactoryService; + + [key: string]: any; + constructor(@TbInject(ElementRef) public element: ElementRef) { + } + + ngOnDestroy(): void { + this.dynamicComponentFactoryService.destroyDynamicComponent(this.componentType); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.module.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.module.ts index 2ed9a0bb4c..9366321392 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.module.ts +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.module.ts @@ -19,16 +19,20 @@ import { CommonModule } from '@angular/common'; import { SharedModule } from '@app/shared/shared.module'; import { HomeComponentsModule } from '@modules/home/components/home-components.module'; import { ScadaSymbolComponent } from '@home/pages/scada-symbol/scada-symbol.component'; +import { ScadaSymbolEditorComponent } from '@home/pages/scada-symbol/scada-symbol-editor.component'; +import { ScadaSymbolTooltipComponentsModule } from '@home/pages/scada-symbol/scada-symbol-tooltip.components'; @NgModule({ declarations: [ + ScadaSymbolEditorComponent, ScadaSymbolComponent ], imports: [ CommonModule, SharedModule, - HomeComponentsModule + HomeComponentsModule, + ScadaSymbolTooltipComponentsModule ] }) export class ScadaSymbolModule { } diff --git a/ui-ngx/src/styles.scss b/ui-ngx/src/styles.scss index 0c59e74ee5..f741fa5a31 100644 --- a/ui-ngx/src/styles.scss +++ b/ui-ngx/src/styles.scss @@ -466,6 +466,8 @@ mat-icon { background: rgba(0, 0, 0, 0.54); border: 1px solid rgba(0, 0, 0, 0); border-radius: 8px; + display: flex; + align-items: center; .tooltipster-content { padding: 4px 8px; font-size: 12px; @@ -480,7 +482,7 @@ mat-icon { border: 1px solid rgba(0, 0, 0, 0.38); box-shadow: 0 0 10px 6px rgba(0, 0, 0, .2); .tooltipster-content { - color: #000; + color: rgba(0, 0, 0, 0.76); } } .tooltipster-arrow { @@ -549,6 +551,11 @@ mat-icon { } } } + &.tooltipster-left, &.tooltipster-right { + .tooltipster-box { + min-height: 36px; + } + } &.tooltipster-top { .tooltipster-arrow { bottom: -2px; From 64f858a73b7008b518e56627e99b059d178c9244 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Fri, 17 May 2024 20:25:51 +0300 Subject: [PATCH 035/442] UI: Improve element tags editing. --- .../scada-symbol-tooltip.components.ts | 42 ++-- .../pages/scada-symbol/scada-symbol.models.ts | 207 ++---------------- 2 files changed, 50 insertions(+), 199 deletions(-) diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.components.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.components.ts index cf38552f7a..df9e299cda 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.components.ts +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.components.ts @@ -22,7 +22,7 @@ import { ElementRef, EventEmitter, Input, - NgModule, + NgModule, OnDestroy, Output, Type, ViewChild, @@ -33,6 +33,7 @@ import { ScadaSymbolElement } from '@home/pages/scada-symbol/scada-symbol.models import { CommonModule } from '@angular/common'; import { SharedModule } from '@shared/shared.module'; import { ENTER } from '@angular/cdk/keycodes'; +import Timeout = NodeJS.Timeout; @Directive() abstract class ScadaSymbolPanelComponent implements AfterViewInit { @@ -98,7 +99,7 @@ class ScadaSymbolAddTagPanelComponent extends ScadaSymbolPanelComponent { styleUrls: ['./scada-symbol-tooltip.component.scss'], encapsulation: ViewEncapsulation.None }) -class ScadaSymbolTagInputPanelComponent extends ScadaSymbolPanelComponent implements AfterViewInit { +class ScadaSymbolTagInputPanelComponent extends ScadaSymbolPanelComponent implements AfterViewInit, OnDestroy { @ViewChild('tagField') tagField: ElementRef; @@ -117,6 +118,8 @@ class ScadaSymbolTagInputPanelComponent extends ScadaSymbolPanelComponent implem private closed = false; + private blurTimeout: Timeout; + constructor(public element: ElementRef) { super(element); } @@ -128,6 +131,13 @@ class ScadaSymbolTagInputPanelComponent extends ScadaSymbolPanelComponent implem }); } + ngOnDestroy() { + if (this.blurTimeout) { + clearTimeout(this.blurTimeout); + this.blurTimeout = null; + } + } + public tagEnter($event: KeyboardEvent) { if ($event.keyCode === ENTER) { $event.preventDefault(); @@ -153,7 +163,7 @@ class ScadaSymbolTagInputPanelComponent extends ScadaSymbolPanelComponent implem } public onBlur() { - setTimeout(() => { + this.blurTimeout = setTimeout(() => { if (!this.closed) { this.closed = true; this.cancel.emit(); @@ -219,7 +229,7 @@ class ScadaSymbolTagPanelComponent extends ScadaSymbolPanelComponent implements export class ScadaSymbolTooltipComponentsModule { } export const setupAddTagPanelTooltip = (symbolElement: ScadaSymbolElement, container: ViewContainerRef) => { - symbolElement.setEditing(false); + symbolElement.stopEdit(); symbolElement.tooltip.off('close'); const componentRef = setTooltipComponent(symbolElement, container, ScadaSymbolAddTagPanelComponent); componentRef.instance.addTag.subscribe(() => { @@ -229,7 +239,7 @@ export const setupAddTagPanelTooltip = (symbolElement: ScadaSymbolElement, conta }; export const setupTagPanelTooltip = (symbolElement: ScadaSymbolElement, container: ViewContainerRef) => { - symbolElement.setEditing(false); + symbolElement.stopEdit(); symbolElement.unhighlight(); const componentRef = setTooltipComponent(symbolElement, container, ScadaSymbolTagPanelComponent); componentRef.instance.updateTag.subscribe(() => { @@ -244,7 +254,17 @@ export const setupTagPanelTooltip = (symbolElement: ScadaSymbolElement, containe }; const setupTagInputPanelTooltip = (symbolElement: ScadaSymbolElement, container: ViewContainerRef, isAdd: boolean) => { - symbolElement.setEditing(true); + + symbolElement.startEdit(() => { + if (isAdd) { + symbolElement.unhighlight(); + symbolElement.tooltip.close(); + } else { + componentRef.destroy(); + setupTagPanelTooltip(symbolElement, container); + } + }); + const componentRef = setTooltipComponent(symbolElement, container, ScadaSymbolTagInputPanelComponent); componentRef.instance.isAdd = isAdd; @@ -259,13 +279,7 @@ const setupTagInputPanelTooltip = (symbolElement: ScadaSymbolElement, container: symbolElement.setTag(newTag); }); componentRef.instance.cancel.subscribe(() => { - if (isAdd) { - symbolElement.unhighlight(); - symbolElement.tooltip.close(); - } else { - componentRef.destroy(); - setupTagPanelTooltip(symbolElement, container); - } + symbolElement.stopEdit(true); }); if (isAdd) { symbolElement.tooltip.option('delay', [0, 10000000]); @@ -283,7 +297,7 @@ const setTooltipComponent = (symbolElement: componentRef.instance.symbolElement = symbolElement; componentRef.instance.viewInited.subscribe(() => { if (symbolElement.tooltip.status().open) { - symbolElement.repositionTooltip(); + symbolElement.tooltip.reposition(); } }); const parentElement = componentRef.instance.element.nativeElement; diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.models.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.models.ts index d8ee8c1a64..fe843f8cf9 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.models.ts +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.models.ts @@ -71,8 +71,8 @@ export class ScadaSymbolEditObject { this.shapeResize$.observe(this.rootElement); } - public editingElement() { - return this.elements.find(e => e.isEditing()); + public cancelEdit() { + this.elements.filter(e => e.isEditing()).forEach(e => e.stopEdit(true)); } private doSetup() { @@ -300,6 +300,7 @@ export class ScadaSymbolElement { public tag: string; private editing = false; + private onCancelEdit: () => void; private innerTooltipOffset = 0; @@ -373,7 +374,7 @@ export class ScadaSymbolElement { this.element.addClass('hovered'); } if (this.hasTag()) { - this.repositionTooltip(); + this.tooltip.reposition(); $(this.tooltip.elementTooltip()).addClass('tb-active'); } } @@ -396,13 +397,6 @@ export class ScadaSymbolElement { } } - public repositionTooltip() { - const editingElement = this.editObject.editingElement(); - if (!editingElement || editingElement === this) { - this.tooltip.reposition(); - } - } - public clearTag() { this.tooltip.destroy(); this.tag = null; @@ -418,10 +412,24 @@ export class ScadaSymbolElement { this.createTagTooltip(); } - public setEditing(editing: boolean) { - this.editing = editing; - if (this.hasTag() && !this.editing && !this.highlighted) { - $(this.tooltip.elementTooltip()).removeClass('tb-active'); + public startEdit(onCancelEdit: () => void) { + if (!this.editing) { + this.editObject.cancelEdit(); + this.editing = true; + this.onCancelEdit = onCancelEdit; + } + } + + public stopEdit(cancel = false) { + if (this.editing) { + this.editing = false; + if (cancel && this.onCancelEdit) { + this.onCancelEdit(); + } + this.onCancelEdit = null; + if (this.hasTag() && !this.highlighted) { + $(this.tooltip.elementTooltip()).removeClass('tb-active'); + } } } @@ -490,78 +498,6 @@ export class ScadaSymbolElement { private setupTagPanel() { setupTagPanelTooltip(this, this.editObject.viewContainerRef); - /*this.isEditing = false; - this.unhighlight(); - const tagPanel = - $(`
- ${this.element.type}: - ${this.tag} - edit - delete -
`); - const updateTagButton = tagPanel.find('.edit-icon'); - textTooltip(updateTagButton, 'Update tag'); - updateTagButton.on('click', () => { - this.setupEditTagPanel(); - }); - const deleteButton = tagPanel.find('.delete-icon'); - textTooltip(deleteButton, 'Remove tag'); - deleteButton.on('click', () => { - this.clearTag(); - }); - this.tooltip.content(tagPanel); - this.tooltip.open();*/ - } - - private setupEditTagPanel() { - this.editing = true; - const editTagInputPanel = - $(`
- Update tag: - - done - close -
`); - const tagInput = editTagInputPanel.find('input.tag-input'); - const applyTagButton = editTagInputPanel.find('span.apply-icon'); - const closeButton = editTagInputPanel.find('span.close-icon'); - textTooltip(applyTagButton, 'Apply'); - textTooltip(closeButton, 'Cancel'); - tagInput.val(this.tag); - let editPanelClosed = false; - - tagInput.on('keypress', (event) => { - if (event.which === 13) { - const newTag: string = tagInput.val() as string; - if (newTag) { - editPanelClosed = true; - this.setTag(newTag); - } - } - }); - applyTagButton.on('click', () => { - const newTag: string = tagInput.val() as string; - editPanelClosed = true; - if (newTag) { - this.setTag(newTag); - } else { - this.setupTagPanel(); - } - }); - closeButton.on('click', () => { - editPanelClosed = true; - this.setupTagPanel(); - }); - tagInput.on('blur', () => { - setTimeout(() => { - if (!editPanelClosed) { - editPanelClosed = true; - this.setupTagPanel(); - } - }); - }); - this.tooltip.content(editTagInputPanel); - tagInput.trigger('focus'); } private createAddTagTooltip() { @@ -609,105 +545,6 @@ export class ScadaSymbolElement { private setupAddTagPanel() { setupAddTagPanelTooltip(this, this.editObject.viewContainerRef); - /*this.isEditing = false; - this.tooltip.off('close'); - this.editObject.dynamicComponentFactoryService.createDynamicComponent( - class TbTooltipComponentInstance extends TbTooltipContent {}, - `
- ${this.element.type}: - -
`, - [this.editObject.sharedModule] - ).subscribe(componentData => { - const tooltipComponentRef = - this.editObject.viewContainerRef.createComponent(componentData.componentType, - {index: 0, ngModuleRef: componentData.componentModuleRef}); - tooltipComponentRef.instance.componentType = componentData.componentType; - tooltipComponentRef.instance.dynamicComponentFactoryService = this.editObject.dynamicComponentFactoryService; - tooltipComponentRef.instance.addTag = new EventEmitter(); - tooltipComponentRef.instance.addTag.subscribe(() => { - tooltipComponentRef.destroy(); - this.setupAddTagInputPanel(); - }); - const parentElement = tooltipComponentRef.instance.element.nativeElement; - const content = parentElement.firstChild; - parentElement.removeChild(content); - parentElement.style.display = 'none'; - this.tooltip.content(content); - });*/ - /* - const addTagPanel = - $(`
- ${this.element.type}: - -
`); - const addTagButton = addTagPanel.find('.add-tag-button'); - addTagButton.on('click', () => { - this.setupAddTagInputPanel(); - }); - this.tooltip.content(addTagPanel); - */ - // this.tooltip.off('closing'); - } - - private setupAddTagInputPanel() { - this.editing = true; - const addTagInputPanel = - $(`
- Enter tag: - - done - close -
`); - const tagInput = addTagInputPanel.find('input.tag-input'); - const applyTagButton = addTagInputPanel.find('span.apply-icon'); - const closeButton = addTagInputPanel.find('span.close-icon'); - textTooltip(applyTagButton, 'Apply'); - textTooltip(closeButton, 'Cancel'); - - let addPanelClosed = false; - tagInput.on('keypress', (event) => { - if (event.which === 13) { - const newTag: string = tagInput.val() as string; - if (newTag) { - addPanelClosed = true; - this.tooltip.off('close'); - this.setTag(newTag); - } - } - }); - applyTagButton.on('click', () => { - const newTag: string = tagInput.val() as string; - addPanelClosed = true; - if (newTag) { - this.tooltip.off('close'); - this.setTag(newTag); - } else { - this.unhighlight(); - this.tooltip.close(); - } - }); - closeButton.on('click', () => { - addPanelClosed = true; - this.unhighlight(); - this.tooltip.close(); - }); - tagInput.on('blur', () => { - setTimeout(() => { - if (!addPanelClosed) { - addPanelClosed = true; - this.tooltip.close(); - } - }); - }); - this.tooltip.content(addTagInputPanel); - this.tooltip.option('delay', [0, 10000000]); - this.tooltip.on('close', () => { - this.tooltip.option('delay', [0, 300]); - this.setupAddTagPanel(); - }); - tagInput.trigger('focus'); } private innerTagTooltipPosition(instance: ITooltipsterInstance, helper: ITooltipsterHelper, From 9dc76ec308fd08f935de0a73b320b3e66ba28600 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Mon, 20 May 2024 18:08:25 +0300 Subject: [PATCH 036/442] UI: Scada symbol editor --- .../widget/lib/svg/iot-svg.models.ts | 40 ++++++ .../scada-symbol-editor.component.ts | 27 ++-- .../scada-symbol-metadata.component.html | 55 ++++++++ .../scada-symbol-metadata.component.scss | 65 +++++++++ .../scada-symbol-metadata.component.ts | 129 ++++++++++++++++++ .../scada-symbol-tooltip.component.scss | 10 ++ .../scada-symbol-tooltip.components.ts | 123 +++++++++++++++-- .../scada-symbol/scada-symbol.component.html | 47 ++++++- .../scada-symbol/scada-symbol.component.ts | 67 +++++++-- .../pages/scada-symbol/scada-symbol.models.ts | 63 +++++++-- .../pages/scada-symbol/scada-symbol.module.ts | 4 +- .../assets/locale/locale.constant-en_US.json | 9 +- 12 files changed, 593 insertions(+), 46 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-metadata.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-metadata.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-metadata.component.ts diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts index db649d8ad1..7277ea0751 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts @@ -195,6 +195,46 @@ const updateIotSvgMetadataInDom = (svgDoc: Document, metadata: IotSvgMetadata) = metadataElement.appendChild(cdata); }; +const svgPartsRegex = /()(.*)<\/svg>/gms; + +const svgMetadataRegex = /.*<\/tb:metadata>/gs; + +export const svgRootNodePart = (svgContent: string): string => { + let result = svgContent; + svgPartsRegex.lastIndex = 0; + const match = svgPartsRegex.exec(svgContent); + if (match != null && match.length > 1) { + result = match[1]; + } + return result; +}; + +export const innerSvgContent = (svgContent: string): string => { + let result = svgContent; + svgPartsRegex.lastIndex = 0; + const match = svgPartsRegex.exec(svgContent); + if (match != null && match.length > 2) { + result = match[2]; + } + return result; +}; + +export const stripSvgMetadata = (svgContent: string) => { + let result = svgContent; + svgMetadataRegex.lastIndex = 0; + const match = svgMetadataRegex.exec(svgContent); + if (match !== null && match.length) { + const metadata = match[0]; + result = result.replace(metadata, ''); + } + return result; +}; + +export const innerSvgContentFromSvgjs = (svgContent: string): string => { + const result = innerSvgContent(svgContent); + return result.replace(/svgjs:.*?=".*?"/gs, ''); +}; + const defaultGetValueSettings = (get: IotSvgBehaviorValue): GetValueSettings => ({ action: GetValueAction.DO_NOTHING, defaultValue: get.defaultValue, diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.ts index 6f73ae7ac4..833f96ea67 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.ts +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.ts @@ -27,9 +27,12 @@ import { ViewContainerRef, ViewEncapsulation } from '@angular/core'; -import { Subject } from 'rxjs'; import { ScadaSymbolEditObject } from '@home/pages/scada-symbol/scada-symbol.models'; +export interface ScadaSymbolEditorData { + svgContent: string; +} + @Component({ selector: 'tb-scada-symbol-editor', templateUrl: './scada-symbol-editor.component.html', @@ -42,12 +45,10 @@ export class ScadaSymbolEditorComponent implements OnInit, OnDestroy, AfterViewI iotSvgShape: ElementRef; @Input() - svgContent: string; + data: ScadaSymbolEditorData; scadaSymbolEditObject: ScadaSymbolEditObject; - private destroy$ = new Subject(); - constructor(private viewContainerRef: ViewContainerRef) { } @@ -57,8 +58,8 @@ export class ScadaSymbolEditorComponent implements OnInit, OnDestroy, AfterViewI ngAfterViewInit() { this.scadaSymbolEditObject = new ScadaSymbolEditObject(this.iotSvgShape.nativeElement, this.viewContainerRef); - if (this.svgContent) { - this.scadaSymbolEditObject.setContent(this.svgContent); + if (this.data) { + this.scadaSymbolEditObject.setContent(this.data.svgContent); } } @@ -66,9 +67,11 @@ export class ScadaSymbolEditorComponent implements OnInit, OnDestroy, AfterViewI for (const propName of Object.keys(changes)) { const change = changes[propName]; if (!change.firstChange && change.currentValue !== change.previousValue) { - if (propName === 'svgContent') { + if (propName === 'data') { if (this.scadaSymbolEditObject) { - this.scadaSymbolEditObject.setContent(this.svgContent); + setTimeout(() => { + this.scadaSymbolEditObject.setContent(this.data.svgContent); + }); } } } @@ -79,5 +82,13 @@ export class ScadaSymbolEditorComponent implements OnInit, OnDestroy, AfterViewI this.scadaSymbolEditObject.destroy(); } + get dirty(): boolean { + return this.scadaSymbolEditObject?.dirty; + } + + getContent(): string { + return this.scadaSymbolEditObject?.getContent(); + } + } diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-metadata.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-metadata.component.html new file mode 100644 index 0000000000..f4770bd823 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-metadata.component.html @@ -0,0 +1,55 @@ + + diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-metadata.component.scss b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-metadata.component.scss new file mode 100644 index 0000000000..48cc8471b8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-metadata.component.scss @@ -0,0 +1,65 @@ +/** + * Copyright ยฉ 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../../../../../scss/constants'; + +.tb-scada-symbol-metadata { + display: flex; + flex-direction: column; + gap: 8px; + .tb-scada-symbol-metadata-header { + padding: 24px 24px 0; + display: flex; + gap: 12px; + flex-direction: column-reverse; + align-items: flex-start; + @media #{$mat-gt-sm} { + gap: 0; + flex-direction: row; + align-items: center; + justify-content: space-between; + } + .tb-scada-symbol-metadata-header-components { + width: 100%; + flex: 1; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + } + } + .tb-scada-symbol-metadata-content { + & > .mat-content { + padding-top: 8px; + @media #{$mat-xs} { + padding-left: 8px; + padding-right: 8px; + } + } + flex: 1; + overflow: auto; + & > div { + padding: 16px; + } + .mat-content { + display: flex; + flex-direction: column; + gap: 16px; + @media #{$mat-xs} { + gap: 8px; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-metadata.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-metadata.component.ts new file mode 100644 index 0000000000..56f0a2d16b --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-metadata.component.ts @@ -0,0 +1,129 @@ +/// +/// Copyright ยฉ 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, forwardRef, Input, OnInit } from '@angular/core'; +import { + ControlValueAccessor, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormGroup, + Validators +} from '@angular/forms'; +import { PageComponent } from '@shared/components/page.component'; +import { IotSvgMetadata } from '@home/components/widget/lib/svg/iot-svg.models'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { WidgetConfigMode, widgetType } from '@shared/models/widget.models'; +import { ToggleHeaderOption } from '@shared/components/toggle-header.component'; +import { TranslateService } from '@ngx-translate/core'; + +@Component({ + selector: 'tb-scada-symbol-metadata', + templateUrl: './scada-symbol-metadata.component.html', + styleUrls: ['./scada-symbol-metadata.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ScadaSymbolMetadataComponent), + multi: true + } + ] +}) +export class ScadaSymbolMetadataComponent extends PageComponent implements OnInit, ControlValueAccessor { + + @Input() + disabled: boolean; + + private modelValue: IotSvgMetadata; + + private propagateChange = null; + + public metadataFormGroup: UntypedFormGroup; + + headerOptions: ToggleHeaderOption[] = [ + { + name: this.translate.instant('scada.general'), + value: 'general' + }, + { + name: this.translate.instant('scada.tags'), + value: 'tags' + }, + { + name: this.translate.instant('scada.behavior'), + value: 'behavior' + }, + { + name: this.translate.instant('scada.properties'), + value: 'properties' + } + ]; + + selectedOption = 'general'; + + constructor(protected store: Store, + private fb: UntypedFormBuilder, + private translate: TranslateService) { + super(store); + } + + ngOnInit(): void { + this.metadataFormGroup = this.fb.group({ + title: [null, [Validators.required]], + stateRenderFunction: [null], + tags: [null], + behavior: [null], + properties: [null] + }); + + this.metadataFormGroup.valueChanges.subscribe(() => { + this.updateModel(); + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.metadataFormGroup.disable({emitEvent: false}); + } else { + this.metadataFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: IotSvgMetadata): void { + this.selectedOption = 'general'; + this.modelValue = value; + this.metadataFormGroup.patchValue( + value, {emitEvent: false} + ); + } + + private updateModel() { + const metadata: IotSvgMetadata = this.metadataFormGroup.getRawValue(); + this.modelValue = metadata; + this.propagateChange(this.modelValue); + } + + protected readonly WidgetConfigMode = WidgetConfigMode; + protected readonly widgetType = widgetType; +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.component.scss b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.component.scss index c7fc243dc0..1ea820b6a2 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.component.scss +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.component.scss @@ -18,6 +18,16 @@ flex-direction: row; align-items: center; gap: 8px; + &.column { + flex-direction: column; + } + + .tb-confirm-text { + font-size: 14px; + line-height: 16px; + text-align: center; + font-weight: 400; + } .mdc-button { line-height: 20px; diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.components.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.components.ts index df9e299cda..faf091c5b7 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.components.ts +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.components.ts @@ -23,6 +23,7 @@ import { EventEmitter, Input, NgModule, OnDestroy, + OnInit, Output, Type, ViewChild, @@ -34,6 +35,8 @@ import { CommonModule } from '@angular/common'; import { SharedModule } from '@shared/shared.module'; import { ENTER } from '@angular/cdk/keycodes'; import Timeout = NodeJS.Timeout; +import { MatButton } from '@angular/material/button'; +import ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance; @Directive() abstract class ScadaSymbolPanelComponent implements AfterViewInit { @@ -55,7 +58,10 @@ abstract class ScadaSymbolPanelComponent implements AfterViewInit { @Component({ template: `
{{ symbolElement?.element?.type }}: - +
`, styleUrls: ['./scada-symbol-tooltip.component.scss'], encapsulation: ViewEncapsulation.None @@ -87,13 +93,13 @@ class ScadaSymbolAddTagPanelComponent extends ScadaSymbolPanelComponent { matTooltipPosition="above" [disabled]="!tag" (click)="onApply()"> - done + done
`, styleUrls: ['./scada-symbol-tooltip.component.scss'], @@ -181,13 +187,13 @@ class ScadaSymbolTagInputPanelComponent extends ScadaSymbolPanelComponent implem matTooltip="Update tag" matTooltipPosition="above" (click)="onUpdateTag()"> - edit + edit -
`, styleUrls: ['./scada-symbol-tooltip.component.scss'], @@ -195,19 +201,99 @@ class ScadaSymbolTagInputPanelComponent extends ScadaSymbolPanelComponent implem }) class ScadaSymbolTagPanelComponent extends ScadaSymbolPanelComponent implements AfterViewInit { + @ViewChild('removeTagButton', {read: ElementRef}) + removeTagButton: ElementRef; + @Output() updateTag = new EventEmitter(); @Output() removeTag = new EventEmitter(); - constructor(public element: ElementRef) { + constructor(public element: ElementRef, + private container: ViewContainerRef) { super(element); } + ngAfterViewInit() { + super.ngAfterViewInit(); + setTimeout(() => { + const el = $(this.removeTagButton.nativeElement); + el.tooltipster( + { + zIndex: 200, + arrow: true, + theme: ['iot-svg', 'tb-active'], + interactive: true, + trigger: 'click', + side: 'top', + content: '' + } + ); + const tooltip = el.tooltipster('instance'); + const compRef = + setTooltipComponent(this.symbolElement, this.container, ScadaSymbolRemoveTagConfirmComponent, tooltip); + compRef.instance.removeTag.subscribe(() => { + tooltip.destroy(); + this.removeTag.emit(); + }); + compRef.instance.cancel.subscribe(() => { + tooltip.close(); + }); + tooltip.on('ready', () => { + compRef.instance.yesButton.focus(); + }); + }); + } + public onUpdateTag() { this.updateTag.emit(); } +} + +@Component({ + template: `
+
+
+ + +
+
`, + styleUrls: ['./scada-symbol-tooltip.component.scss'], + encapsulation: ViewEncapsulation.None +}) +class ScadaSymbolRemoveTagConfirmComponent extends ScadaSymbolPanelComponent implements OnInit, AfterViewInit { + + @ViewChild('yesButton') + yesButton: MatButton; + + deleteText: string; + + @Output() + cancel = new EventEmitter(); + + @Output() + removeTag = new EventEmitter(); + + constructor(public element: ElementRef) { + super(element); + } + + ngOnInit() { + this.deleteText = `Are you sure you want to delete tag
${this.symbolElement?.tag} + from ${this.symbolElement?.element?.type} element?`; + } + + public onCancel() { + this.cancel.emit(); + } public onRemoveTag() { this.removeTag.emit(); @@ -219,7 +305,8 @@ class ScadaSymbolTagPanelComponent extends ScadaSymbolPanelComponent implements [ ScadaSymbolAddTagPanelComponent, ScadaSymbolTagInputPanelComponent, - ScadaSymbolTagPanelComponent + ScadaSymbolTagPanelComponent, + ScadaSymbolRemoveTagConfirmComponent ], imports: [ CommonModule, @@ -292,18 +379,26 @@ const setupTagInputPanelTooltip = (symbolElement: ScadaSymbolElement, container: }; const setTooltipComponent = (symbolElement: ScadaSymbolElement, - container: ViewContainerRef, componentType: Type): ComponentRef => { + container: ViewContainerRef, + componentType: Type, + tooltip?: ITooltipsterInstance): ComponentRef => { + if (!tooltip) { + tooltip = symbolElement.tooltip; + } const componentRef = container.createComponent(componentType); componentRef.instance.symbolElement = symbolElement; componentRef.instance.viewInited.subscribe(() => { - if (symbolElement.tooltip.status().open) { - symbolElement.tooltip.reposition(); + if (tooltip.status().open) { + tooltip.reposition(); } }); + tooltip.on('destroyed', () => { + componentRef.destroy(); + }); const parentElement = componentRef.instance.element.nativeElement; const content = parentElement.firstChild; parentElement.removeChild(content); parentElement.style.display = 'none'; - symbolElement.tooltip.content(content); + tooltip.content(content); return componentRef; }; diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html index dc4e620f94..fb32b1decc 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html @@ -29,13 +29,54 @@ [showCloseDetails]="false" headerHeightPx="64" headerTitle="{{symbolData?.imageResource?.title}}"> -
- {{ metadata | json }} +
+ + + +
- +
diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.ts index 0d521d0a4f..7b19df3ff3 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.ts +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.ts @@ -14,7 +14,16 @@ /// limitations under the License. /// -import { AfterViewInit, Component, HostBinding, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; +import { + AfterViewInit, + ChangeDetectorRef, + Component, + HostBinding, + OnDestroy, + OnInit, + ViewChild, + ViewEncapsulation +} from '@angular/core'; import { PageComponent } from '@shared/components/page.component'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @@ -23,6 +32,12 @@ import { takeUntil } from 'rxjs/operators'; import { Subject } from 'rxjs'; import { ScadaSymbolData } from '@home/pages/scada-symbol/scada-symbol.models'; import { IotSvgMetadata, parseIotSvgMetadataFromContent } from '@home/components/widget/lib/svg/iot-svg.models'; +import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { deepClone } from '@core/utils'; +import { + ScadaSymbolEditorComponent, + ScadaSymbolEditorData +} from '@home/pages/scada-symbol/scada-symbol-editor.component'; @Component({ selector: 'tb-scada-symbol', @@ -35,28 +50,43 @@ export class ScadaSymbolComponent extends PageComponent implements OnInit, OnDes @HostBinding('style.width') width = '100%'; @HostBinding('style.height') height = '100%'; + @ViewChild('symbolEditor') + symbolEditor: ScadaSymbolEditorComponent; + symbolData: ScadaSymbolData; + symbolEditorData: ScadaSymbolEditorData; metadata: IotSvgMetadata; + previewMode = false; + + scadaSymbolFormGroup: UntypedFormGroup; + private destroy$ = new Subject(); + private origSymbolData: ScadaSymbolData; + constructor(protected store: Store, private router: Router, - private route: ActivatedRoute) { + private route: ActivatedRoute, + private fb: UntypedFormBuilder, + private cd: ChangeDetectorRef) { super(store); + } + + ngOnInit(): void { + this.scadaSymbolFormGroup = this.fb.group({ + metadata: [null] + }); this.route.data.pipe( takeUntil(this.destroy$) ).subscribe( () => { this.reset(); - this.init(); + this.init(this.route.snapshot.data.symbolData); } ); } - ngOnInit(): void { - } - ngAfterViewInit() { } @@ -66,12 +96,33 @@ export class ScadaSymbolComponent extends PageComponent implements OnInit, OnDes this.destroy$.complete(); } + onApplyScadaSymbolConfig() { + if (this.scadaSymbolFormGroup.valid) { + const svgContent = this.symbolEditor.getContent(); + console.log(svgContent); + } + } + + onRevertScadaSymbolConfig() { + this.init(this.origSymbolData); + } + private reset(): void { + this.previewMode = false; } - private init() { - this.symbolData = this.route.snapshot.data.symbolData; + private init(data: ScadaSymbolData) { + this.origSymbolData = data; + this.symbolData = deepClone(data); + this.symbolEditorData = { + svgContent: this.symbolData.svgContent + }; this.metadata = parseIotSvgMetadataFromContent(this.symbolData.svgContent); + this.scadaSymbolFormGroup.patchValue({ + metadata: this.metadata + }, {emitEvent: false}); + this.scadaSymbolFormGroup.markAsPristine(); + this.cd.markForCheck(); } } diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.models.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.models.ts index fe843f8cf9..1fc4af8459 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.models.ts +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.models.ts @@ -30,6 +30,12 @@ import ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance; import TooltipPositioningSide = JQueryTooltipster.TooltipPositioningSide; import ITooltipsterHelper = JQueryTooltipster.ITooltipsterHelper; import ITooltipPosition = JQueryTooltipster.ITooltipPosition; +import { + innerSvgContent, + innerSvgContentFromSvgjs, + stripSvgMetadata, + svgRootNodePart +} from '@home/components/widget/lib/svg/iot-svg.models'; export interface ScadaSymbolData { imageResource: ImageResourceInfo; @@ -39,12 +45,15 @@ export interface ScadaSymbolData { export class ScadaSymbolEditObject { public svgShape: Svg; + private svgRootNodePart: string; private box: Box; private elements: ScadaSymbolElement[] = []; private readonly shapeResize$: ResizeObserver; private performSetup = false; private hoverFilterStyle: Style; public scale = 1; + + public dirty = false; constructor(private rootElement: HTMLElement, public viewContainerRef: ViewContainerRef) { this.shapeResize$ = new ResizeObserver(() => { @@ -55,11 +64,17 @@ export class ScadaSymbolEditObject { public setContent(svgContent: string) { this.shapeResize$.unobserve(this.rootElement); if (this.svgShape) { - this.elements.length = 0; + this.destroyElements(); this.svgShape.remove(); } - const doc: XMLDocument = new DOMParser().parseFromString(svgContent, 'image/svg+xml'); - this.svgShape = SVG().addTo(this.rootElement).svg(doc.documentElement.innerHTML); + this.scale = 1; + this.dirty = false; + this.svgRootNodePart = svgRootNodePart(svgContent); + svgContent = innerSvgContent(svgContent); + svgContent = stripSvgMetadata(svgContent); + console.log(svgContent); + // const doc: XMLDocument = new DOMParser().parseFromString(svgContent, 'image/svg+xml'); + this.svgShape = SVG().addTo(this.rootElement).svg(svgContent); this.svgShape.node.style.overflow = 'visible'; this.svgShape.node.style['user-select'] = 'none'; this.box = this.svgShape.bbox(); @@ -71,6 +86,24 @@ export class ScadaSymbolEditObject { this.shapeResize$.observe(this.rootElement); } + public getContent(): string { + if (this.svgShape) { + let svgContent = innerSvgContentFromSvgjs(this.svgShape.svg()); + console.log(svgContent); + svgContent = `${this.svgRootNodePart}\n${svgContent}\n`; + const doc: XMLDocument = new DOMParser().parseFromString(svgContent, 'image/svg+xml'); + const root = $(doc.documentElement); + root.find('.tb-element').removeClass('tb-element').each((i, e) => { + // + }); + root.find('.tooltipstered').removeClass('tooltipstered'); + svgContent = doc.documentElement.outerHTML; + return svgContent; + } else { + return null; + } + } + public cancelEdit() { this.elements.filter(e => e.isEditing()).forEach(e => e.stopEdit(true)); } @@ -196,6 +229,14 @@ export class ScadaSymbolEditObject { if (this.shapeResize$) { this.shapeResize$.disconnect(); } + this.destroyElements(); + } + + private destroyElements() { + this.elements.forEach(e => { + e.destroy(); + }); + this.elements.length = 0; } private resize() { @@ -269,14 +310,6 @@ const hasBBox = (e: Element): boolean => { } }; -const textTooltip = (el: JQuery, text: string) => { - el.tooltipster({ - theme: ['iot-svg'], - trigger: 'hover', - content: text - }); -}; - const isDomRectContained = (target: DOMRect, container: DOMRect, horizontalGap = 0, verticalGap = 0): boolean => ( target.left >= container.left - horizontalGap && target.left + target.width <= container.left + container.width + horizontalGap && @@ -403,6 +436,7 @@ export class ScadaSymbolElement { this.element.attr('tb:tag', null); this.unhighlight(); this.createAddTagTooltip(); + this.editObject.dirty = true; } public setTag(tag: string) { @@ -410,6 +444,7 @@ export class ScadaSymbolElement { this.tag = tag; this.element.attr('tb:tag', tag); this.createTagTooltip(); + this.editObject.dirty = true; } public startEdit(onCancelEdit: () => void) { @@ -456,6 +491,12 @@ export class ScadaSymbolElement { this.innerTooltipOffset = offset + (center - this.box.cy) * this.editObject.scale; } + public destroy() { + if (this.tooltip) { + this.tooltip.destroy(); + } + } + private unscaled(size: number): number { return size / (this.editObject.scale * this.editObject.svgShape.zoom()); } diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.module.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.module.ts index 9366321392..36d4cab1a2 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.module.ts +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.module.ts @@ -21,12 +21,14 @@ import { HomeComponentsModule } from '@modules/home/components/home-components.m import { ScadaSymbolComponent } from '@home/pages/scada-symbol/scada-symbol.component'; import { ScadaSymbolEditorComponent } from '@home/pages/scada-symbol/scada-symbol-editor.component'; import { ScadaSymbolTooltipComponentsModule } from '@home/pages/scada-symbol/scada-symbol-tooltip.components'; +import { ScadaSymbolMetadataComponent } from '@home/pages/scada-symbol/scada-symbol-metadata.component'; @NgModule({ declarations: [ ScadaSymbolEditorComponent, - ScadaSymbolComponent + ScadaSymbolComponent, + ScadaSymbolMetadataComponent ], imports: [ CommonModule, diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 265c2c10d2..fbb815fc40 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -3410,7 +3410,14 @@ "delete-symbols-title": "Are you sure you want to delete { count, plural, =1 {1 SCADA symbol} other {# SCADA symbols} }?", "delete-symbols-text": "Be careful, after the confirmation all selected SCADA symbols will be removed and all related data will become unrecoverable.", "include-system-symbols": "Include system symbols", - "symbol-preview": "Symbol preview" + "symbol-preview": "Symbol preview", + "general": "General", + "tags": "Tags", + "behavior": "Behavior", + "properties": "Properties", + "title": "Title", + "state-render-function": "State render function", + "preview": "Preview" }, "item": { "selected": "Selected" From 32d34e0acc54a24ecc6068dabb392d49c1506e6a Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Tue, 21 May 2024 19:26:48 +0300 Subject: [PATCH 037/442] UI: SCADA symbol preview mode. --- ui-ngx/src/app/core/utils.ts | 5 + .../lib/svg/iot-svg-widget.component.html | 2 +- .../lib/svg/iot-svg-widget.component.ts | 39 ++++- .../widget/lib/svg/iot-svg-widget.models.ts | 4 +- .../widget/lib/svg/iot-svg.models.ts | 87 +++++----- .../scada-symbol/scada-symbol.component.html | 44 +++-- .../scada-symbol/scada-symbol.component.ts | 150 ++++++++++++++++-- .../pages/scada-symbol/scada-symbol.models.ts | 63 +++----- 8 files changed, 269 insertions(+), 125 deletions(-) diff --git a/ui-ngx/src/app/core/utils.ts b/ui-ngx/src/app/core/utils.ts index 04c0a9fc9b..545f82ddf5 100644 --- a/ui-ngx/src/app/core/utils.ts +++ b/ui-ngx/src/app/core/utils.ts @@ -229,6 +229,11 @@ export const updateFileContent = (file: File, newContent: string): File => { return new File([blob], file.name, {type: file.type}); }; +export const createFileFromContent = (content: string, name: string, type: string): File => { + const blob = new Blob([content], { type }); + return new File([blob], name, { type }); +}; + const scrollRegex = /(auto|scroll)/; function parentNodes(node: Node, nodes: Node[]): Node[] { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg-widget.component.html index b2f4ce1815..ee005b98c0 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg-widget.component.html @@ -22,5 +22,5 @@
- +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg-widget.component.ts index c7b5e87723..608feee493 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg-widget.component.ts @@ -35,8 +35,9 @@ import { iotSvgWidgetDefaultSettings, IotSvgWidgetSettings } from '@home/components/widget/lib/svg/iot-svg-widget.models'; -import { Observable } from 'rxjs'; +import { Observable, of } from 'rxjs'; import { backgroundStyle, ComponentStyle, overlayStyle } from '@shared/models/widget-settings.models'; +import { ImageService } from '@core/http/image.service'; @Component({ selector: 'tb-iot-svg-widget', @@ -63,6 +64,7 @@ export class IotSvgWidgetComponent extends constructor(protected imagePipe: ImagePipe, protected sanitizer: DomSanitizer, private renderer: Renderer2, + private imageService: ImageService, protected cd: ChangeDetectorRef, private http: HttpClient) { super(cd); @@ -75,15 +77,40 @@ export class IotSvgWidgetComponent extends this.backgroundStyle$ = backgroundStyle(this.settings.background, this.imagePipe, this.sanitizer); this.overlayStyle = overlayStyle(this.settings.background.overlay); - this.iotSvgObject = new IotSvgObject(this.ctx, this.settings.iotSvg, this.settings.iotSvgObject); + let svgContent$: Observable; + if (this.settings.iotSvgContent) { + svgContent$ = of(this.settings.iotSvgContent); + } else if (this.settings.iotSvgUrl) { + svgContent$ = this.imageService.getImageString(this.settings.iotSvgUrl); + } else { + svgContent$ = this.http.get(this.settings.iotSvg, {responseType: 'text'}); + } + + svgContent$.subscribe( + (content) => { + this.initObject(content); + } + ); + } + + private initObject(svgContent: string) { + this.iotSvgObject = new IotSvgObject(this.ctx, svgContent, this.settings.iotSvgObject); this.iotSvgObject.onError((error) => { this.ctx.showErrorToast(error, 'bottom', 'center', this.ctx.toastTargetId, true); }); - this.iotSvgObject.init().subscribe(); + this.iotSvgObject.init(); + if (this.iotSvgShape) { + this.iotSvgObject.addTo(this.iotSvgShape.nativeElement); + if (this.autoScale) { + this.onResize(); + } + } } ngAfterViewInit(): void { - this.iotSvgObject.addTo(this.iotSvgShape.nativeElement); + if (this.iotSvgObject) { + this.iotSvgObject.addTo(this.iotSvgShape.nativeElement); + } if (this.autoScale) { this.shapeResize$ = new ResizeObserver(() => { this.onResize(); @@ -114,6 +141,8 @@ export class IotSvgWidgetComponent extends private onResize() { const shapeWidth = this.iotSvgShape.nativeElement.getBoundingClientRect().width; const shapeHeight = this.iotSvgShape.nativeElement.getBoundingClientRect().height; - this.iotSvgObject.setSize(shapeWidth, shapeHeight); + if (this.iotSvgObject) { + this.iotSvgObject.setSize(shapeWidth, shapeHeight); + } } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg-widget.models.ts index e4992ae62f..b3223c5b79 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg-widget.models.ts @@ -18,7 +18,9 @@ import { IotSvgObjectSettings } from '@home/components/widget/lib/svg/iot-svg.mo import { BackgroundSettings, BackgroundType } from '@shared/models/widget-settings.models'; export interface IotSvgWidgetSettings { - iotSvg: string; + iotSvg?: string; + iotSvgUrl?: string; + iotSvgContent?: string; iotSvgObject: IotSvgObjectSettings; background: BackgroundSettings; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts index 7277ea0751..b037b947ed 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts @@ -34,7 +34,7 @@ import { parseFunction } from '@core/utils'; import { BehaviorSubject, forkJoin, Observable, Observer } from 'rxjs'; -import { map, share } from 'rxjs/operators'; +import { share } from 'rxjs/operators'; import { ValueAction, ValueGetter, ValueSetter } from '@home/components/widget/lib/action/action-widget.models'; import { WidgetContext } from '@home/models/widget-component.models'; import { ColorProcessor, constantColor, Font } from '@shared/models/widget-settings.models'; @@ -196,45 +196,38 @@ const updateIotSvgMetadataInDom = (svgDoc: Document, metadata: IotSvgMetadata) = }; const svgPartsRegex = /()(.*)<\/svg>/gms; +const tbMetadataRegex = /.*<\/tb:metadata>/gs; -const svgMetadataRegex = /.*<\/tb:metadata>/gs; - -export const svgRootNodePart = (svgContent: string): string => { - let result = svgContent; - svgPartsRegex.lastIndex = 0; - const match = svgPartsRegex.exec(svgContent); - if (match != null && match.length > 1) { - result = match[1]; - } - return result; -}; +export interface IoTSvgContentData { + svgRootNode: string; + innerSvg: string; +} -export const innerSvgContent = (svgContent: string): string => { - let result = svgContent; +export const iotSvgContentData = (svgContent: string): IoTSvgContentData => { + const result: IoTSvgContentData = { + svgRootNode: '', + innerSvg: '' + }; svgPartsRegex.lastIndex = 0; const match = svgPartsRegex.exec(svgContent); - if (match != null && match.length > 2) { - result = match[2]; - } - return result; -}; - -export const stripSvgMetadata = (svgContent: string) => { - let result = svgContent; - svgMetadataRegex.lastIndex = 0; - const match = svgMetadataRegex.exec(svgContent); - if (match !== null && match.length) { - const metadata = match[0]; - result = result.replace(metadata, ''); + if (match != null) { + if (match.length > 1) { + result.svgRootNode = match[1]; + } + if (match.length > 2) { + let innerSvgContent = match[2]; + tbMetadataRegex.lastIndex = 0; + const metadataMatch = tbMetadataRegex.exec(svgContent); + if (metadataMatch !== null && metadataMatch.length) { + const metadata = metadataMatch[0]; + innerSvgContent = innerSvgContent.replace(metadata, ''); + } + result.innerSvg = innerSvgContent; + } } return result; }; -export const innerSvgContentFromSvgjs = (svgContent: string): string => { - const result = innerSvgContent(svgContent); - return result.replace(/svgjs:.*?=".*?"/gs, ''); -}; - const defaultGetValueSettings = (get: IotSvgBehaviorValue): GetValueSettings => ({ action: GetValueAction.DO_NOTHING, defaultValue: get.defaultValue, @@ -335,21 +328,18 @@ export class IotSvgObject { private _onError: (error: string) => void = () => {}; constructor(private ctx: WidgetContext, - private svgPath: string, - private inputSettings: IotSvgObjectSettings) {} - - public init(): Observable { - return this.ctx.http.get(this.svgPath, {responseType: 'text'}).pipe( - map((inputSvgContent) => { - const doc: XMLDocument = new DOMParser().parseFromString(inputSvgContent, 'image/svg+xml'); - this.metadata = parseIotSvgMetadataFromDom(doc); - const defaults = defaultIotSvgObjectSettings(this.metadata); - this.settings = mergeDeep({}, defaults, this.inputSettings || {}); - this.prepareMetadata(); - this.prepareSvgShape(doc); - this.initialize(); - }) - ); + private svgContent: string, + private inputSettings: IotSvgObjectSettings) { + } + + public init() { + const doc: XMLDocument = new DOMParser().parseFromString(this.svgContent, 'image/svg+xml'); + this.metadata = parseIotSvgMetadataFromDom(doc); + const defaults = defaultIotSvgObjectSettings(this.metadata); + this.settings = mergeDeep({}, defaults, this.inputSettings || {}); + this.prepareMetadata(); + this.prepareSvgShape(doc); + this.initialize(); } public onError(onError: (error: string) => void) { @@ -371,6 +361,9 @@ export class IotSvgObject { this.valueActions.forEach(v => v.destroy()); this.loadingSubject.complete(); this.loadingSubject.unsubscribe(); + if (this.svgShape) { + this.svgShape.remove(); + } } public setSize(targetWidth: number, targetHeight: number) { diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html index fb32b1decc..b04247b916 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html @@ -29,41 +29,39 @@ [showCloseDetails]="false" headerHeightPx="64" headerTitle="{{symbolData?.imageResource?.title}}"> -
+ +
+
+ + +
diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-metadata.component.scss b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.scss similarity index 88% rename from ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-metadata.component.scss rename to ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.scss index 48cc8471b8..bfaf8b0529 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-metadata.component.scss +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.scss @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -@import '../../../../../scss/constants'; +@import '../../../../../../scss/constants'; .tb-scada-symbol-metadata { display: flex; @@ -61,5 +61,12 @@ gap: 8px; } } + .tb-form-panel { + .mat-expansion-panel.tb-settings { + & > .mat-expansion-panel-header { + font-weight: 400; + } + } + } } } diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-metadata.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.ts similarity index 85% rename from ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-metadata.component.ts rename to ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.ts index 56f0a2d16b..966115c44e 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-metadata.component.ts +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.ts @@ -14,12 +14,15 @@ /// limitations under the License. /// -import { Component, forwardRef, Input, OnInit } from '@angular/core'; +import { Component, forwardRef, Input, OnInit, ViewEncapsulation } from '@angular/core'; import { ControlValueAccessor, + NG_VALIDATORS, NG_VALUE_ACCESSOR, UntypedFormBuilder, + UntypedFormControl, UntypedFormGroup, + Validator, Validators } from '@angular/forms'; import { PageComponent } from '@shared/components/page.component'; @@ -39,14 +42,23 @@ import { TranslateService } from '@ngx-translate/core'; provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => ScadaSymbolMetadataComponent), multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => ScadaSymbolMetadataComponent), + multi: true } - ] + ], + encapsulation: ViewEncapsulation.None }) -export class ScadaSymbolMetadataComponent extends PageComponent implements OnInit, ControlValueAccessor { +export class ScadaSymbolMetadataComponent extends PageComponent implements OnInit, ControlValueAccessor, Validator { @Input() disabled: boolean; + @Input() + tags: string[]; + private modelValue: IotSvgMetadata; private propagateChange = null; @@ -118,6 +130,15 @@ export class ScadaSymbolMetadataComponent extends PageComponent implements OnIni ); } + public validate(c: UntypedFormControl) { + const valid = this.metadataFormGroup.valid; + return valid ? null : { + metadata: { + valid: false, + }, + }; + } + private updateModel() { const metadata: IotSvgMetadata = this.metadataFormGroup.getRawValue(); this.modelValue = metadata; diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.ts index 833f96ea67..cd25aa7063 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.ts +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.ts @@ -17,11 +17,11 @@ import { AfterViewInit, Component, - ElementRef, + ElementRef, EventEmitter, Input, OnChanges, OnDestroy, - OnInit, + OnInit, Output, SimpleChanges, ViewChild, ViewContainerRef, @@ -47,6 +47,9 @@ export class ScadaSymbolEditorComponent implements OnInit, OnDestroy, AfterViewI @Input() data: ScadaSymbolEditorData; + @Output() + tags = new EventEmitter(); + scadaSymbolEditObject: ScadaSymbolEditObject; constructor(private viewContainerRef: ViewContainerRef) { @@ -58,6 +61,7 @@ export class ScadaSymbolEditorComponent implements OnInit, OnDestroy, AfterViewI ngAfterViewInit() { this.scadaSymbolEditObject = new ScadaSymbolEditObject(this.iotSvgShape.nativeElement, this.viewContainerRef); + this.scadaSymbolEditObject.tagsUpdated.subscribe(tags => this.tags.emit(tags)); if (this.data) { this.scadaSymbolEditObject.setContent(this.data.svgContent); } diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html index b04247b916..82b415e570 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html @@ -29,14 +29,43 @@ [showCloseDetails]="false" headerHeightPx="64" headerTitle="{{symbolData?.imageResource?.title}}"> - +
+
+ + + + +
+
+
+ + +
+
+
- +
-
+
diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.scss b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.scss index bfaf8b0529..7b923e1688 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.scss +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.scss @@ -41,6 +41,7 @@ } } .tb-scada-symbol-metadata-content { + display: flex; & > .mat-content { padding-top: 8px; @media #{$mat-xs} { @@ -60,6 +61,9 @@ @media #{$mat-xs} { gap: 8px; } + &.overflow-hidden { + overflow: hidden; + } } .tb-form-panel { .mat-expansion-panel.tb-settings { diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.ts index 966115c44e..d8b6267a8f 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.ts +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { Component, forwardRef, Input, OnInit, ViewEncapsulation } from '@angular/core'; +import { Component, forwardRef, Input, OnInit, ViewChild, ViewEncapsulation } from '@angular/core'; import { ControlValueAccessor, NG_VALIDATORS, @@ -29,9 +29,11 @@ import { PageComponent } from '@shared/components/page.component'; import { IotSvgMetadata } from '@home/components/widget/lib/svg/iot-svg.models'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; -import { WidgetConfigMode, widgetType } from '@shared/models/widget.models'; import { ToggleHeaderOption } from '@shared/components/toggle-header.component'; import { TranslateService } from '@ngx-translate/core'; +import { + ScadaSymbolMetadataTagsComponent +} from '@home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tags.component'; @Component({ selector: 'tb-scada-symbol-metadata', @@ -53,6 +55,9 @@ import { TranslateService } from '@ngx-translate/core'; }) export class ScadaSymbolMetadataComponent extends PageComponent implements OnInit, ControlValueAccessor, Validator { + @ViewChild('symbolMetadataTags') + symbolMetadataTags: ScadaSymbolMetadataTagsComponent; + @Input() disabled: boolean; @@ -130,6 +135,16 @@ export class ScadaSymbolMetadataComponent extends PageComponent implements OnIni ); } + editTagStateRenderFunction(tag: string): void { + this.selectedOption = 'tags'; + this.symbolMetadataTags.editTagStateRenderFunction(tag); + } + + editTagClickAction(tag: string): void { + this.selectedOption = 'tags'; + this.symbolMetadataTags.editTagClickAction(tag); + } + public validate(c: UntypedFormControl) { const valid = this.metadataFormGroup.valid; return valid ? null : { @@ -144,7 +159,4 @@ export class ScadaSymbolMetadataComponent extends PageComponent implements OnIni this.modelValue = metadata; this.propagateChange(this.modelValue); } - - protected readonly WidgetConfigMode = WidgetConfigMode; - protected readonly widgetType = widgetType; } diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.ts index cd25aa7063..10c2b3f7f0 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.ts +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.ts @@ -17,17 +17,17 @@ import { AfterViewInit, Component, - ElementRef, EventEmitter, + ElementRef, Input, OnChanges, OnDestroy, - OnInit, Output, + OnInit, SimpleChanges, ViewChild, ViewContainerRef, ViewEncapsulation } from '@angular/core'; -import { ScadaSymbolEditObject } from '@home/pages/scada-symbol/scada-symbol.models'; +import { ScadaSymbolEditObject, ScadaSymbolEditObjectCallbacks } from '@home/pages/scada-symbol/scada-symbol.models'; export interface ScadaSymbolEditorData { svgContent: string; @@ -47,8 +47,8 @@ export class ScadaSymbolEditorComponent implements OnInit, OnDestroy, AfterViewI @Input() data: ScadaSymbolEditorData; - @Output() - tags = new EventEmitter(); + @Input() + editObjectCallbacks: ScadaSymbolEditObjectCallbacks; scadaSymbolEditObject: ScadaSymbolEditObject; @@ -60,8 +60,7 @@ export class ScadaSymbolEditorComponent implements OnInit, OnDestroy, AfterViewI ngAfterViewInit() { this.scadaSymbolEditObject = new ScadaSymbolEditObject(this.iotSvgShape.nativeElement, - this.viewContainerRef); - this.scadaSymbolEditObject.tagsUpdated.subscribe(tags => this.tags.emit(tags)); + this.viewContainerRef, this.editObjectCallbacks); if (this.data) { this.scadaSymbolEditObject.setContent(this.data.svgContent); } @@ -86,13 +85,8 @@ export class ScadaSymbolEditorComponent implements OnInit, OnDestroy, AfterViewI this.scadaSymbolEditObject.destroy(); } - get dirty(): boolean { - return this.scadaSymbolEditObject?.dirty; - } - getContent(): string { return this.scadaSymbolEditObject?.getContent(); } - } diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.component.scss b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.component.scss index 1ea820b6a2..43613b6d40 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.component.scss +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.component.scss @@ -21,6 +21,9 @@ &.column { flex-direction: column; } + &.flex-start { + align-items: start; + } .tb-confirm-text { font-size: 14px; diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.components.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.components.ts index faf091c5b7..813a652af1 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.components.ts +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.components.ts @@ -189,6 +189,12 @@ class ScadaSymbolTagInputPanelComponent extends ScadaSymbolPanelComponent implem (click)="onUpdateTag()"> edit + + +
scada.on-click-action
+ + +
`, + styleUrls: ['./scada-symbol-tooltip.component.scss'], + encapsulation: ViewEncapsulation.None +}) +class ScadaSymbolTagSettingsComponent extends ScadaSymbolPanelComponent implements OnInit, AfterViewInit { + + hasStateRenderFunction = false; + + hasClickAction = false; + + constructor(public element: ElementRef) { + super(element); + } + + ngOnInit() { + } + + updateFunctionsState() { + this.hasStateRenderFunction = this.symbolElement.hasStateRenderFunction(); + this.hasClickAction = this.symbolElement.hasClickAction(); + } + + editStateRenderFunction() { + this.symbolElement.editStateRenderFunction(); + } + + editClickAction() { + this.symbolElement.editClickAction(); + } +} + @NgModule({ declarations: [ ScadaSymbolAddTagPanelComponent, ScadaSymbolTagInputPanelComponent, ScadaSymbolTagPanelComponent, - ScadaSymbolRemoveTagConfirmComponent + ScadaSymbolRemoveTagConfirmComponent, + ScadaSymbolTagSettingsComponent ], imports: [ CommonModule, diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html index 82b415e570..449bf95bc7 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html @@ -64,6 +64,7 @@
diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-row.component.scss b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-row.component.scss new file mode 100644 index 0000000000..d0592c431e --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-row.component.scss @@ -0,0 +1,26 @@ +/** + * Copyright ยฉ 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +.tb-scada-symbol-metadata-behavior-row { + .tb-id-field { + flex: 1 1 40%; + } + .tb-name-field { + flex: 1 1 40%; + } + .tb-type-field { + flex: 1 1 20%; + } +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-row.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-row.component.ts new file mode 100644 index 0000000000..277d9fe4ce --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-row.component.ts @@ -0,0 +1,172 @@ +/// +/// Copyright ยฉ 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + ChangeDetectorRef, + Component, + ElementRef, + EventEmitter, + forwardRef, + Input, + OnInit, + Output, + ViewChild, + ViewEncapsulation +} from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormGroup, + ValidationErrors +} from '@angular/forms'; +import { MatDialog } from '@angular/material/dialog'; +import { + IotSvgBehavior, + IotSvgBehaviorAction, + IotSvgBehaviorType, + iotSvgBehaviorTypes, + iotSvgBehaviorTypeTranslations, + IotSvgBehaviorValue +} from '@home/components/widget/lib/svg/iot-svg.models'; + +export const behaviorValid = (behavior: IotSvgBehavior): boolean => { + if (!behavior.id || !behavior.name || !behavior.type) { + return false; + } + switch (behavior.type) { + case IotSvgBehaviorType.value: + const valueBehavior = behavior as IotSvgBehaviorValue; + if (!valueBehavior.valueType) { + return false; + } + break; + case IotSvgBehaviorType.action: + const actionBehavior = behavior as IotSvgBehaviorAction; + if (!actionBehavior.valueToDataType) { + return false; + } + break; + case IotSvgBehaviorType.widgetAction: + break; + } + return true; +}; + +export const behaviorValidator = (control: AbstractControl): ValidationErrors | null => { + const behavior: IotSvgBehavior = control.value; + if (!behaviorValid(behavior)) { + return { + behavior: true + }; + } + return null; +}; + +@Component({ + selector: 'tb-scada-symbol-metadata-behavior-row', + templateUrl: './scada-symbol-behavior-row.component.html', + styleUrls: ['./scada-symbol-behavior-row.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ScadaSymbolBehaviorRowComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class ScadaSymbolBehaviorRowComponent implements ControlValueAccessor, OnInit { + + @ViewChild('idInput') + idInput: ElementRef; + + iotSvgBehaviorTypes = iotSvgBehaviorTypes; + iotSvgBehaviorTypeTranslations = iotSvgBehaviorTypeTranslations; + + @Input() + disabled: boolean; + + @Output() + behaviorRemoved = new EventEmitter(); + + behaviorRowFormGroup: UntypedFormGroup; + + modelValue: IotSvgBehavior; + + private propagateChange = (_val: any) => {}; + + constructor(private fb: UntypedFormBuilder, + private dialog: MatDialog, + private cd: ChangeDetectorRef) { + } + + ngOnInit() { + this.behaviorRowFormGroup = this.fb.group({ + id: [null, []], + name: [null, []], + type: [null, []] + }); + this.behaviorRowFormGroup.valueChanges.subscribe( + () => this.updateModel() + ); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.behaviorRowFormGroup.disable({emitEvent: false}); + } else { + this.behaviorRowFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: IotSvgBehavior): void { + this.modelValue = value; + this.behaviorRowFormGroup.patchValue( + { + id: value?.id, + name: value?.name, + type: value?.type + }, {emitEvent: false} + ); + this.cd.markForCheck(); + } + + editBehavior() { + + } + + focus() { + this.idInput.nativeElement.scrollIntoView(); + this.idInput.nativeElement.focus(); + } + + private updateModel() { + const value: IotSvgBehavior = this.behaviorRowFormGroup.value; + this.modelValue = {...this.modelValue, ...value}; + this.propagateChange(this.modelValue); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behaviors.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behaviors.component.html new file mode 100644 index 0000000000..28efaca269 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behaviors.component.html @@ -0,0 +1,60 @@ + +
+
+
+
scada.behavior.id
+
scada.behavior.name
+
scada.behavior.type
+
+
+
+
+ + +
+ +
+
+
+
+
+ +
+
+ + + {{ 'scada.behavior.no-behaviors' | translate }} + diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behaviors.component.scss b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behaviors.component.scss new file mode 100644 index 0000000000..a9fd4df110 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behaviors.component.scss @@ -0,0 +1,43 @@ +/** + * Copyright ยฉ 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +.tb-scada-symbol-behaviors { + flex: 1; + margin: 12px; + .tb-form-table-header-cell { + &.tb-id-header { + flex: 1 1 40%; + } + &.tb-name-header { + flex: 1 1 40%; + } + &.tb-type-header { + flex: 1 1 20%; + } + &.tb-actions-header { + width: 120px; + min-width: 120px; + } + } + .tb-form-table { + overflow: hidden; + } + .tb-form-table-body { + overflow: auto; + tb-scada-symbol-metadata-behavior-row { + overflow: hidden; + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behaviors.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behaviors.component.ts new file mode 100644 index 0000000000..d06e790784 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behaviors.component.ts @@ -0,0 +1,177 @@ +/// +/// Copyright ยฉ 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + Component, + forwardRef, + HostBinding, + Input, + OnInit, + QueryList, + ViewChildren, + ViewEncapsulation +} from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormArray, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validator +} from '@angular/forms'; +import { IotSvgBehavior, IotSvgBehaviorType } from '@home/components/widget/lib/svg/iot-svg.models'; +import { ValueType } from '@shared/models/constants'; +import { CdkDragDrop } from '@angular/cdk/drag-drop'; +import { + behaviorValid, + behaviorValidator, + ScadaSymbolBehaviorRowComponent +} from '@home/pages/scada-symbol/metadata-components/scada-symbol-behavior-row.component'; + +@Component({ + selector: 'tb-scada-symbol-metadata-behaviors', + templateUrl: './scada-symbol-behaviors.component.html', + styleUrls: ['./scada-symbol-behaviors.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ScadaSymbolBehaviorsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => ScadaSymbolBehaviorsComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class ScadaSymbolBehaviorsComponent implements ControlValueAccessor, OnInit, Validator { + + @HostBinding('style.display') styleDisplay = 'flex'; + @HostBinding('style.overflow') styleOverflow = 'hidden'; + + @ViewChildren(ScadaSymbolBehaviorRowComponent) + behaviorRows: QueryList; + + @Input() + disabled: boolean; + + behaviorsFormGroup: UntypedFormGroup; + + get dragEnabled(): boolean { + return this.behaviorsFormArray().controls.length > 1; + } + + private propagateChange = (_val: any) => {}; + + constructor(private fb: UntypedFormBuilder) { + } + + ngOnInit() { + this.behaviorsFormGroup = this.fb.group({ + behaviors: this.fb.array([]) + }); + this.behaviorsFormGroup.valueChanges.subscribe( + () => { + let behaviors: IotSvgBehavior[] = this.behaviorsFormGroup.get('behaviors').value; + if (behaviors) { + behaviors = behaviors.filter(b => behaviorValid(b)); + } + this.propagateChange(behaviors); + } + ); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.behaviorsFormGroup.disable({emitEvent: false}); + } else { + this.behaviorsFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: IotSvgBehavior[] | undefined): void { + const behaviors= value || []; + this.behaviorsFormGroup.setControl('behaviors', this.prepareBehaviorsFormArray(behaviors), {emitEvent: false}); + } + + public validate(c: UntypedFormControl) { + const valid = this.behaviorsFormGroup.valid; + return valid ? null : { + behaviors: { + valid: false, + }, + }; + } + + behaviorDrop(event: CdkDragDrop) { + const behaviorsArray = this.behaviorsFormGroup.get('behaviors') as UntypedFormArray; + const behavior = behaviorsArray.at(event.previousIndex); + behaviorsArray.removeAt(event.previousIndex); + behaviorsArray.insert(event.currentIndex, behavior); + } + + behaviorsFormArray(): UntypedFormArray { + return this.behaviorsFormGroup.get('behaviors') as UntypedFormArray; + } + + trackByBehavior(index: number, behaviorControl: AbstractControl): any { + return behaviorControl; + } + + removeBehavior(index: number, emitEvent = true) { + (this.behaviorsFormGroup.get('behaviors') as UntypedFormArray).removeAt(index, {emitEvent}); + } + + addBehavior() { + const behavior: IotSvgBehavior = { + id: '', + name: '', + type: IotSvgBehaviorType.value, + valueType: ValueType.BOOLEAN, + defaultValue: false + }; + const behaviorsArray = this.behaviorsFormGroup.get('behaviors') as UntypedFormArray; + const behaviorControl = this.fb.control(behavior, [behaviorValidator]); + behaviorsArray.push(behaviorControl); + setTimeout(() => { + const behaviorRow = this.behaviorRows.get(this.behaviorRows.length-1); + behaviorRow.focus(); + }); + } + + private prepareBehaviorsFormArray(behaviors: IotSvgBehavior[] | undefined): UntypedFormArray { + const behaviorsControls: Array = []; + if (behaviors) { + behaviors.forEach((behavior) => { + behaviorsControls.push(this.fb.control(behavior, [behaviorValidator])); + }); + } + return this.fb.array(behaviorsControls); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-components.module.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-components.module.ts index 5b86c23f04..469f7e9782 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-components.module.ts +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-components.module.ts @@ -24,13 +24,21 @@ import { ScadaSymbolMetadataComponent } from '@home/pages/scada-symbol/metadata- import { ScadaSymbolMetadataTagsComponent } from '@home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tags.component'; +import { + ScadaSymbolBehaviorsComponent +} from '@home/pages/scada-symbol/metadata-components/scada-symbol-behaviors.component'; +import { + ScadaSymbolBehaviorRowComponent +} from '@home/pages/scada-symbol/metadata-components/scada-symbol-behavior-row.component'; @NgModule({ declarations: [ ScadaSymbolMetadataComponent, ScadaSymbolMetadataTagComponent, - ScadaSymbolMetadataTagsComponent + ScadaSymbolMetadataTagsComponent, + ScadaSymbolBehaviorsComponent, + ScadaSymbolBehaviorRowComponent ], imports: [ CommonModule, diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag.component.html index e3e0b82d82..01914d1e32 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag.component.html +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag.component.html @@ -22,7 +22,7 @@
scada.tag
- + {{ tag }} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag.component.ts index f074955776..663a87d8fa 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag.component.ts +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag.component.ts @@ -37,6 +37,7 @@ import { import { IotSvgTag } from '@home/components/widget/lib/svg/iot-svg.models'; import { MatExpansionPanel } from '@angular/material/expansion'; import { JsFuncComponent } from '@shared/components/js-func.component'; +import { MatSelect } from '@angular/material/select'; @Component({ selector: 'tb-scada-symbol-metadata-tag', @@ -53,6 +54,9 @@ import { JsFuncComponent } from '@shared/components/js-func.component'; }) export class ScadaSymbolMetadataTagComponent implements ControlValueAccessor, OnInit, OnChanges { + @ViewChild('tagSelect') + tagSelect: MatSelect; + @ViewChild('expansionPanel') expansionPanel: MatExpansionPanel; @@ -163,6 +167,11 @@ export class ScadaSymbolMetadataTagComponent implements ControlValueAccessor, On }); } + focus() { + this.tagSelect._elementRef.nativeElement.scrollIntoView(); + this.tagSelect.focus(); + } + private openPanelWithCallback(panel: MatExpansionPanel, callback: () => void) { if (!panel.expanded) { const s = panel.afterExpand.subscribe(() => { diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tags.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tags.component.ts index 13bebfe72d..5b6c8210db 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tags.component.ts +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tags.component.ts @@ -164,6 +164,10 @@ export class ScadaSymbolMetadataTagsComponent implements ControlValueAccessor, O addTag() { this.addNewTag(null); + setTimeout(() => { + const tagComponent = this.metadataTags.get(this.metadataTags.length-1); + tagComponent.focus(); + }); } editTagStateRenderFunction(tag: string): void { diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.html index 16573951a7..395f6c67f0 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.html +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.html @@ -58,5 +58,10 @@ formControlName="tags">
+
+ + +
diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.ts index d8b6267a8f..96f4b0b184 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.ts +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.ts @@ -80,7 +80,7 @@ export class ScadaSymbolMetadataComponent extends PageComponent implements OnIni value: 'tags' }, { - name: this.translate.instant('scada.behavior'), + name: this.translate.instant('scada.behavior.behavior'), value: 'behavior' }, { diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.ts index d3b08fd795..c595f63071 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.ts +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.ts @@ -218,13 +218,22 @@ export class ScadaSymbolComponent extends PageComponent enterPreviewMode() { this.symbolData.svgContent = this.prepareSvgContent(); - this.previewIotSvgObjectSettings = {}; + this.previewIotSvgObjectSettings = { + behavior: {}, + properties: {} + }; this.scadaPreviewFormGroup.patchValue({ iotSvgObject: this.previewIotSvgObjectSettings }, {emitEvent: false}); this.scadaPreviewFormGroup.markAsPristine(); const settings: IotSvgWidgetSettings = {...iotSvgWidgetDefaultSettings, - ...{iotSvg: null, iotSvgContent: this.symbolData.svgContent, iotSvgObject: this.previewIotSvgObjectSettings}}; + ...{ + simulated: true, + iotSvg: null, + iotSvgContent: this.symbolData.svgContent, + iotSvgObject: this.previewIotSvgObjectSettings + } + }; this.previewWidget = { typeFullFqn: 'system.iot_svg', type: widgetType.rpc, diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index f6a81e8f1d..8ac12e376e 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -3389,9 +3389,6 @@ "error-entities": "There was an error creating {{count}} entities." } }, - "iot-svg": { - "behavior": "Behavior" - }, "scada": { "symbol": "SCADA symbol", "symbols": "SCADA symbols", @@ -3413,7 +3410,6 @@ "symbol-preview": "Symbol preview", "general": "General", "tags": "Tags", - "behavior": "Behavior", "properties": "Properties", "title": "Title", "state-render-function": "State render function", @@ -3423,7 +3419,21 @@ "remove-tag": "Remove tag", "add-tag": "Add tag", "no-tags": "No tags configured", - "select-tag": "Select tag" + "select-tag": "Select tag", + "preview-widget-action-text": "Widget action '{{type}}' successfully invoked!", + "behavior": { + "behavior": "Behavior", + "id": "Id", + "name": "Name", + "type": "Type", + "no-behaviors": "No behaviors configured", + "add-behavior": "Add behavior", + "type-action": "Action", + "type-value": "Value", + "type-widget-action": "Widget action", + "behavior-settings": "Behavior settings", + "remove-behavior": "Remove behavior" + } }, "item": { "selected": "Selected" diff --git a/ui-ngx/src/assets/widget/svg/drawing.svg b/ui-ngx/src/assets/widget/svg/drawing.svg index 55d60d04c1..5f64fc6643 100644 --- a/ui-ngx/src/assets/widget/svg/drawing.svg +++ b/ui-ngx/src/assets/widget/svg/drawing.svg @@ -28,7 +28,7 @@ "tag": "onButton", "actions": { "click": { - "actionFunction": "ctx.api.callAction(event, 'onUpdateState');" + "actionFunction": "ctx.api.callAction(event, 'onUpdateState', undefined, {\n next: () => {\n ctx.api.setValue('on', true);\n }\n});" } } }, @@ -36,7 +36,7 @@ "tag": "offButton", "actions": { "click": { - "actionFunction": "ctx.api.callAction(event, 'offUpdateState');" + "actionFunction": "ctx.api.callAction(event, 'offUpdateState', undefined, {\n next: () => {\n ctx.api.setValue('on', false);\n }\n});\n" } } }, @@ -59,33 +59,30 @@ ], "behavior": [ { - "id": "levelState", + "id": "level", "name": "Level", "type": "value", "valueType": "DOUBLE", - "defaultValue": 0, - "valueId": "level" + "defaultValue": 0 }, { - "id": "disabledState", + "id": "disabled", "name": "{i18n:widgets.rpc-state.disabled-state}", "hint": "{i18n:widgets.rpc-state.disabled-state-hint}", "stateLabel": "{i18n:widgets.rpc-state.disabled}", "type": "value", "valueType": "BOOLEAN", - "defaultValue": false, - "valueId": "disabled" + "defaultValue": false }, { - "id": "onState", + "id": "on", "name": "On/Off state", "trueLabel": "{i18n:widgets.rpc-state.on}", "falseLabel": "{i18n:widgets.rpc-state.off}", "stateLabel": "{i18n:widgets.rpc-state.on}", "type": "value", "valueType": "BOOLEAN", - "defaultValue": false, - "valueId": "on" + "defaultValue": false }, { "id": "onUpdateState", diff --git a/ui-ngx/src/form.scss b/ui-ngx/src/form.scss index 18f5f6755d..88d0df0e29 100644 --- a/ui-ngx/src/form.scss +++ b/ui-ngx/src/form.scss @@ -564,6 +564,7 @@ .tb-form-table-header { height: 48px; + min-height: 48px; border-bottom: 1px solid rgba(0, 0, 0, 0.12); &-cell { color: rgba(0, 0, 0, 0.54); From 2e4e580036a3ededbd73d31f75460d7f4573cfa9 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Fri, 24 May 2024 19:39:37 +0300 Subject: [PATCH 041/442] UI: SCADA symbol: Implement behavior settings. Improve iot svg widget initialization. --- .../iot-svg-object-settings.component.html | 12 +- .../svg/iot-svg-object-settings.component.ts | 10 +- .../svg/iot-svg-object-settings.models.ts | 4 +- .../lib/svg/iot-svg-widget.component.ts | 96 +-- .../widget/lib/svg/iot-svg.models.ts | 160 +++-- ...scada-symbol-behavior-panel.component.html | 138 ++++ ...scada-symbol-behavior-panel.component.scss | 67 ++ .../scada-symbol-behavior-panel.component.ts | 148 +++++ .../scada-symbol-behavior-row.component.html | 3 +- .../scada-symbol-behavior-row.component.ts | 106 +++- .../scada-symbol-behaviors.component.ts | 10 +- ...scada-symbol-metadata-components.module.ts | 6 +- .../scada-symbol-metadata-tags.component.html | 2 +- .../pages/scada-symbol/scada-symbol.models.ts | 37 +- .../assets/locale/locale.constant-en_US.json | 26 +- ui-ngx/src/assets/widget/svg/drawing.svg | 589 +++++++++--------- 16 files changed, 945 insertions(+), 469 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-panel.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-panel.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-panel.component.ts diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/svg/iot-svg-object-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/svg/iot-svg-object-settings.component.html index 1e9bd9616d..3e0684fd75 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/svg/iot-svg-object-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/svg/iot-svg-object-settings.component.html @@ -60,35 +60,35 @@
{{ property.subLabel | customTranslate }}
- +
{{ property.fieldSuffix | customTranslate }}
- - - +
{{ property.fieldSuffix | customTranslate }}
- - diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/svg/iot-svg-object-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/svg/iot-svg-object-settings.component.ts index 58d4346e53..7073d034ff 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/svg/iot-svg-object-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/svg/iot-svg-object-settings.component.ts @@ -30,13 +30,13 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { defaultIotSvgObjectSettings, - parseIotSvgMetadataFromContent, IotSvgBehaviorType, IotSvgMetadata, - IotSvgObjectSettings + IotSvgObjectSettings, + IotSvgPropertyType, + parseIotSvgMetadataFromContent } from '@home/components/widget/lib/svg/iot-svg.models'; import { HttpClient } from '@angular/common/http'; -import { ValueType } from '@shared/models/constants'; import { IAliasController } from '@core/api/widget-api.models'; import { TargetDevice, widgetType } from '@shared/models/widget.models'; import { isDefinedAndNotNull, mergeDeep } from '@core/utils'; @@ -69,6 +69,8 @@ export class IotSvgObjectSettingsComponent implements OnInit, OnChanges, Control IotSvgBehaviorType = IotSvgBehaviorType; + IotSvgPropertyType = IotSvgPropertyType; + @Input() disabled: boolean; @@ -205,7 +207,7 @@ export class IotSvgObjectSettingsComponent implements OnInit, OnChanges, Control if (property.required) { validators.push(Validators.required); } - if (property.type === 'number') { + if (property.type === IotSvgPropertyType.number) { if (isDefinedAndNotNull(property.min)) { validators.push(Validators.min(property.min)); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/svg/iot-svg-object-settings.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/svg/iot-svg-object-settings.models.ts index ebb8b908a9..807dffcc56 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/svg/iot-svg-object-settings.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/svg/iot-svg-object-settings.models.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { IotSvgProperty } from '@home/components/widget/lib/svg/iot-svg.models'; +import { IotSvgProperty, IotSvgPropertyType } from '@home/components/widget/lib/svg/iot-svg.models'; export interface IotSvgPropertyRow { label: string; @@ -35,7 +35,7 @@ export const toPropertyRows = (properties: IotSvgProperty[]): IotSvgPropertyRow[ }; result.push(propertyRow); } - if (property.type === 'switch') { + if (property.type === IotSvgPropertyType.switch) { propertyRow.switch = property; } else { propertyRow.properties.push(property); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg-widget.component.ts index fc443bcd39..52f40152a0 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg-widget.component.ts @@ -19,18 +19,18 @@ import { ChangeDetectorRef, Component, ElementRef, + Input, OnDestroy, OnInit, Renderer2, + TemplateRef, ViewChild, ViewEncapsulation } from '@angular/core'; -import { BasicActionWidgetComponent } from '@home/components/widget/lib/action/action-widget.models'; import { ImagePipe } from '@shared/pipe/image.pipe'; import { DomSanitizer } from '@angular/platform-browser'; import { HttpClient } from '@angular/common/http'; -import { IotSvgObject } from '@home/components/widget/lib/svg/iot-svg.models'; -import { ResizeObserver } from '@juggle/resize-observer'; +import { IotSvgObject, IotSvgObjectCallbacks } from '@home/components/widget/lib/svg/iot-svg.models'; import { iotSvgWidgetDefaultSettings, IotSvgWidgetSettings @@ -40,6 +40,7 @@ import { backgroundStyle, ComponentStyle, overlayStyle } from '@shared/models/wi import { ImageService } from '@core/http/image.service'; import { WidgetComponent } from '@home/components/widget/widget.component'; import { isDefinedAndNotNull, mergeDeep } from '@core/utils'; +import { WidgetContext } from '@home/models/widget-component.models'; @Component({ selector: 'tb-iot-svg-widget', @@ -47,110 +48,79 @@ import { isDefinedAndNotNull, mergeDeep } from '@core/utils'; styleUrls: ['../action/action-widget.scss', './iot-svg-widget.component.scss'], encapsulation: ViewEncapsulation.None }) -export class IotSvgWidgetComponent extends - BasicActionWidgetComponent implements OnInit, AfterViewInit, OnDestroy { +export class IotSvgWidgetComponent implements OnInit, AfterViewInit, OnDestroy, IotSvgObjectCallbacks { @ViewChild('iotSvgShape', {static: false}) iotSvgShape: ElementRef; + @Input() + ctx: WidgetContext; + + @Input() + widgetTitlePanel: TemplateRef; + private settings: IotSvgWidgetSettings; + private svgContent$: Observable; backgroundStyle$: Observable; overlayStyle: ComponentStyle = {}; iotSvgObject: IotSvgObject; - private autoScale = true; - - private shapeResize$: ResizeObserver; - constructor(public widgetComponent: WidgetComponent, protected imagePipe: ImagePipe, protected sanitizer: DomSanitizer, - private renderer: Renderer2, private imageService: ImageService, protected cd: ChangeDetectorRef, private http: HttpClient) { - super(cd); } ngOnInit(): void { - super.ngOnInit(); + this.ctx.$scope.actionWidget = this; this.settings = mergeDeep({} as IotSvgWidgetSettings, iotSvgWidgetDefaultSettings, this.ctx.settings || {}); this.backgroundStyle$ = backgroundStyle(this.settings.background, this.imagePipe, this.sanitizer); this.overlayStyle = overlayStyle(this.settings.background.overlay); - let svgContent$: Observable; if (this.settings.iotSvgContent) { - svgContent$ = of(this.settings.iotSvgContent); + this.svgContent$ = of(this.settings.iotSvgContent); } else if (this.settings.iotSvgUrl) { - svgContent$ = this.imageService.getImageString(this.settings.iotSvgUrl); + this.svgContent$ = this.imageService.getImageString(this.settings.iotSvgUrl); } else { - svgContent$ = this.http.get(this.settings.iotSvg, {responseType: 'text'}); - } - - svgContent$.subscribe( - (content) => { - this.initObject(content); - } - ); - } - - private initObject(svgContent: string) { - const simulated = this.ctx.utilsService.widgetEditMode || - this.ctx.isPreview || (isDefinedAndNotNull(this.settings.simulated) ? this.settings.simulated : false); - this.iotSvgObject = new IotSvgObject(this.ctx, svgContent, this.settings.iotSvgObject, simulated); - this.iotSvgObject.onError((error) => { - this.ctx.showErrorToast(error, 'bottom', 'center', this.ctx.toastTargetId, true); - }); - this.iotSvgObject.onMessage((message) => { - this.ctx.showSuccessToast(message, 3000, 'bottom', 'center', this.ctx.toastTargetId, true); - }); - this.iotSvgObject.init(); - if (this.iotSvgShape) { - this.iotSvgObject.addTo(this.iotSvgShape.nativeElement); - if (this.autoScale) { - this.onResize(); - } + this.svgContent$ = this.http.get(this.settings.iotSvg, {responseType: 'text'}); } } ngAfterViewInit(): void { - if (this.iotSvgObject) { - this.iotSvgObject.addTo(this.iotSvgShape.nativeElement); - } - if (this.autoScale) { - this.shapeResize$ = new ResizeObserver(() => { - this.onResize(); - }); - this.shapeResize$.observe(this.iotSvgShape.nativeElement); - this.onResize(); - } - super.ngAfterViewInit(); + this.svgContent$.subscribe((content) => { + this.initObject(this.iotSvgShape.nativeElement, content); + }); } ngOnDestroy() { - if (this.shapeResize$) { - this.shapeResize$.disconnect(); - } if (this.iotSvgObject) { this.iotSvgObject.destroy(); } - super.ngOnDestroy(); } public onInit() { - super.onInit(); const borderRadius = this.ctx.$widgetElement.css('borderRadius'); this.overlayStyle = {...this.overlayStyle, ...{borderRadius}}; this.cd.detectChanges(); } - private onResize() { - const shapeWidth = this.iotSvgShape.nativeElement.getBoundingClientRect().width; - const shapeHeight = this.iotSvgShape.nativeElement.getBoundingClientRect().height; - if (this.iotSvgObject) { - this.iotSvgObject.setSize(shapeWidth, shapeHeight); - } + onSvgObjectError(error: string) { + this.ctx.showErrorToast(error, 'bottom', 'center', this.ctx.toastTargetId, true); } + + onSvgObjectMessage(message: string) { + this.ctx.showSuccessToast(message, 3000, 'bottom', 'center', this.ctx.toastTargetId, true); + } + + private initObject(rootElement: HTMLElement, + svgContent: string) { + const simulated = this.ctx.utilsService.widgetEditMode || + this.ctx.isPreview || (isDefinedAndNotNull(this.settings.simulated) ? this.settings.simulated : false); + this.iotSvgObject = new IotSvgObject(rootElement, this.ctx, svgContent, this.settings.iotSvgObject, this, simulated); + } + } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts index 1302afefd1..5c463b7165 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts @@ -15,7 +15,7 @@ /// import { ValueType } from '@shared/models/constants'; -import { Box, Element, Runner, SVG, Svg, Text } from '@svgdotjs/svg.js'; +import { Box, Element, Runner, SVG, Svg, Text, Timeline } from '@svgdotjs/svg.js'; import '@svgdotjs/svg.panzoom.js'; import { DataToValueType, @@ -33,7 +33,7 @@ import { mergeDeep, parseFunction } from '@core/utils'; -import { BehaviorSubject, forkJoin, Observable, Observer, of } from 'rxjs'; +import { BehaviorSubject, forkJoin, Observable, Observer } from 'rxjs'; import { share } from 'rxjs/operators'; import { ValueAction, ValueGetter, ValueSetter } from '@home/components/widget/lib/action/action-widget.models'; import { WidgetContext } from '@home/models/widget-component.models'; @@ -41,6 +41,7 @@ import { ColorProcessor, constantColor, Font } from '@shared/models/widget-setti import { AttributeScope } from '@shared/models/telemetry/telemetry.models'; import { UtilsService } from '@core/services/utils.service'; import { WidgetAction, WidgetActionType, widgetActionTypeTranslationMap } from '@shared/models/widget.models'; +import { ResizeObserver } from '@juggle/resize-observer'; export interface IotSvgApi { formatValue: (value: any, dec?: number, units?: string, showZeroDecimals?: boolean) => string | undefined; @@ -112,14 +113,37 @@ export interface IotSvgBehaviorValue extends IotSvgBehaviorBase { } export interface IotSvgBehaviorAction extends IotSvgBehaviorBase { + valueType: ValueType; valueToDataType: ValueToDataType; constantValue: any; valueToDataFunction: string; } -export type IotSvgBehavior = IotSvgBehaviorValue | IotSvgBehaviorAction; +export type IotSvgBehavior = IotSvgBehaviorValue & IotSvgBehaviorAction; + +export enum IotSvgPropertyType { + text = 'text', + number = 'number', + switch = 'switch', + color = 'color', + color_settings = 'color_settings', + font = 'font', + units = 'units' +} + +export const iotSvgPropertyTypes = Object.keys(IotSvgPropertyType) as IotSvgPropertyType[]; -export type IotSvgPropertyType = 'string' | 'number' | 'color' | 'color-settings' | 'font' | 'units' | 'switch'; +export const iotSvgPropertyTypeTranslations = new Map( + [ + [IotSvgPropertyType.text, 'scada.property.type-text'], + [IotSvgPropertyType.number, 'scada.property.type-number'], + [IotSvgPropertyType.switch, 'scada.property.type-switch'], + [IotSvgPropertyType.color, 'scada.property.type-color'], + [IotSvgPropertyType.color_settings, 'scada.property.type-color-settings'], + [IotSvgPropertyType.font, 'scada.property.type-font'], + [IotSvgPropertyType.units, 'scada.property.type-units'] + ] +); export interface IotSvgPropertyBase { id: string; @@ -320,17 +344,19 @@ export type IotSvgObjectSettings = { const parseError = (ctx: WidgetContext, err: any): string => ctx.$injector.get(UtilsService).parseException(err).message || 'Unknown Error'; +export interface IotSvgObjectCallbacks { + onSvgObjectError: (error: string) => void; + onSvgObjectMessage: (message: string) => void; +} + export class IotSvgObject { private metadata: IotSvgMetadata; private settings: IotSvgObjectSettings; private context: IotSvgContext; - private rootElement: HTMLElement; private svgShape: Svg; private box: Box; - private targetWidth: number; - private targetHeight: number; private loadingSubject = new BehaviorSubject(false); private valueGetters: ValueGetter[] = []; @@ -339,19 +365,22 @@ export class IotSvgObject { private stateValueSubjects: {[id: string]: BehaviorSubject} = {}; - loading$ = this.loadingSubject.asObservable().pipe(share()); + private readonly shapeResize$: ResizeObserver; + private scale = 1; - private _onError: (error: string) => void = () => {}; + private performInit = true; - private _onMessage: (message: string) => void = () => {}; + loading$ = this.loadingSubject.asObservable().pipe(share()); - constructor(private ctx: WidgetContext, + constructor(private rootElement: HTMLElement, + private ctx: WidgetContext, private svgContent: string, private inputSettings: IotSvgObjectSettings, + private callbacks: IotSvgObjectCallbacks, private simulated: boolean) { - } - - public init() { + this.shapeResize$ = new ResizeObserver(() => { + this.resize(); + }); const doc: XMLDocument = new DOMParser().parseFromString(this.svgContent, 'image/svg+xml'); this.metadata = parseIotSvgMetadataFromDom(doc); const defaults = defaultIotSvgObjectSettings(this.metadata); @@ -359,25 +388,13 @@ export class IotSvgObject { defaults, this.inputSettings || {} as IotSvgObjectSettings); this.prepareMetadata(); this.prepareSvgShape(doc); - this.initialize(); - } - - public onError(onError: (error: string) => void) { - this._onError = onError; - } - - public onMessage(onMessage: (message: string) => void) { - this._onMessage = onMessage; - } - - public addTo(element: HTMLElement) { - this.rootElement = element; - if (this.svgShape) { - this.svgShape.addTo(element); - } + this.shapeResize$.observe(this.rootElement); } public destroy() { + if (this.shapeResize$) { + this.shapeResize$.disconnect(); + } for (const stateValueId of Object.keys(this.stateValueSubjects)) { this.stateValueSubjects[stateValueId].complete(); this.stateValueSubjects[stateValueId].unsubscribe(); @@ -385,16 +402,15 @@ export class IotSvgObject { this.valueActions.forEach(v => v.destroy()); this.loadingSubject.complete(); this.loadingSubject.unsubscribe(); - if (this.svgShape) { - this.svgShape.remove(); + for (const tag of this.metadata.tags) { + const elements = this.context.tags[tag.tag]; + elements.forEach(element => { + element.timeline().finish(); + element.timeline(null); + }); } - } - - public setSize(targetWidth: number, targetHeight: number) { - this.targetWidth = targetWidth; - this.targetHeight = targetHeight; if (this.svgShape) { - this.resize(); + this.svgShape.remove(); } } @@ -421,15 +437,10 @@ export class IotSvgObject { this.svgShape.node.style['user-select'] = 'none'; this.box = this.svgShape.bbox(); this.svgShape.size(this.box.width, this.box.height); - if (this.rootElement) { - this.svgShape.addTo(this.rootElement); - } - if (this.targetWidth && this.targetHeight) { - this.resize(); - } + this.svgShape.addTo(this.rootElement); } - private initialize() { + private init() { this.context = { api: { formatValue, @@ -447,7 +458,7 @@ export class IotSvgObject { }; const taggedElements = this.svgShape.find(`[tb\\:tag]`); for (const element of taggedElements) { - const tag = element.attr('tb:tag'); + const tag: string = element.attr('tb:tag'); let elements = this.context.tags[tag]; if (!elements) { elements = []; @@ -489,7 +500,7 @@ export class IotSvgObject { next: (val) => {this.onValue(getBehavior.id, val);}, error: (err) => { const message = parseError(this.ctx, err); - this._onError(message); + this.onError(message); } }, this.simulated); this.valueGetters.push(valueGetter); @@ -526,6 +537,14 @@ export class IotSvgObject { } } + private onError(error: string) { + this.callbacks.onSvgObjectError(error); + } + + private onMessage(message: string) { + this.callbacks.onSvgObjectMessage(message); + } + private callAction(event: Event, behaviorId: string, value?: any, observer?: Partial>) { const behavior = this.metadata.behavior.find(b => b.id === behaviorId); if (behavior) { @@ -547,7 +566,7 @@ export class IotSvgObject { observer.error(err); } const message = parseError(this.ctx, err); - this._onError(message); + this.onError(message); } } ); @@ -557,22 +576,38 @@ export class IotSvgObject { if (this.simulated) { const translatedType = this.ctx.translate.instant(widgetActionTypeTranslationMap.get(widgetAction.type)); const message = this.ctx.translate.instant('scada.preview-widget-action-text', {type: translatedType}); - this._onMessage(message); + this.onMessage(message); } else { this.ctx.actionsApi.onWidgetAction(event, widgetAction); } + if (observer?.next) { + observer.next(); + } } } } private resize() { - let scale: number; - if (this.targetWidth < this.targetHeight) { - scale = this.targetWidth / this.box.width; - } else { - scale = this.targetHeight / this.box.height; + if (this.svgShape) { + const targetWidth = this.rootElement.getBoundingClientRect().width; + const targetHeight = this.rootElement.getBoundingClientRect().height; + if (targetWidth && targetHeight) { + let scale: number; + if (targetWidth < targetHeight) { + scale = targetWidth / this.box.width; + } else { + scale = targetHeight / this.box.height; + } + if (this.scale !== scale) { + this.scale = scale; + this.svgShape.node.style.transform = `scale(${this.scale})`; + } + if (this.performInit) { + this.performInit = false; + this.init(); + } + } } - this.svgShape.node.style.transform = `scale(${scale})`; } private onValue(id: string, value: any) { @@ -598,7 +633,7 @@ export class IotSvgObject { private renderState(): void { this.metadata.stateRender(this.svgShape, this.context); for (const tag of this.metadata.tags) { - const elements = this.svgShape.find(`[tb\\:tag="${tag.tag}"]`); + const elements = this.context.tags[tag.tag];// this.svgShape.find(`[tb\\:tag="${tag.tag}"]`); elements.forEach(element => { tag.stateRender(element, this.context); }); @@ -664,6 +699,7 @@ export class IotSvgObject { private animate(element: Element, duration: number): Runner { element.timeline().finish(); + element.timeline(new Timeline()); return element.animate(duration, 0, 'now'); } @@ -692,9 +728,9 @@ export class IotSvgObject { if (property) { const value = this.settings.properties[id]; if (isDefinedAndNotNull(value)) { - if (property.type === 'color-settings') { + if (property.type === IotSvgPropertyType.color_settings) { return ColorProcessor.fromSettings(value); - } else if (property.type === 'string') { + } else if (property.type === IotSvgPropertyType.text) { const result = this.ctx.utilsService.customTranslation(value, value); const entityInfo = this.ctx.defaultSubscription.getFirstEntityInfo(); return createLabelFromSubscriptionEntityInfo(entityInfo, result); @@ -702,13 +738,13 @@ export class IotSvgObject { return value; } else { switch (property.type) { - case 'string': + case IotSvgPropertyType.text: return ''; - case 'number': + case IotSvgPropertyType.number: return 0; - case 'color': + case IotSvgPropertyType.color: return '#000'; - case 'color-settings': + case IotSvgPropertyType.color_settings: return ColorProcessor.fromSettings(constantColor('#000')); } } diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-panel.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-panel.component.html new file mode 100644 index 0000000000..12d308ed7c --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-panel.component.html @@ -0,0 +1,138 @@ + +
+
{{ panelTitle | translate }}
+
+
+
scada.behavior.id
+ + + +
+
+
scada.behavior.name
+ + + +
+
+
scada.behavior.hint
+ + + +
+
+
scada.behavior.type
+ + + + {{ iotSvgBehaviorTypeTranslations.get(type) | translate }} + + + +
+
+
scada.behavior.value-type
+ + + +
+ + {{ valueTypesMap.get(behaviorFormGroup.get('valueType').value).name | translate }} +
+
+ + + {{ valueTypesMap.get(valueType).name | translate }} + +
+
+
+ +
+
scada.behavior.default-value
+ +
+ +
+
scada.behavior.true-label
+ + + +
+
+
scada.behavior.false-label
+ + + +
+
+
scada.behavior.state-label
+ + + +
+
+
+ +
+
+
{{ 'scada.behavior.default-payload' | translate }}
+ + {{ 'widgets.value-action.converter-value' | translate }} + {{ 'widgets.value-action.converter-constant' | translate }} + {{ 'widgets.value-action.converter-function' | translate }} + {{ 'widgets.value-action.converter-none' | translate }} + +
+ + + + +
+
+
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-panel.component.scss b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-panel.component.scss new file mode 100644 index 0000000000..3fbbd245a7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-panel.component.scss @@ -0,0 +1,67 @@ +/** + * Copyright ยฉ 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../../../../../../scss/constants'; + +.tb-scada-symbol-behavior-settings-panel { + width: 540px; + display: flex; + flex-direction: column; + gap: 16px; + @media #{$mat-lt-md} { + width: 90vw; + } + .tb-scada-symbol-behavior-settings-panel-content { + display: flex; + flex-direction: column; + gap: 16px; + overflow: auto; + margin: -10px; + padding: 10px; + .mat-mdc-form-field.tb-value-type { + .mat-mdc-form-field-infix { + max-height: 40px; + } + mat-select-trigger { + .tb-value-type-row { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + gap: 10px; + .mat-icon { + color: rgba(0, 0, 0, 0.38); + vertical-align: bottom; + } + } + } + } + } + .tb-scada-symbol-behavior-settings-title { + font-size: 16px; + font-weight: 500; + line-height: 24px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.87); + } + .tb-scada-symbol-behavior-settings-panel-buttons { + height: 40px; + display: flex; + flex-direction: row; + gap: 16px; + justify-content: flex-end; + align-items: flex-end; + } +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-panel.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-panel.component.ts new file mode 100644 index 0000000000..bdb2827dac --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-panel.component.ts @@ -0,0 +1,148 @@ +/// +/// Copyright ยฉ 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { merge } from 'rxjs'; +import { + IotSvgBehavior, + IotSvgBehaviorType, + iotSvgBehaviorTypes, + iotSvgBehaviorTypeTranslations +} from '@app/modules/home/components/widget/lib/svg/iot-svg.models'; +import { ValueType, valueTypesMap } from '@shared/models/constants'; +import { ValueToDataType } from '@shared/models/action-widget-settings.models'; +import { WidgetService } from '@core/http/widget.service'; + +@Component({ + selector: 'tb-scada-symbol-behavior-panel', + templateUrl: './scada-symbol-behavior-panel.component.html', + styleUrls: ['./scada-symbol-behavior-panel.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class ScadaSymbolBehaviorPanelComponent implements OnInit { + + IotSvgBehaviorType = IotSvgBehaviorType; + + ValueType = ValueType; + + ValueToDataType = ValueToDataType; + + iotSvgBehaviorTypes = iotSvgBehaviorTypes; + iotSvgBehaviorTypeTranslations = iotSvgBehaviorTypeTranslations; + + valueTypes = Object.keys(ValueType) as ValueType[]; + + valueTypesMap = valueTypesMap; + + @Input() + isAdd = false; + + @Input() + behavior: IotSvgBehavior; + + @Input() + popover: TbPopoverComponent; + + @Output() + behaviorSettingsApplied = new EventEmitter(); + + functionScopeVariables = this.widgetService.getWidgetScopeVariables(); + + panelTitle: string; + + behaviorFormGroup: UntypedFormGroup; + + constructor(private fb: UntypedFormBuilder, + private widgetService: WidgetService) { + } + + ngOnInit(): void { + this.panelTitle = this.isAdd ? 'scada.behavior.add-behavior' : 'scada.behavior.behavior-settings'; + this.behaviorFormGroup = this.fb.group( + { + id: [this.behavior.id, [Validators.required]], + name: [this.behavior.name, [Validators.required]], + hint: [this.behavior.hint, []], + type: [this.behavior.type, [Validators.required]], + valueType: [this.behavior.valueType, [Validators.required]], + defaultValue: [this.behavior.defaultValue, [Validators.required]], + trueLabel: [this.behavior.trueLabel, []], + falseLabel: [this.behavior.falseLabel, []], + stateLabel: [this.behavior.stateLabel, []], + valueToDataType: [this.behavior.valueToDataType, [Validators.required]], + constantValue: [this.behavior.constantValue, [Validators.required]], + valueToDataFunction: [this.behavior.valueToDataFunction, [Validators.required]] + } + ); + merge(this.behaviorFormGroup.get('type').valueChanges, + this.behaviorFormGroup.get('valueType').valueChanges, + this.behaviorFormGroup.get('valueToDataType').valueChanges).subscribe(() => { + this.updateValidators(); + }); + this.updateValidators(); + } + + cancel() { + this.popover?.hide(); + } + + applyBehaviorSettings() { + const behavior = this.behaviorFormGroup.getRawValue(); + this.behaviorSettingsApplied.emit(behavior); + } + + private updateValidators() { + const type: IotSvgBehaviorType = this.behaviorFormGroup.get('type').value; + const valueType: ValueType = this.behaviorFormGroup.get('valueType').value; + let valueToDataType: ValueToDataType = this.behaviorFormGroup.get('valueToDataType').value; + this.behaviorFormGroup.disable({emitEvent: false}); + this.behaviorFormGroup.get('id').enable({emitEvent: false}); + this.behaviorFormGroup.get('name').enable({emitEvent: false}); + this.behaviorFormGroup.get('type').enable({emitEvent: false}); + this.behaviorFormGroup.get('hint').enable({emitEvent: false}); + switch (type) { + case IotSvgBehaviorType.value: + this.behaviorFormGroup.get('valueType').enable({emitEvent: false}); + this.behaviorFormGroup.get('defaultValue').enable({emitEvent: false}); + if (valueType === ValueType.BOOLEAN) { + this.behaviorFormGroup.get('trueLabel').enable({emitEvent: false}); + this.behaviorFormGroup.get('falseLabel').enable({emitEvent: false}); + this.behaviorFormGroup.get('stateLabel').enable({emitEvent: false}); + } + break; + case IotSvgBehaviorType.action: + if (valueType === ValueType.BOOLEAN && valueToDataType === ValueToDataType.VALUE) { + this.behaviorFormGroup.patchValue({valueToDataType: ValueToDataType.CONSTANT}, {emitEvent: false}); + valueToDataType = ValueToDataType.CONSTANT; + } else if (valueType !== ValueType.BOOLEAN && valueToDataType === ValueToDataType.CONSTANT) { + this.behaviorFormGroup.patchValue({valueToDataType: ValueToDataType.VALUE}, {emitEvent: false}); + valueToDataType = ValueToDataType.VALUE; + } + this.behaviorFormGroup.get('valueType').enable({emitEvent: false}); + this.behaviorFormGroup.get('valueToDataType').enable({emitEvent: false}); + if (valueToDataType === ValueToDataType.CONSTANT) { + this.behaviorFormGroup.get('constantValue').enable({emitEvent: false}); + } else if (valueToDataType === ValueToDataType.FUNCTION) { + this.behaviorFormGroup.get('valueToDataFunction').enable({emitEvent: false}); + } + break; + case IotSvgBehaviorType.widgetAction: + break; + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-row.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-row.component.html index 5b9c4d2cf9..766cce736b 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-row.component.html +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-row.component.html @@ -32,7 +32,8 @@
-
diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.models.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.models.ts index c34da44d23..3cb586218b 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.models.ts +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.models.ts @@ -74,13 +74,14 @@ export class ScadaSymbolEditObject { this.scale = 1; const contentData = iotSvgContentData(svgContent); this.svgRootNodePart = contentData.svgRootNode; - this.svgShape = SVG().addTo(this.rootElement).svg(contentData.innerSvg); + this.svgShape = SVG().svg(contentData.innerSvg); this.svgShape.node.style.overflow = 'visible'; this.svgShape.node.style['user-select'] = 'none'; this.box = this.svgShape.bbox(); this.svgShape.size(this.box.width, this.box.height); this.svgShape.viewbox(`0 0 ${this.box.width} ${this.box.height}`); this.svgShape.style().attr('tb:inner', true).rule('.tb-element', {cursor: 'pointer', transition: '0.2s filter ease-in-out'}); + this.svgShape.addTo(this.rootElement); this.updateHoverFilterStyle(); this.performSetup = true; this.shapeResize$.observe(this.rootElement); @@ -245,22 +246,24 @@ export class ScadaSymbolEditObject { if (this.svgShape) { const targetWidth = this.rootElement.getBoundingClientRect().width; const targetHeight = this.rootElement.getBoundingClientRect().height; - let scale: number; - if (targetWidth < targetHeight) { - scale = targetWidth / this.box.width; - } else { - scale = targetHeight / this.box.height; - } - if (this.scale !== scale) { - this.scale = scale; - this.svgShape.node.style.transform = `scale(${this.scale})`; - this.updateHoverFilterStyle(); - this.updateZoomOptions(); - this.updateTooltipPositions(); - } - if (this.performSetup) { - this.performSetup = false; - this.doSetup(); + if (targetWidth && targetHeight) { + let scale: number; + if (targetWidth < targetHeight) { + scale = targetWidth / this.box.width; + } else { + scale = targetHeight / this.box.height; + } + if (this.scale !== scale) { + this.scale = scale; + this.svgShape.node.style.transform = `scale(${this.scale})`; + this.updateHoverFilterStyle(); + this.updateZoomOptions(); + this.updateTooltipPositions(); + } + if (this.performSetup) { + this.performSetup = false; + this.doSetup(); + } } } } diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 8ac12e376e..42c5a644c2 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -3432,7 +3432,31 @@ "type-value": "Value", "type-widget-action": "Widget action", "behavior-settings": "Behavior settings", - "remove-behavior": "Remove behavior" + "remove-behavior": "Remove behavior", + "hint": "Hint", + "value-type": "Value type", + "default-value": "Default value", + "true-label": "True label", + "false-label": "False label", + "state-label": "State label", + "default-payload": "Default payload" + }, + "property": { + "property": "Property", + "id": "Id", + "name": "Name", + "type": "Type", + "type-text": "Text", + "type-number": "Number", + "type-switch": "Switch", + "type-color": "Color", + "type-color-settings": "Color settings", + "type-font": "Font", + "type-units": "Units", + "no-properties": "No properties configured", + "add-property": "Add property", + "property-settings": "Property settings", + "remove-property": "Remove property" } }, "item": { diff --git a/ui-ngx/src/assets/widget/svg/drawing.svg b/ui-ngx/src/assets/widget/svg/drawing.svg index 5f64fc6643..23759d17a5 100644 --- a/ui-ngx/src/assets/widget/svg/drawing.svg +++ b/ui-ngx/src/assets/widget/svg/drawing.svg @@ -1,307 +1,310 @@ - - { - "title": "My first IoT SVG Object", - "stateRenderFunction": "var showMinMaxLevel = ctx.properties.showMinMaxLevel;\nvar minLevelElement = ctx.tags.minLevel[0];\nvar maxLevelElement = ctx.tags.maxLevel[0];\nvar minLevel = ctx.properties.minLevel; \nvar maxLevel = ctx.properties.maxLevel;\n\nif (showMinMaxLevel) {\n \n var minMaxLevelFont = ctx.properties.minMaxLevelFont;\n var minMaxLevelColor = ctx.properties.minMaxLevelColor;\n \n ctx.api.text(minLevelElement, minLevel);\n ctx.api.text(maxLevelElement, maxLevel);\n \n ctx.api.font(minLevelElement, minMaxLevelFont, minMaxLevelColor);\n ctx.api.font(maxLevelElement, minMaxLevelFont, minMaxLevelColor);\n \n} else {\n minLevelElement.hide();\n maxLevelElement.hide();\n}\n\nvar disabled = ctx.values.disabled;\nvar on = ctx.values.on;\nvar level = ctx.values.level;\n\nvar onButton = ctx.tags.onButton;\nvar offButton = ctx.tags.offButton;\nvar levelUpButton = ctx.tags.levelUpButton;\nvar levelDownButton = ctx.tags.levelDownButton;\n\nvar onButtonEnabled = !disabled && !on;\nvar offButtonEnabled = !disabled && on;\nvar levelUpEnabled = !disabled && level < maxLevel;\nvar levelDownEnabled = !disabled && level > minLevel;\n\nif (onButtonEnabled) {\n ctx.api.enable(onButton);\n onButton[0].findOne('rect').attr({fill: '#12ed19'});\n} else {\n ctx.api.disable(onButton);\n onButton[0].findOne('rect').attr({fill: '#777'});\n}\n \nif (offButtonEnabled) {\n ctx.api.enable(offButton);\n offButton[0].findOne('rect').attr({fill: '#ed121f'});\n} else {\n ctx.api.disable(offButton);\n offButton[0].findOne('rect').attr({fill: '#777'});\n} \n\n\nif (levelUpEnabled) {\n ctx.api.enable(levelUpButton);\n levelUpButton[0].findOne('rect').attr({fill: '#fff'});\n} else {\n ctx.api.disable(levelUpButton);\n levelUpButton[0].findOne('rect').attr({fill: '#777'});\n}\n \nif (levelDownEnabled) {\n ctx.api.enable(levelDownButton);\n levelDownButton[0].findOne('rect').attr({fill: '#fff'});\n} else {\n ctx.api.disable(levelDownButton);\n levelDownButton[0].findOne('rect').attr({fill: '#777'});\n}", - "tags": [ - { - "tag": "level", - "stateRenderFunction": "var level = ctx.values.level; \nvar disabled = ctx.values.disabled;\nvar minLevel = ctx.properties.minLevel; \nvar maxLevel = ctx.properties.maxLevel;\n\nvar height = (level - minLevel) / (maxLevel - minLevel);\nheight = Math.max(0, Math.min(1, height))*80; \n\nctx.api.animate(element, 200).attr({height: height});\n\nvar fill;\nif (disabled) {\n fill = ctx.properties.disabledLevelBackground;\n} else {\n var colorProcessor = ctx.properties.levelBackground;\n colorProcessor.update(level);\n fill = colorProcessor.color;\n}\n\nelement.attr({fill: fill});" - }, - { - "tag": "levelTitle", - "stateRenderFunction": "var showLevelTitle = ctx.properties.showLevelTitle;\n\nif (showLevelTitle) {\n var levelTitle = ctx.properties.levelTitle;\n var levelTitleFont = ctx.properties.levelTitleFont;\n var levelTitleColor = ctx.properties.levelTitleColor;\n \n ctx.api.text(element, levelTitle);\n ctx.api.font(element, levelTitleFont, levelTitleColor);\n \n} else {\n element.hide();\n}" - }, - { - "tag": "levelValue", - "stateRenderFunction": "var showValue = ctx.properties.showValue;\n\nif (showValue) {\n var valueFont = ctx.properties.valueFont;\n var valueColor = ctx.properties.valueColor;\n var level = ctx.values.level; \n \n var levelText = ctx.api.formatValue(level, ctx.properties.valueDecimals, ctx.properties.valueUnits, false);\n\n ctx.api.font(element, valueFont, valueColor);\n ctx.api.text(element, levelText);\n\n} else {\n element.hide();\n ctx.tags.levelValueBackground[0].hide();\n}", - "actions": { - "click": { - "actionFunction": "ctx.api.callAction(event, 'levelValueClick');" - } - } - }, - { - "tag": "fan", - "stateRenderFunction": "var on = ctx.values.on;\nif (on) {\n element.addClass('fan-rotate');\n} else {\n element.removeClass('fan-rotate');\n}\n" - }, - { - "tag": "onButton", - "actions": { - "click": { - "actionFunction": "ctx.api.callAction(event, 'onUpdateState', undefined, {\n next: () => {\n ctx.api.setValue('on', true);\n }\n});" - } - } - }, - { - "tag": "offButton", - "actions": { - "click": { - "actionFunction": "ctx.api.callAction(event, 'offUpdateState', undefined, {\n next: () => {\n ctx.api.setValue('on', false);\n }\n});\n" - } - } - }, - { - "tag": "levelUpButton", - "actions": { - "click": { - "actionFunction": "var level = ctx.values.level; \nvar maxLevel = ctx.properties.maxLevel;\n\nvar newLevel = Math.min(maxLevel, level + 5);\nctx.api.setValue('level', newLevel);\nctx.api.callAction(event, 'levelUpdateState', newLevel, {\n error: () => {\n ctx.api.setValue('level', level);\n }\n});" - } - } - }, - { - "tag": "levelDownButton", - "actions": { - "click": { - "actionFunction": "var level = ctx.values.level; \nvar minLevel = ctx.properties.minLevel;\n\nvar newLevel = Math.max(minLevel, level - 5);\nctx.api.setValue('level', newLevel);\nctx.api.callAction(event, 'levelUpdateState', newLevel, {\n error: () => {\n ctx.api.setValue('level', level);\n }\n});" - } - } +<svg xmlns="http://www.w3.org/2000/svg" xmlns:tb="https://thingsboard.io/svg" width="100" height="100" version="1.1" viewBox="0 0 100 100"> +<tb:metadata xmlns=""><![CDATA[{ + "title": "Level and Fan12", + "stateRenderFunction": "var showMinMaxLevel = ctx.properties.showMinMaxLevel;\nvar minLevelElement = ctx.tags.minLevel[0];\nvar maxLevelElement = ctx.tags.maxLevel[0];\nvar minLevel = ctx.properties.minLevel; \nvar maxLevel = ctx.properties.maxLevel;\n\nif (showMinMaxLevel) {\n \n var minMaxLevelFont = ctx.properties.minMaxLevelFont;\n var minMaxLevelColor = ctx.properties.minMaxLevelColor;\n \n ctx.api.text(minLevelElement, minLevel);\n ctx.api.text(maxLevelElement, maxLevel);\n \n ctx.api.font(minLevelElement, minMaxLevelFont, minMaxLevelColor);\n ctx.api.font(maxLevelElement, minMaxLevelFont, minMaxLevelColor);\n \n} else {\n minLevelElement.hide();\n maxLevelElement.hide();\n}\n\nvar disabled = ctx.values.disabled;\nvar on = ctx.values.on;\nvar level = ctx.values.level;\n\nvar onButton = ctx.tags.onButton;\nvar offButton = ctx.tags.offButton;\nvar levelUpButton = ctx.tags.levelUpButton;\nvar levelDownButton = ctx.tags.levelDownButton;\n\nvar onButtonEnabled = !disabled && !on;\nvar offButtonEnabled = !disabled && on;\nvar levelUpEnabled = !disabled && level < maxLevel;\nvar levelDownEnabled = !disabled && level > minLevel;\n\nif (onButtonEnabled) {\n ctx.api.enable(onButton);\n onButton[0].findOne('rect').attr({fill: '#12ed19'});\n} else {\n ctx.api.disable(onButton);\n onButton[0].findOne('rect').attr({fill: '#777'});\n}\n \nif (offButtonEnabled) {\n ctx.api.enable(offButton);\n offButton[0].findOne('rect').attr({fill: '#ed121f'});\n} else {\n ctx.api.disable(offButton);\n offButton[0].findOne('rect').attr({fill: '#777'});\n} \n\n\nif (levelUpEnabled) {\n ctx.api.enable(levelUpButton);\n levelUpButton[0].findOne('rect').attr({fill: '#fff'});\n} else {\n ctx.api.disable(levelUpButton);\n levelUpButton[0].findOne('rect').attr({fill: '#777'});\n}\n \nif (levelDownEnabled) {\n ctx.api.enable(levelDownButton);\n levelDownButton[0].findOne('rect').attr({fill: '#fff'});\n} else {\n ctx.api.disable(levelDownButton);\n levelDownButton[0].findOne('rect').attr({fill: '#777'});\n}", + "tags": [ + { + "tag": "level", + "stateRenderFunction": "var level = ctx.values.level; \nvar disabled = ctx.values.disabled;\nvar minLevel = ctx.properties.minLevel; \nvar maxLevel = ctx.properties.maxLevel;\n\nvar height = (level - minLevel) / (maxLevel - minLevel);\nheight = Math.max(0, Math.min(1, height))*80; \n\nctx.api.animate(element, 200).attr({height: height});\n\nvar fill;\nif (disabled) {\n fill = ctx.properties.disabledLevelBackground;\n} else {\n var colorProcessor = ctx.properties.levelBackground;\n colorProcessor.update(level);\n fill = colorProcessor.color;\n}\n\nelement.attr({fill: fill});" + }, + { + "tag": "levelTitle", + "stateRenderFunction": "var showLevelTitle = ctx.properties.showLevelTitle;\n\nif (showLevelTitle) {\n var levelTitle = ctx.properties.levelTitle;\n var levelTitleFont = ctx.properties.levelTitleFont;\n var levelTitleColor = ctx.properties.levelTitleColor;\n \n ctx.api.text(element, levelTitle);\n ctx.api.font(element, levelTitleFont, levelTitleColor);\n \n} else {\n element.hide();\n}" + }, + { + "tag": "levelValue", + "stateRenderFunction": "var showValue = ctx.properties.showValue;\n\nif (showValue) {\n var valueFont = ctx.properties.valueFont;\n var valueColor = ctx.properties.valueColor;\n var level = ctx.values.level; \n \n var levelText = ctx.api.formatValue(level, ctx.properties.valueDecimals, ctx.properties.valueUnits, false);\n\n ctx.api.font(element, valueFont, valueColor);\n ctx.api.text(element, levelText);\n\n} else {\n element.hide();\n ctx.tags.levelValueBackground[0].hide();\n}", + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'levelValueClick');" } - ], - "behavior": [ - { - "id": "level", - "name": "Level", - "type": "value", - "valueType": "DOUBLE", - "defaultValue": 0 - }, - { - "id": "disabled", - "name": "{i18n:widgets.rpc-state.disabled-state}", - "hint": "{i18n:widgets.rpc-state.disabled-state-hint}", - "stateLabel": "{i18n:widgets.rpc-state.disabled}", - "type": "value", - "valueType": "BOOLEAN", - "defaultValue": false - }, - { - "id": "on", - "name": "On/Off state", - "trueLabel": "{i18n:widgets.rpc-state.on}", - "falseLabel": "{i18n:widgets.rpc-state.off}", - "stateLabel": "{i18n:widgets.rpc-state.on}", - "type": "value", - "valueType": "BOOLEAN", - "defaultValue": false - }, - { - "id": "onUpdateState", - "name": "{i18n:widgets.rpc-state.turn-on}", - "hint": "{i18n:widgets.rpc-state.turn-on-hint}", - "type": "action", - "valueType": "BOOLEAN", - "valueToDataType": "CONSTANT", - "constantValue": true - }, - { - "id": "offUpdateState", - "name": "{i18n:widgets.rpc-state.turn-off}", - "hint": "{i18n:widgets.rpc-state.turn-off-hint}", - "type": "action", - "valueType": "BOOLEAN", - "valueToDataType": "CONSTANT", - "constantValue": false - }, - { - "id": "levelUpdateState", - "name": "Update level", - "type": "action", - "valueType": "DOUBLE", - "valueToDataType": "VALUE", - "constantValue": 0 - }, - { - "id": "levelValueClick", - "name": "On level value click", - "type": "widgetAction" + } + }, + { + "tag": "fan", + "stateRenderFunction": "var on = ctx.values.on;\nif (on) {\n var level = ctx.values.level; \n var minLevel = ctx.properties.minLevel; \n var maxLevel = ctx.properties.maxLevel;\n\n var speed = (level - minLevel) / (maxLevel - minLevel);\n speed = Math.max(0, Math.min(1, speed))*2; \n\n var t = element.timeline();\n if (!t.active()) {\n ctx.api.animate(element, 1000).ease('-').rotate(360).loop();\n t = element.timeline();\n }\n t.speed(speed);\n} else {\n element.timeline().stop();\n}\n", + "actions": null + }, + { + "tag": "onButton", + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'onUpdateState', undefined, {\n next: () => {\n ctx.api.setValue('on', true);\n }\n});\n\n" } - ], - "properties": [ - { - "id": "showLevelTitle", - "name": "Level title", - "type": "switch", - "default": true, - "rowClass": "column-xs" - }, - { - "id": "levelTitle", - "name": "Level title", - "type": "string", - "default": "{i18n:widgets.battery-level.value}", - "disableOnProperty": "showLevelTitle", - "fieldClass": "flex" - }, - { - "id": "levelTitleFont", - "name": "Level title", - "type": "font", - "default": { - "size": 6, - "sizeUnit": "px", - "family": "Roboto", - "weight": "normal", - "style": "normal" - }, - "disableOnProperty": "showLevelTitle" - }, - { - "id": "levelTitleColor", - "name": "Level title", - "type": "color", - "default": "#000000", - "disableOnProperty": "showLevelTitle" - }, - { - "id": "showValue", - "name": "Value", - "type": "switch", - "default": true, - "rowClass": "column-xs" - }, - { - "id": "valueUnits", - "name": "Value", - "type": "units", - "default": "", - "disableOnProperty": "showValue", - "fieldClass": "flex" - }, - { - "id": "valueDecimals", - "name": "Value", - "type": "number", - "default": 2, - "min": 0, - "max": 15, - "step": 1, - "fieldSuffix": "decimals", - "disableOnProperty": "showValue", - "fieldClass": "flex" - }, - { - "id": "valueFont", - "name": "Value", - "type": "font", - "default": { - "size": 6, - "sizeUnit": "px", - "family": "Roboto", - "weight": "normal", - "style": "normal" - }, - "disableOnProperty": "showValue" - }, - { - "id": "valueColor", - "name": "Value", - "type": "color", - "default": "#000000", - "disableOnProperty": "showValue" - }, - { - "id": "minLevel", - "name": "Level range", - "subLabel": "min", - "type": "number", - "required": true, - "default": 0, - "min": 0, - "rowClass": "column-xs", - "fieldClass": "flex-xs" - }, - { - "id": "maxLevel", - "name": "Level range", - "subLabel": "max", - "type": "number", - "required": true, - "default": 100, - "min": 0, - "fieldClass": "flex-xs" - }, - { - "id": "showMinMaxLevel", - "name": "Min/Max label", - "type": "switch", - "default": true - }, - { - "id": "minMaxLevelFont", - "name": "Min/Max label", - "type": "font", - "default": { - "size": 6, - "sizeUnit": "px", - "family": "Roboto", - "weight": "normal", - "style": "normal" - }, - "disableOnProperty": "showMinMaxLevel" - }, - { - "id": "minMaxLevelColor", - "name": "Min/Max label", - "type": "color", - "default": "#666", - "disableOnProperty": "showMinMaxLevel" - }, - { - "id": "levelBackground", - "name": "Level background", - "subLabel": "Enabled", - "type": "color-settings", - "default": { - "type": "constant", - "color": "#1abb48", - "colorFunction": "return value &gt; 70 ? '#d5280d' : '#1abb48';" - }, - "divider": true, - "rowClass": "column-xs" - }, - { - "id": "disabledLevelBackground", - "name": "Level background", - "subLabel": "Disabled", - "type": "color", - "default": "#ccc" - } - ] - } - - - - - min - - + } + }, + { + "tag": "offButton", + "actions": { + "click": { + "actionFunction": "ctx.api.callAction(event, 'offUpdateState', undefined, {\n next: () => {\n ctx.api.setValue('on', false);\n }\n});\n" + } + } + }, + { + "tag": "levelUpButton", + "actions": { + "click": { + "actionFunction": "var level = ctx.values.level; \nvar maxLevel = ctx.properties.maxLevel;\n\nvar newLevel = Math.min(maxLevel, level + 5);\nctx.api.setValue('level', newLevel);\nctx.api.callAction(event, 'levelUpdateState', newLevel, {\n error: () => {\n ctx.api.setValue('level', level);\n }\n});" + } + } + }, + { + "tag": "levelDownButton", + "actions": { + "click": { + "actionFunction": "var level = ctx.values.level; \nvar minLevel = ctx.properties.minLevel;\n\nvar newLevel = Math.max(minLevel, level - 5);\nctx.api.setValue('level', newLevel);\nctx.api.callAction(event, 'levelUpdateState', newLevel, {\n error: () => {\n ctx.api.setValue('level', level);\n }\n});" + } + } + }, + { + "tag": "ramka", + "stateRenderFunction": "var on = ctx.values.on;\nif (on) {\n if (!element.timeline().active()) {\n ctx.api.animate(element, 1000).scale(1.1).loop(0, true);\n }\n} else {\n element.timeline().stop();\n}", + "actions": null + } + ], + "behavior": [ + { + "id": "level", + "name": "Level", + "hint": null, + "type": "value", + "valueType": "DOUBLE", + "defaultValue": 10, + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "valueToDataType": null, + "constantValue": null, + "valueToDataFunction": null + }, + { + "id": "disabled", + "name": "{i18n:widgets.rpc-state.disabled-state}", + "hint": "{i18n:widgets.rpc-state.disabled-state-hint}", + "type": "value", + "valueType": "BOOLEAN", + "defaultValue": false, + "trueLabel": "", + "falseLabel": "", + "stateLabel": "{i18n:widgets.rpc-state.disabled}", + "valueToDataType": null, + "constantValue": null, + "valueToDataFunction": null + }, + { + "id": "on", + "name": "On/Off state", + "hint": null, + "type": "value", + "valueType": "BOOLEAN", + "defaultValue": true, + "trueLabel": "{i18n:widgets.rpc-state.on}", + "falseLabel": "{i18n:widgets.rpc-state.off}", + "stateLabel": "{i18n:widgets.rpc-state.on}", + "valueToDataType": null, + "constantValue": null, + "valueToDataFunction": null + }, + { + "id": "onUpdateState", + "name": "{i18n:widgets.rpc-state.turn-on}", + "hint": "{i18n:widgets.rpc-state.turn-on-hint}", + "type": "action", + "valueType": "BOOLEAN", + "valueToDataType": "CONSTANT", + "constantValue": true + }, + { + "id": "offUpdateState", + "name": "{i18n:widgets.rpc-state.turn-off}", + "hint": "{i18n:widgets.rpc-state.turn-off-hint}", + "type": "action", + "valueType": "BOOLEAN", + "valueToDataType": "CONSTANT", + "constantValue": false + }, + { + "id": "levelUpdateState", + "name": "Update level", + "type": "action", + "valueType": "DOUBLE", + "valueToDataType": "VALUE", + "constantValue": 0 + }, + { + "id": "levelValueClick", + "name": "On level value click", + "type": "widgetAction" + } + ], + "properties": [ + { + "id": "showLevelTitle", + "name": "Level title", + "type": "switch", + "default": true, + "rowClass": "column-xs" + }, + { + "id": "levelTitle", + "name": "Level title", + "type": "text", + "default": "{i18n:widgets.battery-level.value}", + "disableOnProperty": "showLevelTitle", + "fieldClass": "flex" + }, + { + "id": "levelTitleFont", + "name": "Level title", + "type": "font", + "default": { + "size": 6, + "sizeUnit": "px", + "family": "Roboto", + "weight": "normal", + "style": "normal" + }, + "disableOnProperty": "showLevelTitle" + }, + { + "id": "levelTitleColor", + "name": "Level title", + "type": "color", + "default": "#000000", + "disableOnProperty": "showLevelTitle" + }, + { + "id": "showValue", + "name": "Value", + "type": "switch", + "default": true, + "rowClass": "column-xs" + }, + { + "id": "valueUnits", + "name": "Value", + "type": "units", + "default": "", + "disableOnProperty": "showValue", + "fieldClass": "flex" + }, + { + "id": "valueDecimals", + "name": "Value", + "type": "number", + "default": 2, + "min": 0, + "max": 15, + "step": 1, + "fieldSuffix": "decimals", + "disableOnProperty": "showValue", + "fieldClass": "flex" + }, + { + "id": "valueFont", + "name": "Value", + "type": "font", + "default": { + "size": 6, + "sizeUnit": "px", + "family": "Roboto", + "weight": "normal", + "style": "normal" + }, + "disableOnProperty": "showValue" + }, + { + "id": "valueColor", + "name": "Value", + "type": "color", + "default": "#000000", + "disableOnProperty": "showValue" + }, + { + "id": "minLevel", + "name": "Level range", + "subLabel": "min", + "type": "number", + "required": true, + "default": 0, + "min": 0, + "rowClass": "column-xs", + "fieldClass": "flex-xs" + }, + { + "id": "maxLevel", + "name": "Level range", + "subLabel": "max", + "type": "number", + "required": true, + "default": 100, + "min": 0, + "fieldClass": "flex-xs" + }, + { + "id": "showMinMaxLevel", + "name": "Min/Max label", + "type": "switch", + "default": true + }, + { + "id": "minMaxLevelFont", + "name": "Min/Max label", + "type": "font", + "default": { + "size": 6, + "sizeUnit": "px", + "family": "Roboto", + "weight": "normal", + "style": "normal" + }, + "disableOnProperty": "showMinMaxLevel" + }, + { + "id": "minMaxLevelColor", + "name": "Min/Max label", + "type": "color", + "default": "#666", + "disableOnProperty": "showMinMaxLevel" + }, + { + "id": "levelBackground", + "name": "Level background", + "subLabel": "Enabled", + "type": "color_settings", + "default": { + "type": "constant", + "color": "#1abb48", + "colorFunction": "return value > 70 ? '#d5280d' : '#1abb48';" + }, + "divider": true, + "rowClass": "column-xs" + }, + { + "id": "disabledLevelBackground", + "name": "Level background", + "subLabel": "Disabled", + "type": "color", + "default": "#ccc" + } + ] +}]]> + min N/A Level max - - - - + On - - + Off - - + - - + - + \ No newline at end of file From 1aca952eca39dedfb134f96de41aaf646edd5d60 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Mon, 27 May 2024 18:17:23 +0300 Subject: [PATCH 042/442] UI: Implement SCADA symbol properties section. --- .../widget/lib/svg/iot-svg.models.ts | 6 + ...scada-symbol-behavior-panel.component.html | 2 +- .../scada-symbol-behavior-row.component.ts | 70 ++++- .../scada-symbol-behaviors.component.html | 2 + .../scada-symbol-behaviors.component.ts | 36 ++- ...scada-symbol-metadata-components.module.ts | 22 +- .../scada-symbol-metadata.component.html | 5 + .../scada-symbol-metadata.component.ts | 1 - .../scada-symbol-properties.component.html | 63 ++++ .../scada-symbol-properties.component.scss | 43 +++ .../scada-symbol-properties.component.ts | 207 +++++++++++++ ...scada-symbol-property-panel.component.html | 184 ++++++++++++ ...scada-symbol-property-panel.component.scss | 49 +++ .../scada-symbol-property-panel.component.ts | 129 ++++++++ .../scada-symbol-property-row.component.html | 49 +++ .../scada-symbol-property-row.component.scss | 26 ++ .../scada-symbol-property-row.component.ts | 280 ++++++++++++++++++ .../scada-symbol/scada-symbol.component.ts | 3 + .../assets/locale/locale.constant-en_US.json | 19 +- 19 files changed, 1168 insertions(+), 28 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-properties.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-properties.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-properties.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-panel.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-panel.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-panel.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-row.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-row.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-row.component.ts diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts index 5c463b7165..c77065fbf8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts @@ -145,6 +145,12 @@ export const iotSvgPropertyTypeTranslations = new Mapscada.behavior.value-type
- +
{{ valueTypesMap.get(behaviorFormGroup.get('valueType').value).name | translate }} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-row.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-row.component.ts index 1fba319cb2..12bc0d2ecc 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-row.component.ts +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-row.component.ts @@ -30,11 +30,11 @@ import { } from '@angular/core'; import { AbstractControl, - ControlValueAccessor, + ControlValueAccessor, NG_VALIDATORS, NG_VALUE_ACCESSOR, - UntypedFormBuilder, + UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, - ValidationErrors, + ValidationErrors, Validator, ValidatorFn, Validators } from '@angular/forms'; import { @@ -50,6 +50,9 @@ import { ScadaSymbolBehaviorPanelComponent } from '@home/pages/scada-symbol/metadata-components/scada-symbol-behavior-panel.component'; import { ValueToDataType } from '@shared/models/action-widget-settings.models'; +import { + ScadaSymbolBehaviorsComponent +} from '@home/pages/scada-symbol/metadata-components/scada-symbol-behaviors.component'; export const behaviorValid = (behavior: IotSvgBehavior): boolean => { if (!behavior.id || !behavior.name || !behavior.type) { @@ -80,16 +83,6 @@ export const behaviorValid = (behavior: IotSvgBehavior): boolean => { return true; }; -export const behaviorValidator = (control: AbstractControl): ValidationErrors | null => { - const behavior: IotSvgBehavior = control.value; - if (!behaviorValid(behavior)) { - return { - behavior: true - }; - } - return null; -}; - @Component({ selector: 'tb-scada-symbol-metadata-behavior-row', templateUrl: './scada-symbol-behavior-row.component.html', @@ -99,11 +92,16 @@ export const behaviorValidator = (control: AbstractControl): ValidationErrors | provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => ScadaSymbolBehaviorRowComponent), multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => ScadaSymbolBehaviorRowComponent), + multi: true } ], encapsulation: ViewEncapsulation.None }) -export class ScadaSymbolBehaviorRowComponent implements ControlValueAccessor, OnInit { +export class ScadaSymbolBehaviorRowComponent implements ControlValueAccessor, OnInit, Validator { @ViewChild('idInput') idInput: ElementRef; @@ -117,6 +115,9 @@ export class ScadaSymbolBehaviorRowComponent implements ControlValueAccessor, On @Input() disabled: boolean; + @Input() + index: number; + @Output() behaviorRemoved = new EventEmitter(); @@ -130,12 +131,13 @@ export class ScadaSymbolBehaviorRowComponent implements ControlValueAccessor, On private cd: ChangeDetectorRef, private popoverService: TbPopoverService, private renderer: Renderer2, - private viewContainerRef: ViewContainerRef) { + private viewContainerRef: ViewContainerRef, + private behaviorsComponent: ScadaSymbolBehaviorsComponent) { } ngOnInit() { this.behaviorRowFormGroup = this.fb.group({ - id: [null, [Validators.required]], + id: [null, [this.behaviorIdValidator()]], name: [null, [Validators.required]], type: [null, [Validators.required]] }); @@ -223,6 +225,42 @@ export class ScadaSymbolBehaviorRowComponent implements ControlValueAccessor, On this.editBehavior(null, this.editButton, true, onCanceled); } + public validate(c: UntypedFormControl) { + const idControl = this.behaviorRowFormGroup.get('id'); + if (idControl.hasError('behaviorIdNotUnique')) { + idControl.updateValueAndValidity({onlySelf: false, emitEvent: false}); + } + if (idControl.hasError('behaviorIdNotUnique')) { + this.behaviorRowFormGroup.get('id').markAsTouched(); + return { + behaviorIdNotUnique: true + }; + } + const behavior: IotSvgBehavior = {...this.modelValue, ...this.behaviorRowFormGroup.value}; + if (!behaviorValid(behavior)) { + return { + behavior: true + }; + } + return null; + } + + private behaviorIdValidator(): ValidatorFn { + return control => { + if (!control.value) { + return { + required: true + }; + } + if (!this.behaviorsComponent.behaviorIdUnique(control.value, this.index)) { + return { + behaviorIdNotUnique: true + }; + } + return null; + }; + } + private onTypeChanged(newType: IotSvgBehaviorType) { const prevType = this.modelValue.type; this.modelValue = {...this.modelValue, ...{type: newType}}; diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behaviors.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behaviors.component.html index 28efaca269..7d5991ed4b 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behaviors.component.html +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behaviors.component.html @@ -31,6 +31,7 @@ class="tb-draggable-form-table-row" *ngFor="let behaviorControl of behaviorsFormArray().controls; trackBy: trackByBehavior; let $index = index;"> @@ -46,6 +47,7 @@
+
+
+ + +
diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.ts index 96f4b0b184..75e6fcc8bb 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.ts +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.ts @@ -128,7 +128,6 @@ export class ScadaSymbolMetadataComponent extends PageComponent implements OnIni } writeValue(value: IotSvgMetadata): void { - this.selectedOption = 'general'; this.modelValue = value; this.metadataFormGroup.patchValue( value, {emitEvent: false} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-properties.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-properties.component.html new file mode 100644 index 0000000000..c0f3316bbe --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-properties.component.html @@ -0,0 +1,63 @@ + +
+
+
+
scada.property.id
+
scada.property.name
+
scada.property.type
+
+
+
+
+ + +
+ +
+
+
+ +
+
+ +
+
+ + + {{ 'scada.property.no-properties' | translate }} + diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-properties.component.scss b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-properties.component.scss new file mode 100644 index 0000000000..9bafbf2ccb --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-properties.component.scss @@ -0,0 +1,43 @@ +/** + * Copyright ยฉ 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +.tb-scada-symbol-properties { + flex: 1; + margin: 12px; + .tb-form-table-header-cell { + &.tb-id-header { + flex: 1 1 40%; + } + &.tb-name-header { + flex: 1 1 40%; + } + &.tb-type-header { + flex: 1 1 20%; + } + &.tb-actions-header { + width: 120px; + min-width: 120px; + } + } + .tb-form-table { + overflow: hidden; + } + .tb-form-table-body { + overflow: auto; + tb-scada-symbol-metadata-property-row { + overflow: hidden; + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-properties.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-properties.component.ts new file mode 100644 index 0000000000..a36536464f --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-properties.component.ts @@ -0,0 +1,207 @@ +/// +/// Copyright ยฉ 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + Component, + forwardRef, + HostBinding, + Input, + OnInit, + QueryList, + ViewChildren, + ViewEncapsulation +} from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormArray, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validator +} from '@angular/forms'; +import { IotSvgProperty, IotSvgPropertyType } from '@home/components/widget/lib/svg/iot-svg.models'; +import { CdkDragDrop } from '@angular/cdk/drag-drop'; +import { + propertyValid, + ScadaSymbolPropertyRowComponent +} from '@home/pages/scada-symbol/metadata-components/scada-symbol-property-row.component'; +import { TranslateService } from '@ngx-translate/core'; + +@Component({ + selector: 'tb-scada-symbol-metadata-properties', + templateUrl: './scada-symbol-properties.component.html', + styleUrls: ['./scada-symbol-properties.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ScadaSymbolPropertiesComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => ScadaSymbolPropertiesComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class ScadaSymbolPropertiesComponent implements ControlValueAccessor, OnInit, Validator { + + @HostBinding('style.display') styleDisplay = 'flex'; + @HostBinding('style.overflow') styleOverflow = 'hidden'; + + @ViewChildren(ScadaSymbolPropertyRowComponent) + propertyRows: QueryList; + + @Input() + disabled: boolean; + + booleanPropertyIds: string[] = []; + + propertiesFormGroup: UntypedFormGroup; + + errorText = ''; + + get dragEnabled(): boolean { + return this.propertiesFormArray().controls.length > 1; + } + + private propagateChange = (_val: any) => {}; + + constructor(private fb: UntypedFormBuilder, + private translate: TranslateService) { + } + + ngOnInit() { + this.propertiesFormGroup = this.fb.group({ + properties: this.fb.array([]) + }); + this.propertiesFormGroup.valueChanges.subscribe( + () => { + let properties: IotSvgProperty[] = this.propertiesFormGroup.get('properties').value; + if (properties) { + properties = properties.filter(p => propertyValid(p)); + } + this.booleanPropertyIds = properties.filter(p => p.type === IotSvgPropertyType.switch).map(p => p.id); + this.propagateChange(properties); + } + ); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.propertiesFormGroup.disable({emitEvent: false}); + } else { + this.propertiesFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: IotSvgProperty[] | undefined): void { + const properties= value || []; + this.propertiesFormGroup.setControl('properties', this.preparePropertiesFormArray(properties), {emitEvent: false}); + this.booleanPropertyIds = properties.filter(p => p.type === IotSvgPropertyType.switch).map(p => p.id); + } + + public validate(c: UntypedFormControl) { + this.errorText = ''; + const propertiesArray = this.propertiesFormGroup.get('properties') as UntypedFormArray; + const notUniqueControls = + propertiesArray.controls.filter(control => control.hasError('propertyIdNotUnique')); + for (const control of notUniqueControls) { + control.updateValueAndValidity({onlySelf: false, emitEvent: false}); + if (control.hasError('propertyIdNotUnique')) { + this.errorText = this.translate.instant('scada.property.not-unique-property-ids-error'); + } + } + const valid = this.propertiesFormGroup.valid; + return valid ? null : { + properties: { + valid: false, + }, + }; + } + + public propertyIdUnique(id: string, index: number): boolean { + const propertiesArray = this.propertiesFormGroup.get('properties') as UntypedFormArray; + for (let i = 0; i < propertiesArray.controls.length; i++) { + if (i !== index) { + const otherControl = propertiesArray.controls[i]; + if (id === otherControl.value.id) { + return false; + } + } + } + return true; + } + + propertyDrop(event: CdkDragDrop) { + const propertiesArray = this.propertiesFormGroup.get('properties') as UntypedFormArray; + const property = propertiesArray.at(event.previousIndex); + propertiesArray.removeAt(event.previousIndex); + propertiesArray.insert(event.currentIndex, property); + } + + propertiesFormArray(): UntypedFormArray { + return this.propertiesFormGroup.get('properties') as UntypedFormArray; + } + + trackByProperty(index: number, propertyControl: AbstractControl): any { + return propertyControl; + } + + removeProperty(index: number, emitEvent = true) { + (this.propertiesFormGroup.get('properties') as UntypedFormArray).removeAt(index, {emitEvent}); + } + + addProperty() { + const property: IotSvgProperty = { + id: '', + name: '', + type: IotSvgPropertyType.text, + default: '' + }; + const propertiesArray = this.propertiesFormGroup.get('properties') as UntypedFormArray; + const propertyControl = this.fb.control(property, []); + propertiesArray.push(propertyControl); + setTimeout(() => { + const propertyRow = this.propertyRows.get(this.propertyRows.length-1); + propertyRow.onAdd(() => { + this.removeProperty(propertiesArray.length-1); + }); + }); + } + + private preparePropertiesFormArray(properties: IotSvgProperty[] | undefined): UntypedFormArray { + const propertiesControls: Array = []; + if (properties) { + properties.forEach((property) => { + propertiesControls.push(this.fb.control(property, [])); + }); + } + return this.fb.array(propertiesControls); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-panel.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-panel.component.html new file mode 100644 index 0000000000..8a76578487 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-panel.component.html @@ -0,0 +1,184 @@ + +
+
{{ panelTitle | translate }}
+
+
+
scada.property.id
+ + + +
+
+
scada.property.name
+ + + +
+
+
scada.property.type
+ + + + {{ iotSvgPropertyTypeTranslations.get(type) | translate }} + + + +
+
+
scada.property.default-value
+ + + + + + + + + + + + + + +
+
+
scada.property.number-settings
+
+
scada.property.min
+ + + + +
scada.property.max
+ + + + +
scada.property.step
+ + + +
+
+
+ + {{ 'scada.property.value-required' | translate }} + +
+
+ + + + {{ 'scada.property.advanced-ui-settings' | translate }} + + + +
+
scada.property.sub-label
+ + + +
+
+ + {{ 'scada.property.vertical-divider-after' | translate }} + +
+
+
scada.property.input-field-suffix
+ + + +
+
+
scada.property.disable-on-property
+ + + + {{ prop }} + + + +
+
+
scada.property.property-row-classes
+ + + + {{ clazz }} + + + +
+
+
scada.property.property-field-classes
+ + + + {{ clazz }} + + + +
+
+
+
+
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-panel.component.scss b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-panel.component.scss new file mode 100644 index 0000000000..c4b5b153d9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-panel.component.scss @@ -0,0 +1,49 @@ +/** + * Copyright ยฉ 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../../../../../../scss/constants'; + +.tb-scada-symbol-property-settings-panel { + width: 540px; + display: flex; + flex-direction: column; + gap: 16px; + @media #{$mat-lt-md} { + width: 90vw; + } + .tb-scada-symbol-property-settings-panel-content { + display: flex; + flex-direction: column; + gap: 16px; + overflow: auto; + margin: -10px; + padding: 10px; + } + .tb-scada-symbol-property-settings-title { + font-size: 16px; + font-weight: 500; + line-height: 24px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.87); + } + .tb-scada-symbol-property-settings-panel-buttons { + height: 40px; + display: flex; + flex-direction: row; + gap: 16px; + justify-content: flex-end; + align-items: flex-end; + } +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-panel.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-panel.component.ts new file mode 100644 index 0000000000..9be8959dca --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-panel.component.ts @@ -0,0 +1,129 @@ +/// +/// Copyright ยฉ 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { + IotSvgProperty, iotSvgPropertyFieldClasses, iotSvgPropertyRowClasses, + IotSvgPropertyType, + iotSvgPropertyTypes, + iotSvgPropertyTypeTranslations +} from '@home/components/widget/lib/svg/iot-svg.models'; +import { WidgetService } from '@core/http/widget.service'; +import { defaultPropertyValue } from '@home/pages/scada-symbol/metadata-components/scada-symbol-property-row.component'; +import { ValueType } from '@shared/models/constants'; + +@Component({ + selector: 'tb-scada-symbol-property-panel', + templateUrl: './scada-symbol-property-panel.component.html', + styleUrls: ['./scada-symbol-property-panel.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class ScadaSymbolPropertyPanelComponent implements OnInit { + + ValueType = ValueType; + + IotSvgPropertyType = IotSvgPropertyType; + + iotSvgPropertyTypes = iotSvgPropertyTypes; + iotSvgPropertyTypeTranslations = iotSvgPropertyTypeTranslations; + + iotSvgPropertyRowClasses = iotSvgPropertyRowClasses; + + iotSvgPropertyFieldClasses = iotSvgPropertyFieldClasses; + + @Input() + isAdd = false; + + @Input() + property: IotSvgProperty; + + @Input() + booleanPropertyIds: string[]; + + @Input() + popover: TbPopoverComponent; + + @Output() + propertySettingsApplied = new EventEmitter(); + + panelTitle: string; + + propertyFormGroup: UntypedFormGroup; + + private propertyType: IotSvgPropertyType; + + constructor(private fb: UntypedFormBuilder, + private widgetService: WidgetService) { + } + + ngOnInit(): void { + this.panelTitle = this.isAdd ? 'scada.property.add-property' : 'scada.property.property-settings'; + this.propertyType = this.property.type; + this.propertyFormGroup = this.fb.group( + { + id: [this.property.id, [Validators.required]], + name: [this.property.name, [Validators.required]], + type: [this.property.type, [Validators.required]], + default: [this.property.default, []], + required: [this.property.required, []], + subLabel: [this.property.subLabel, []], + divider: [this.property.divider, []], + fieldSuffix: [this.property.fieldSuffix, []], + disableOnProperty: [this.property.disableOnProperty, []], + rowClass: [(this.property.rowClass || '').split(' '), []], + fieldClass: [(this.property.fieldClass || '').split(' '), []], + min: [this.property.min, []], + max: [this.property.max, []], + step: [this.property.step, [Validators.min(0)]] + } + ); + this.propertyFormGroup.get('type').valueChanges.subscribe(() => { + this.updateValidators(); + }); + this.updateValidators(); + } + + cancel() { + this.popover?.hide(); + } + + applyPropertySettings() { + const property = this.propertyFormGroup.getRawValue(); + property.rowClass = (property.rowClass || []).join(' '); + property.fieldClass = (property.fieldClass || []).join(' '); + this.propertySettingsApplied.emit(property); + } + + private updateValidators() { + const type: IotSvgPropertyType = this.propertyFormGroup.get('type').value; + if (type === IotSvgPropertyType.number) { + this.propertyFormGroup.get('min').enable({emitEvent: false}); + this.propertyFormGroup.get('max').enable({emitEvent: false}); + this.propertyFormGroup.get('step').enable({emitEvent: false}); + } else { + this.propertyFormGroup.get('min').disable({emitEvent: false}); + this.propertyFormGroup.get('max').disable({emitEvent: false}); + this.propertyFormGroup.get('step').disable({emitEvent: false}); + } + if (this.propertyType !== type) { + const defaultValue = defaultPropertyValue(type); + this.propertyFormGroup.get('default').patchValue(defaultValue, {emitEvent: false}); + this.propertyType = type; + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-row.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-row.component.html new file mode 100644 index 0000000000..7016461cce --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-row.component.html @@ -0,0 +1,49 @@ + + diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-row.component.scss b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-row.component.scss new file mode 100644 index 0000000000..5c0b4f0166 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-row.component.scss @@ -0,0 +1,26 @@ +/** + * Copyright ยฉ 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +.tb-scada-symbol-metadata-property-row { + .tb-id-field { + flex: 1 1 40%; + } + .tb-name-field { + flex: 1 1 40%; + } + .tb-type-field { + flex: 1 1 20%; + } +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-row.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-row.component.ts new file mode 100644 index 0000000000..60a56aae50 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-row.component.ts @@ -0,0 +1,280 @@ +/// +/// Copyright ยฉ 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + ChangeDetectorRef, + Component, + ElementRef, + EventEmitter, + forwardRef, + Input, + OnInit, + Output, + Renderer2, + ViewChild, + ViewContainerRef, + ViewEncapsulation +} from '@angular/core'; +import { + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validator, + ValidatorFn, + Validators +} from '@angular/forms'; +import { + IotSvgProperty, + IotSvgPropertyType, + iotSvgPropertyTypes, + iotSvgPropertyTypeTranslations +} from '@home/components/widget/lib/svg/iot-svg.models'; +import { deepClone } from '@core/utils'; +import { MatButton } from '@angular/material/button'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { constantColor, Font } from '@shared/models/widget-settings.models'; +import { + ScadaSymbolPropertyPanelComponent +} from '@home/pages/scada-symbol/metadata-components/scada-symbol-property-panel.component'; +import { + ScadaSymbolPropertiesComponent +} from '@home/pages/scada-symbol/metadata-components/scada-symbol-properties.component'; + +export const propertyValid = (property: IotSvgProperty): boolean => !(!property.id || !property.name || !property.type); + +export const defaultPropertyValue = (type: IotSvgPropertyType): any => { + switch (type) { + case IotSvgPropertyType.text: + return ''; + case IotSvgPropertyType.number: + return 0; + case IotSvgPropertyType.switch: + return false; + case IotSvgPropertyType.color: + return '#000'; + case IotSvgPropertyType.color_settings: + return constantColor('#000'); + case IotSvgPropertyType.font: + return { + size: 12, + sizeUnit: 'px', + family: 'Roboto', + weight: 'normal', + style: 'normal', + lineHeight: '1' + } as Font; + case IotSvgPropertyType.units: + return ''; + } +}; + +@Component({ + selector: 'tb-scada-symbol-metadata-property-row', + templateUrl: './scada-symbol-property-row.component.html', + styleUrls: ['./scada-symbol-property-row.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ScadaSymbolPropertyRowComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => ScadaSymbolPropertyRowComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class ScadaSymbolPropertyRowComponent implements ControlValueAccessor, OnInit, Validator { + + @ViewChild('idInput') + idInput: ElementRef; + + @ViewChild('editButton') + editButton: MatButton; + + iotSvgPropertyTypes = iotSvgPropertyTypes; + iotSvgPropertyTypeTranslations = iotSvgPropertyTypeTranslations; + + @Input() + disabled: boolean; + + @Input() + index: number; + + @Input() + booleanPropertyIds: string[]; + + @Output() + propertyRemoved = new EventEmitter(); + + propertyRowFormGroup: UntypedFormGroup; + + modelValue: IotSvgProperty; + + private propagateChange = (_val: any) => {}; + + constructor(private fb: UntypedFormBuilder, + private cd: ChangeDetectorRef, + private popoverService: TbPopoverService, + private renderer: Renderer2, + private viewContainerRef: ViewContainerRef, + private propertiesComponent: ScadaSymbolPropertiesComponent) { + } + + ngOnInit() { + this.propertyRowFormGroup = this.fb.group({ + id: [null, [this.propertyIdValidator()]], + name: [null, [Validators.required]], + type: [null, [Validators.required]] + }); + this.propertyRowFormGroup.valueChanges.subscribe( + () => this.updateModel() + ); + this.propertyRowFormGroup.get('type').valueChanges.subscribe((newType: IotSvgPropertyType) => { + this.onTypeChanged(newType); + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.propertyRowFormGroup.disable({emitEvent: false}); + } else { + this.propertyRowFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: IotSvgProperty): void { + this.modelValue = value; + this.propertyRowFormGroup.patchValue( + { + id: value?.id, + name: value?.name, + type: value?.type + }, {emitEvent: false} + ); + this.cd.markForCheck(); + } + + editProperty($event: Event, matButton: MatButton, add = false, editCanceled = () => {}) { + if ($event) { + $event.stopPropagation(); + } + const trigger = matButton._elementRef.nativeElement; + if (this.popoverService.hasPopover(trigger)) { + this.popoverService.hidePopover(trigger); + } else { + const ctx: any = { + isAdd: add, + booleanPropertyIds: this.booleanPropertyIds, + property: deepClone(this.modelValue) + }; + const scadaSymbolPropertyPanelPopover = this.popoverService.displayPopover(trigger, this.renderer, + this.viewContainerRef, ScadaSymbolPropertyPanelComponent, ['leftOnly', 'leftTopOnly', 'leftBottomOnly'], true, null, + ctx, + {}, + {}, {}, true); + scadaSymbolPropertyPanelPopover.tbComponentRef.instance.popover = scadaSymbolPropertyPanelPopover; + scadaSymbolPropertyPanelPopover.tbComponentRef.instance.propertySettingsApplied.subscribe((property) => { + scadaSymbolPropertyPanelPopover.hide(); + this.propertyRowFormGroup.patchValue( + { + id: property.id, + name: property.name, + type: property.type + }, {emitEvent: false} + ); + this.modelValue = property; + this.propagateChange(this.modelValue); + }); + scadaSymbolPropertyPanelPopover.tbDestroy.subscribe(() => { + if (!propertyValid(this.modelValue)) { + editCanceled(); + } + }); + } + } + + focus() { + this.idInput.nativeElement.scrollIntoView(); + this.idInput.nativeElement.focus(); + } + + onAdd(onCanceled: () => void) { + this.idInput.nativeElement.scrollIntoView(); + this.editProperty(null, this.editButton, true, onCanceled); + } + + public validate(c: UntypedFormControl) { + const idControl = this.propertyRowFormGroup.get('id'); + if (idControl.hasError('propertyIdNotUnique')) { + idControl.updateValueAndValidity({onlySelf: false, emitEvent: false}); + } + if (idControl.hasError('propertyIdNotUnique')) { + this.propertyRowFormGroup.get('id').markAsTouched(); + return { + propertyIdNotUnique: true + }; + } + const property: IotSvgProperty = {...this.modelValue, ...this.propertyRowFormGroup.value}; + if (!propertyValid(property)) { + return { + property: true + }; + } + return null; + } + + private propertyIdValidator(): ValidatorFn { + return control => { + if (!control.value) { + return { + required: true + }; + } + if (!this.propertiesComponent.propertyIdUnique(control.value, this.index)) { + return { + propertyIdNotUnique: true + }; + } + return null; + }; + } + + private onTypeChanged(newType: IotSvgPropertyType) { + this.modelValue = {...this.modelValue, ...{type: newType}}; + this.modelValue.default = defaultPropertyValue(newType); + } + + private updateModel() { + const value: IotSvgProperty = this.propertyRowFormGroup.value; + this.modelValue = {...this.modelValue, ...value}; + this.propagateChange(this.modelValue); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.ts index c595f63071..086ca454d7 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.ts +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.ts @@ -320,6 +320,9 @@ export class ScadaSymbolComponent extends PageComponent } private reset(): void { + if (this.symbolMetadata) { + this.symbolMetadata.selectedOption = 'general'; + } this.previewMode = false; } diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 42c5a644c2..1d49829f21 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -3439,7 +3439,8 @@ "true-label": "True label", "false-label": "False label", "state-label": "State label", - "default-payload": "Default payload" + "default-payload": "Default payload", + "not-unique-behavior-ids-error": "Behavior Ids must be unique!" }, "property": { "property": "Property", @@ -3456,7 +3457,21 @@ "no-properties": "No properties configured", "add-property": "Add property", "property-settings": "Property settings", - "remove-property": "Remove property" + "remove-property": "Remove property", + "default-value": "Default value", + "value-required": "Value required", + "number-settings": "Number settings", + "min": "Min", + "max": "Max", + "step": "Step", + "advanced-ui-settings": "Advanced UI settings", + "disable-on-property": "Disable on property", + "sub-label": "Sub label", + "vertical-divider-after": "Vertical divider after", + "input-field-suffix": "Input field suffix", + "property-row-classes": "Property row classes", + "property-field-classes": "Property field classes", + "not-unique-property-ids-error": "Property Ids must be unique!" } }, "item": { From a4504ad9ce79e7c1897454471beef5793909b264 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Tue, 28 May 2024 16:49:18 +0300 Subject: [PATCH 043/442] UI: SCADA symbol functions completers and highlights. --- .../widget/lib/svg/iot-svg.models.ts | 18 +- .../scada-symbol-metadata-tag.component.html | 10 +- .../scada-symbol-metadata-tag.component.ts | 19 ++ .../scada-symbol-metadata-tags.component.html | 2 + .../scada-symbol-metadata-tags.component.ts | 7 + .../scada-symbol-metadata.component.html | 7 +- .../scada-symbol-metadata.component.scss | 36 +++ .../scada-symbol-metadata.component.ts | 72 ++++- .../pages/scada-symbol/scada-symbol.models.ts | 253 +++++++++++++++++- .../shared/components/js-func.component.ts | 34 ++- .../src/app/shared/models/ace/ace.models.ts | 6 + .../shared/models/ace/completion.models.ts | 4 + 12 files changed, 449 insertions(+), 19 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts index c77065fbf8..61fdcb9b58 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts @@ -61,13 +61,13 @@ export interface IotSvgContext { properties: {[id: string]: any}; } -export type IotSvgStateRenderFunction = (svg: Svg, ctx: IotSvgContext) => void; +export type IotSvgStateRenderFunction = (ctx: IotSvgContext, svg: Svg) => void; -export type IotSvgTagStateRenderFunction = (element: Element, ctx: IotSvgContext) => void; +export type IotSvgTagStateRenderFunction = (ctx: IotSvgContext, element: Element) => void; export type IotSvgActionTrigger = 'click'; -export type IotSvgActionFunction = (event: Event, ctx: IotSvgContext) => void; +export type IotSvgActionFunction = (ctx: IotSvgContext, event: Event) => void; export interface IotSvgAction { actionFunction?: string; action?: IotSvgActionFunction; @@ -421,13 +421,13 @@ export class IotSvgObject { } private prepareMetadata() { - this.metadata.stateRender = parseFunction(this.metadata.stateRenderFunction, ['svg', 'ctx']) || (() => {}); + this.metadata.stateRender = parseFunction(this.metadata.stateRenderFunction, ['ctx', 'svg']) || (() => {}); for (const tag of this.metadata.tags) { - tag.stateRender = parseFunction(tag.stateRenderFunction, ['element', 'ctx']) || (() => {}); + tag.stateRender = parseFunction(tag.stateRenderFunction, ['ctx', 'element']) || (() => {}); if (tag.actions) { for (const trigger of Object.keys(tag.actions)) { const action = tag.actions[trigger]; - action.action = parseFunction(action.actionFunction, ['event', 'ctx']) || (() => {}); + action.action = parseFunction(action.actionFunction, ['ctx', 'event']) || (() => {}); } } } @@ -483,7 +483,7 @@ export class IotSvgObject { elements.forEach(element => { element.attr('cursor', 'pointer'); element.on(trigger, (event) => { - action.action(event, this.context); + action.action(this.context, event); }); }); } @@ -637,11 +637,11 @@ export class IotSvgObject { } private renderState(): void { - this.metadata.stateRender(this.svgShape, this.context); + this.metadata.stateRender(this.context, this.svgShape); for (const tag of this.metadata.tags) { const elements = this.context.tags[tag.tag];// this.svgShape.find(`[tb\\:tag="${tag.tag}"]`); elements.forEach(element => { - tag.stateRender(element, this.context); + tag.stateRender(this.context, element); }); } } diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag.component.html index 01914d1e32..88302152c5 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag.component.html +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag.component.html @@ -49,7 +49,10 @@ @@ -65,7 +68,10 @@ diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag.component.ts index 663a87d8fa..7b5e36266b 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag.component.ts +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag.component.ts @@ -38,6 +38,13 @@ import { IotSvgTag } from '@home/components/widget/lib/svg/iot-svg.models'; import { MatExpansionPanel } from '@angular/material/expansion'; import { JsFuncComponent } from '@shared/components/js-func.component'; import { MatSelect } from '@angular/material/select'; +import { TbEditorCompleter } from '@shared/models/ace/completion.models'; +import { + scadaSymbolClickActionHighlightRules, + scadaSymbolClickActionPropertiesHighlightRules, + scadaSymbolElementStateRenderHighlightRules, + scadaSymbolElementStateRenderPropertiesHighlightRules +} from '@home/pages/scada-symbol/scada-symbol.models'; @Component({ selector: 'tb-scada-symbol-metadata-tag', @@ -78,6 +85,12 @@ export class ScadaSymbolMetadataTagComponent implements ControlValueAccessor, On @Input() tags: string[]; + @Input() + elementStateRenderFunctionCompleter: TbEditorCompleter; + + @Input() + clickActionFunctionCompleter: TbEditorCompleter; + @Output() tagRemoved = new EventEmitter(); @@ -87,6 +100,12 @@ export class ScadaSymbolMetadataTagComponent implements ControlValueAccessor, On modelValue: IotSvgTag; + scadaSymbolElementStateRenderHighlightRules = scadaSymbolElementStateRenderHighlightRules; + scadaSymbolElementStateRenderPropertiesHighlightRules = scadaSymbolElementStateRenderPropertiesHighlightRules; + + scadaSymbolClickActionHighlightRules = scadaSymbolClickActionHighlightRules; + scadaSymbolClickActionPropertiesHighlightRules = scadaSymbolClickActionPropertiesHighlightRules; + private propagateChange = (_val: any) => {}; constructor(private fb: UntypedFormBuilder, diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tags.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tags.component.html index 5e3b49a4aa..6ce57301af 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tags.component.html +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tags.component.html @@ -19,6 +19,8 @@