Browse Source

Merge branch 'master' of github.com:thingsboard/thingsboard

pull/5496/head
Igor Kulikov 5 years ago
parent
commit
4991bb725d
  1. 3
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java
  2. 2
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeConfiguration.java
  3. 223
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java
  4. 3
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html
  5. 2
      ui-ngx/src/app/modules/home/components/home-components.module.ts
  6. 49
      ui-ngx/src/app/modules/home/components/profile/device/coap-device-profile-transport-configuration.component.html
  7. 49
      ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.html
  8. 60
      ui-ngx/src/app/modules/home/components/widget/legend-config-panel.component.html
  9. 32
      ui-ngx/src/app/modules/home/components/widget/legend-config-panel.component.scss
  10. 105
      ui-ngx/src/app/modules/home/components/widget/legend-config-panel.component.ts
  11. 46
      ui-ngx/src/app/modules/home/components/widget/legend-config.component.html
  12. 189
      ui-ngx/src/app/modules/home/components/widget/legend-config.component.ts
  13. 697
      ui-ngx/src/app/modules/home/components/widget/widget-config.component.html
  14. 58
      ui-ngx/src/app/modules/home/components/widget/widget-config.component.scss
  15. 37
      ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts
  16. 1
      ui-ngx/src/app/modules/home/components/widget/widget.component.ts
  17. 1
      ui-ngx/src/app/modules/home/models/widget-component.models.ts
  18. 9
      ui-ngx/src/app/shared/components/marked-options.service.ts
  19. 18
      ui-ngx/src/app/shared/components/material-icon-select.component.ts
  20. 43
      ui-ngx/src/app/shared/components/protobuf-content.component.html
  21. 57
      ui-ngx/src/app/shared/components/protobuf-content.component.scss
  22. 193
      ui-ngx/src/app/shared/components/protobuf-content.component.ts
  23. 4
      ui-ngx/src/app/shared/models/ace/ace.models.ts
  24. 7
      ui-ngx/src/app/shared/shared.module.ts
  25. 6
      ui-ngx/src/assets/locale/locale.constant-el_GR.json
  26. 10
      ui-ngx/src/assets/locale/locale.constant-en_US.json
  27. 4
      ui-ngx/src/assets/locale/locale.constant-fr_FR.json
  28. 2
      ui-ngx/src/assets/locale/locale.constant-ka_GE.json
  29. 4
      ui-ngx/src/assets/locale/locale.constant-lv_LV.json
  30. 12
      ui-ngx/src/assets/locale/locale.constant-ru_RU.json
  31. 12
      ui-ngx/src/assets/locale/locale.constant-uk_UA.json

3
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java

@ -182,7 +182,8 @@ public class TbHttpClient {
HttpMethod method = HttpMethod.valueOf(config.getRequestMethod());
HttpEntity<String> entity;
if(HttpMethod.GET.equals(method) || HttpMethod.HEAD.equals(method) ||
HttpMethod.OPTIONS.equals(method) || HttpMethod.TRACE.equals(method)) {
HttpMethod.OPTIONS.equals(method) || HttpMethod.TRACE.equals(method) ||
config.isIgnoreRequestBody()) {
entity = new HttpEntity<>(headers);
} else {
entity = new HttpEntity<>(msg.getData(), headers);

2
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeConfiguration.java

@ -47,6 +47,7 @@ public class TbRestApiCallNodeConfiguration implements NodeConfiguration<TbRestA
private String proxyPassword;
private String proxyScheme;
private ClientCredentials credentials;
private boolean ignoreRequestBody;
@Override
public TbRestApiCallNodeConfiguration defaultConfiguration() {
@ -61,6 +62,7 @@ public class TbRestApiCallNodeConfiguration implements NodeConfiguration<TbRestA
configuration.setTrimQueue(false);
configuration.setEnableProxy(false);
configuration.setCredentials(new AnonymousCredentials());
configuration.setIgnoreRequestBody(false);
return configuration;
}

223
rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java

@ -0,0 +1,223 @@
/**
* Copyright © 2016-2021 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.rule.engine.rest;
import static org.junit.Assert.*;
import static org.mockito.Mockito.verify;
import java.io.IOException;
import java.util.Collections;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import org.apache.http.Header;
import org.apache.http.HttpException;
import org.apache.http.HttpRequest;
import org.apache.http.HttpResponse;
import org.apache.http.config.SocketConfig;
import org.apache.http.impl.bootstrap.HttpServer;
import org.apache.http.impl.bootstrap.ServerBootstrap;
import org.apache.http.protocol.HttpContext;
import org.apache.http.protocol.HttpRequestHandler;
import org.junit.After;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import org.thingsboard.rule.engine.api.TbContext;
import org.thingsboard.rule.engine.api.TbEmail;
import org.thingsboard.rule.engine.api.TbNodeConfiguration;
import org.thingsboard.rule.engine.api.TbNodeException;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.RuleChainId;
import org.thingsboard.server.common.data.id.RuleNodeId;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgDataType;
import org.thingsboard.server.common.msg.TbMsgMetaData;
import com.datastax.oss.driver.api.core.uuid.Uuids;
import com.fasterxml.jackson.databind.ObjectMapper;
@RunWith(MockitoJUnitRunner.class)
public class TbRestApiCallNodeTest {
private TbRestApiCallNode restNode;
@Mock
private TbContext ctx;
private EntityId originator = new DeviceId(Uuids.timeBased());
private TbMsgMetaData metaData = new TbMsgMetaData();
private RuleChainId ruleChainId = new RuleChainId(Uuids.timeBased());
private RuleNodeId ruleNodeId = new RuleNodeId(Uuids.timeBased());
private HttpServer server;
public void setupServer(String pattern, HttpRequestHandler handler) throws IOException {
SocketConfig config = SocketConfig.custom().setSoReuseAddress(true).setTcpNoDelay(true).build();
server = ServerBootstrap.bootstrap()
.setSocketConfig(config)
.registerHandler(pattern, handler)
.create();
server.start();
}
private void initWithConfig(TbRestApiCallNodeConfiguration config) {
try {
ObjectMapper mapper = new ObjectMapper();
TbNodeConfiguration nodeConfiguration = new TbNodeConfiguration(mapper.valueToTree(config));
restNode = new TbRestApiCallNode();
restNode.init(ctx, nodeConfiguration);
} catch (TbNodeException ex) {
throw new IllegalStateException(ex);
}
}
@After
public void teardown() {
server.stop();
}
@Test
public void deleteRequestWithoutBody() throws IOException, InterruptedException {
final CountDownLatch latch = new CountDownLatch(1);
final String path = "/path/to/delete";
setupServer("*", new HttpRequestHandler() {
@Override
public void handle(HttpRequest request, HttpResponse response, HttpContext context)
throws HttpException, IOException {
try {
assertEquals("Request path matches", request.getRequestLine().getUri(), path);
assertFalse("Content-Type not included", request.containsHeader("Content-Type"));
assertTrue("Custom header included", request.containsHeader("Foo"));
assertEquals("Custom header value", "Bar", request.getFirstHeader("Foo").getValue());
response.setStatusCode(200);
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1000L);
} catch (InterruptedException e) {
// ignore
} finally {
latch.countDown();
}
}
}).start();
} catch ( Exception e ) {
System.out.println("Exception handling request: " + e.toString());
e.printStackTrace();
latch.countDown();
}
}
});
TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration();
config.setRequestMethod("DELETE");
config.setHeaders(Collections.singletonMap("Foo", "Bar"));
config.setIgnoreRequestBody(true);
config.setRestEndpointUrlPattern(String.format("http://localhost:%d%s", server.getLocalPort(), path));
initWithConfig(config);
TbMsg msg = TbMsg.newMsg( "USER", originator, metaData, TbMsgDataType.JSON, "{}", ruleChainId, ruleNodeId);
restNode.onMsg(ctx, msg);
assertTrue("Server handled request", latch.await(10, TimeUnit.SECONDS));
ArgumentCaptor<TbMsg> msgCaptor = ArgumentCaptor.forClass(TbMsg.class);
ArgumentCaptor<String> typeCaptor = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<EntityId> originatorCaptor = ArgumentCaptor.forClass(EntityId.class);
ArgumentCaptor<TbMsgMetaData> metadataCaptor = ArgumentCaptor.forClass(TbMsgMetaData.class);
ArgumentCaptor<String> dataCaptor = ArgumentCaptor.forClass(String.class);
verify(ctx).transformMsg(msgCaptor.capture(), typeCaptor.capture(), originatorCaptor.capture(), metadataCaptor.capture(), dataCaptor.capture());
assertEquals("USER", typeCaptor.getValue());
assertEquals(originator, originatorCaptor.getValue());
assertNotSame(metaData, metadataCaptor.getValue());
assertEquals("{}", dataCaptor.getValue());
}
@Test
public void deleteRequestWithBody() throws IOException, InterruptedException {
final CountDownLatch latch = new CountDownLatch(1);
final String path = "/path/to/delete";
setupServer("*", new HttpRequestHandler() {
@Override
public void handle(HttpRequest request, HttpResponse response, HttpContext context)
throws HttpException, IOException {
try {
assertEquals("Request path matches", path, request.getRequestLine().getUri());
assertTrue("Content-Type included", request.containsHeader("Content-Type"));
assertEquals("Content-Type value", "text/plain;charset=ISO-8859-1",
request.getFirstHeader("Content-Type").getValue());
assertTrue("Content-Length included", request.containsHeader("Content-Length"));
assertEquals("Content-Length value", "2",
request.getFirstHeader("Content-Length").getValue());
assertTrue("Custom header included", request.containsHeader("Foo"));
assertEquals("Custom header value", "Bar", request.getFirstHeader("Foo").getValue());
response.setStatusCode(200);
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1000L);
} catch (InterruptedException e) {
// ignore
} finally {
latch.countDown();
}
}
}).start();
} catch ( Exception e ) {
System.out.println("Exception handling request: " + e.toString());
e.printStackTrace();
latch.countDown();
}
}
});
TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration();
config.setRequestMethod("DELETE");
config.setHeaders(Collections.singletonMap("Foo", "Bar"));
config.setIgnoreRequestBody(false);
config.setRestEndpointUrlPattern(String.format("http://localhost:%d%s", server.getLocalPort(), path));
initWithConfig(config);
TbMsg msg = TbMsg.newMsg( "USER", originator, metaData, TbMsgDataType.JSON, "{}", ruleChainId, ruleNodeId);
restNode.onMsg(ctx, msg);
assertTrue("Server handled request", latch.await(10, TimeUnit.SECONDS));
ArgumentCaptor<TbMsg> msgCaptor = ArgumentCaptor.forClass(TbMsg.class);
ArgumentCaptor<String> typeCaptor = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<EntityId> originatorCaptor = ArgumentCaptor.forClass(EntityId.class);
ArgumentCaptor<TbMsgMetaData> metadataCaptor = ArgumentCaptor.forClass(TbMsgMetaData.class);
ArgumentCaptor<String> dataCaptor = ArgumentCaptor.forClass(String.class);
verify(ctx).transformMsg(msgCaptor.capture(), typeCaptor.capture(), originatorCaptor.capture(), metadataCaptor.capture(), dataCaptor.capture());
assertEquals("USER", typeCaptor.getValue());
assertEquals(originator, originatorCaptor.getValue());
assertNotSame(metaData, metadataCaptor.getValue());
assertEquals("{}", dataCaptor.getValue());
}
}

3
ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html

@ -15,7 +15,7 @@
limitations under the License.
-->
<div class="tb-dashboard-page mat-content" [ngClass]="{'mobile-app': isMobileApp && !isEdit}" tb-toast toastTarget="dashboardRoot"
<div class="tb-dashboard-page mat-content" [ngClass]="{'mobile-app': isMobileApp && !isEdit}"
fxFlex tb-fullscreen [fullscreen]="widgetEditMode || iframeMode || forceFullscreen || isFullscreen">
<tb-hotkeys-cheatsheet #cheatSheetComponent></tb-hotkeys-cheatsheet>
<section class="tb-dashboard-toolbar"
@ -139,6 +139,7 @@
</tb-dashboard-toolbar>
</section>
<section class="tb-dashboard-container tb-absolute-fill"
tb-toast toastTarget="dashboardRoot"
#dashboardContainer
[ngClass]="{ 'is-fullscreen': forceFullscreen,
'tb-dashboard-toolbar-opened': toolbarOpened,

2
ui-ngx/src/app/modules/home/components/home-components.module.ts

@ -49,7 +49,6 @@ import { EntityAliasSelectComponent } from '@home/components/alias/entity-alias-
import { DataKeysComponent } from '@home/components/widget/data-keys.component';
import { DataKeyConfigDialogComponent } from '@home/components/widget/data-key-config-dialog.component';
import { DataKeyConfigComponent } from '@home/components/widget/data-key-config.component';
import { LegendConfigPanelComponent } from '@home/components/widget/legend-config-panel.component';
import { LegendConfigComponent } from '@home/components/widget/legend-config.component';
import { ManageWidgetActionsComponent } from '@home/components/widget/action/manage-widget-actions.component';
import { WidgetActionDialogComponent } from '@home/components/widget/action/widget-action-dialog.component';
@ -182,7 +181,6 @@ import { DeviceProfileCommonModule } from '@home/components/profile/device/commo
DataKeysComponent,
DataKeyConfigComponent,
DataKeyConfigDialogComponent,
LegendConfigPanelComponent,
LegendConfigComponent,
ManageWidgetActionsComponent,
WidgetActionDialogComponent,

49
ui-ngx/src/app/modules/home/components/profile/device/coap-device-profile-transport-configuration.component.html

@ -47,35 +47,50 @@
</mat-error>
</mat-form-field>
<div *ngIf="protoPayloadType" fxLayout="column">
<mat-form-field fxFlex>
<mat-label translate>device-profile.telemetry-proto-schema</mat-label>
<textarea matInput required formControlName="deviceTelemetryProtoSchema" rows="5"></textarea>
<ng-container>
<tb-protobuf-content
fxFlex
formControlName="deviceTelemetryProtoSchema"
label="{{ 'device-profile.telemetry-proto-schema' | translate }}"
[fillHeight]="true">
</tb-protobuf-content>
<mat-error *ngIf="coapTransportConfigurationFormGroup.get('coapDeviceTypeConfiguration.transportPayloadTypeConfiguration.deviceTelemetryProtoSchema').hasError('required')">
{{ 'device-profile.telemetry-proto-schema-required' | translate}}
</mat-error>
</mat-form-field>
<mat-form-field fxFlex>
<mat-label translate>device-profile.attributes-proto-schema</mat-label>
<textarea matInput required formControlName="deviceAttributesProtoSchema" rows="5"></textarea>
</ng-container>
<ng-container>
<tb-protobuf-content
fxFlex
formControlName="deviceAttributesProtoSchema"
label="{{ 'device-profile.attributes-proto-schema' | translate }}"
[fillHeight]="true">
</tb-protobuf-content>
<mat-error *ngIf="coapTransportConfigurationFormGroup.get('coapDeviceTypeConfiguration.transportPayloadTypeConfiguration.deviceAttributesProtoSchema').hasError('required')">
{{ 'device-profile.attributes-proto-schema-required' | translate}}
</mat-error>
</mat-form-field>
<mat-form-field style="padding-bottom: 20px" fxFlex>
<mat-label translate>device-profile.rpc-request-proto-schema</mat-label>
<textarea matInput required formControlName="deviceRpcRequestProtoSchema" rows="5"></textarea>
</ng-container>
<ng-container>
<tb-protobuf-content
fxFlex
formControlName="deviceRpcRequestProtoSchema"
label="{{ 'device-profile.rpc-request-proto-schema' | translate }}"
[fillHeight]="true">
</tb-protobuf-content>
<mat-error *ngIf="coapTransportConfigurationFormGroup.get('coapDeviceTypeConfiguration.transportPayloadTypeConfiguration.deviceRpcRequestProtoSchema').hasError('required')">
{{ 'device-profile.rpc-request-proto-schema-required' | translate}}
</mat-error>
<mat-hint class="tb-hint" translate>device-profile.rpc-request-proto-schema-hint</mat-hint>
</mat-form-field>
<mat-form-field fxFlex>
<mat-label translate>device-profile.rpc-response-proto-schema</mat-label>
<textarea matInput required formControlName="deviceRpcResponseProtoSchema" rows="5"></textarea>
</ng-container>
<ng-container>
<tb-protobuf-content
fxFlex
formControlName="deviceRpcResponseProtoSchema"
label="{{ 'device-profile.rpc-response-proto-schema' | translate }}"
[fillHeight]="true">
</tb-protobuf-content>
<mat-error *ngIf="coapTransportConfigurationFormGroup.get('coapDeviceTypeConfiguration.transportPayloadTypeConfiguration.deviceRpcResponseProtoSchema').hasError('required')">
{{ 'device-profile.rpc-response-proto-schema-required' | translate}}
</mat-error>
</mat-form-field>
</ng-container>
</div>
</div>
</fieldset>

49
ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.html

@ -86,35 +86,50 @@
</div>
</div>
<div *ngIf="protoPayloadType" fxLayout="column">
<mat-form-field fxFlex>
<mat-label translate>device-profile.telemetry-proto-schema</mat-label>
<textarea matInput required formControlName="deviceTelemetryProtoSchema" rows="5"></textarea>
<ng-container>
<tb-protobuf-content
fxFlex
formControlName="deviceTelemetryProtoSchema"
label="{{ 'device-profile.telemetry-proto-schema' | translate }}"
[fillHeight]="true">
</tb-protobuf-content>
<mat-error *ngIf="mqttDeviceProfileTransportConfigurationFormGroup.get('transportPayloadTypeConfiguration.deviceTelemetryProtoSchema').hasError('required')">
{{ 'device-profile.telemetry-proto-schema-required' | translate}}
</mat-error>
</mat-form-field>
<mat-form-field fxFlex>
<mat-label translate>device-profile.attributes-proto-schema</mat-label>
<textarea matInput required formControlName="deviceAttributesProtoSchema" rows="5"></textarea>
</ng-container>
<ng-container>
<tb-protobuf-content
fxFlex
formControlName="deviceAttributesProtoSchema"
label="{{ 'device-profile.attributes-proto-schema' | translate }}"
[fillHeight]="true">
</tb-protobuf-content>
<mat-error *ngIf="mqttDeviceProfileTransportConfigurationFormGroup.get('transportPayloadTypeConfiguration.deviceAttributesProtoSchema').hasError('required')">
{{ 'device-profile.attributes-proto-schema-required' | translate}}
</mat-error>
</mat-form-field>
<mat-form-field style="padding-bottom: 20px" fxFlex>
<mat-label translate>device-profile.rpc-request-proto-schema</mat-label>
<textarea matInput required formControlName="deviceRpcRequestProtoSchema" rows="5"></textarea>
</ng-container>
<ng-container>
<tb-protobuf-content
fxFlex
formControlName="deviceRpcRequestProtoSchema"
label="{{ 'device-profile.rpc-request-proto-schema' | translate }}"
[fillHeight]="true">
</tb-protobuf-content>
<mat-error *ngIf="mqttDeviceProfileTransportConfigurationFormGroup.get('transportPayloadTypeConfiguration.deviceRpcRequestProtoSchema').hasError('required')">
{{ 'device-profile.rpc-request-proto-schema-required' | translate}}
</mat-error>
<mat-hint class="tb-hint" translate>device-profile.rpc-request-proto-schema-hint</mat-hint>
</mat-form-field>
<mat-form-field fxFlex>
<mat-label translate>device-profile.rpc-response-proto-schema</mat-label>
<textarea matInput required formControlName="deviceRpcResponseProtoSchema" rows="5"></textarea>
</ng-container>
<ng-container>
<tb-protobuf-content
fxFlex
formControlName="deviceRpcResponseProtoSchema"
label="{{ 'device-profile.rpc-response-proto-schema' | translate }}"
[fillHeight]="true">
</tb-protobuf-content>
<mat-error *ngIf="mqttDeviceProfileTransportConfigurationFormGroup.get('transportPayloadTypeConfiguration.deviceRpcResponseProtoSchema').hasError('required')">
{{ 'device-profile.rpc-response-proto-schema-required' | translate}}
</mat-error>
</mat-form-field>
</ng-container>
</div>
</div>
</fieldset>

60
ui-ngx/src/app/modules/home/components/widget/legend-config-panel.component.html

@ -1,60 +0,0 @@
<!--
Copyright © 2016-2021 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.
-->
<form [formGroup]="legendConfigForm">
<fieldset [disabled]="(isLoading$ | async)">
<div class="mat-content" style="height: 100%;">
<div class="mat-padding">
<section fxLayout="column">
<mat-form-field>
<mat-label translate>legend.direction</mat-label>
<mat-select matInput formControlName="direction" style="min-width: 150px;">
<mat-option *ngFor="let direction of legendDirections" [value]="direction">
{{ legendDirectionTranslations.get(legendDirection[direction]) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label translate>legend.position</mat-label>
<mat-select matInput formControlName="position" style="min-width: 150px;">
<mat-option *ngFor="let pos of legendPositions" [value]="pos"
[disabled]="legendConfigForm.get('direction').value === legendDirection.row &&
(pos === legendPosition.left || pos === legendPosition.right)">
{{ legendPositionTranslations.get(legendPosition[pos]) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-checkbox formControlName="sortDataKeys">
{{ 'legend.sort-legend' | translate }}
</mat-checkbox>
<mat-checkbox formControlName="showMin">
{{ 'legend.show-min' | translate }}
</mat-checkbox>
<mat-checkbox formControlName="showMax">
{{ 'legend.show-max' | translate }}
</mat-checkbox>
<mat-checkbox formControlName="showAvg">
{{ 'legend.show-avg' | translate }}
</mat-checkbox>
<mat-checkbox formControlName="showTotal">
{{ 'legend.show-total' | translate }}
</mat-checkbox>
</section>
</div>
</div>
</fieldset>
</form>

32
ui-ngx/src/app/modules/home/components/widget/legend-config-panel.component.scss

@ -1,32 +0,0 @@
/**
* Copyright © 2016-2021 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.
*/
:host {
width: 100%;
height: 100%;
form,
fieldset {
height: 100%;
}
.mat-content {
overflow: hidden;
background-color: #fff;
}
.mat-padding {
padding: 16px;
}
}

105
ui-ngx/src/app/modules/home/components/widget/legend-config-panel.component.ts

@ -1,105 +0,0 @@
///
/// Copyright © 2016-2021 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, Inject, InjectionToken, OnInit, ViewContainerRef } from '@angular/core';
import { OverlayRef } from '@angular/cdk/overlay';
import { PageComponent } from '@shared/components/page.component';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { FormBuilder, FormGroup } from '@angular/forms';
import {
LegendConfig,
LegendDirection,
legendDirectionTranslationMap,
LegendPosition,
legendPositionTranslationMap
} from '@shared/models/widget.models';
export const LEGEND_CONFIG_PANEL_DATA = new InjectionToken<any>('LegendConfigPanelData');
export interface LegendConfigPanelData {
legendConfig: LegendConfig;
legendConfigUpdated: (legendConfig: LegendConfig) => void;
}
@Component({
selector: 'tb-legend-config-panel',
templateUrl: './legend-config-panel.component.html',
styleUrls: ['./legend-config-panel.component.scss']
})
export class LegendConfigPanelComponent extends PageComponent implements OnInit {
legendConfigForm: FormGroup;
legendDirection = LegendDirection;
legendDirections = Object.keys(LegendDirection);
legendDirectionTranslations = legendDirectionTranslationMap;
legendPosition = LegendPosition;
legendPositions = Object.keys(LegendPosition);
legendPositionTranslations = legendPositionTranslationMap;
constructor(@Inject(LEGEND_CONFIG_PANEL_DATA) public data: LegendConfigPanelData,
public overlayRef: OverlayRef,
protected store: Store<AppState>,
public fb: FormBuilder,
public viewContainerRef: ViewContainerRef) {
super(store);
}
ngOnInit(): void {
this.legendConfigForm = this.fb.group({
direction: [this.data.legendConfig.direction, []],
position: [this.data.legendConfig.position, []],
sortDataKeys: [this.data.legendConfig.sortDataKeys, []],
showMin: [this.data.legendConfig.showMin, []],
showMax: [this.data.legendConfig.showMax, []],
showAvg: [this.data.legendConfig.showAvg, []],
showTotal: [this.data.legendConfig.showTotal, []]
});
this.legendConfigForm.get('direction').valueChanges.subscribe((direction: LegendDirection) => {
this.onDirectionChanged(direction);
});
this.onDirectionChanged(this.data.legendConfig.direction);
this.legendConfigForm.valueChanges.subscribe(() => {
this.update();
});
}
private onDirectionChanged(direction: LegendDirection) {
if (direction === LegendDirection.row) {
let position: LegendPosition = this.legendConfigForm.get('position').value;
if (position !== LegendPosition.bottom && position !== LegendPosition.top) {
position = LegendPosition.bottom;
}
this.legendConfigForm.patchValue(
{
position
}, {emitEvent: false}
);
}
}
update() {
const newLegendConfig: LegendConfig = this.legendConfigForm.value;
this.data.legendConfigUpdated(newLegendConfig);
}
}

46
ui-ngx/src/app/modules/home/components/widget/legend-config.component.html

@ -15,9 +15,43 @@
limitations under the License.
-->
<button cdkOverlayOrigin #legendConfigPanelOrigin="cdkOverlayOrigin" [disabled]="disabled"
type="button"
mat-button mat-raised-button color="primary" (click)="openEditMode()">
<mat-icon class="material-icons">toc</mat-icon>
<span translate>legend.settings</span>
</button>
<form [formGroup]="legendConfigForm">
<div fxLayout.xs="column" fxLayoutAlign.xs="center" fxLayout="row" fxLayoutAlign="start center"
fxLayoutGap="8px">
<mat-form-field fxFlex>
<mat-label translate>legend.direction</mat-label>
<mat-select matInput formControlName="direction">
<mat-option *ngFor="let direction of legendDirections" [value]="direction">
{{ legendDirectionTranslations.get(legendDirection[direction]) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field fxFlex>
<mat-label translate>legend.position</mat-label>
<mat-select matInput formControlName="position">
<mat-option *ngFor="let pos of legendPositions" [value]="pos"
[disabled]="legendConfigForm.get('direction').value === legendDirection.row &&
(pos === legendPosition.left || pos === legendPosition.right)">
{{ legendPositionTranslations.get(legendPosition[pos]) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div fxLayout.xs="column" fxLayoutAlign.xs="center" fxLayout="row wrap" fxLayoutAlign="space-between center" fxLayoutGap="8px">
<mat-checkbox formControlName="showMin" fxFlex="48">
{{ 'legend.show-min' | translate }}
</mat-checkbox>
<mat-checkbox formControlName="showMax" fxFlex="48">
{{ 'legend.show-max' | translate }}
</mat-checkbox>
<mat-checkbox formControlName="showAvg" fxFlex="48">
{{ 'legend.show-avg' | translate }}
</mat-checkbox>
<mat-checkbox formControlName="showTotal" fxFlex="48">
{{ 'legend.show-total' | translate }}
</mat-checkbox>
<mat-checkbox formControlName="sortDataKeys" fxFlex="48">
{{ 'legend.sort-legend' | translate }}
</mat-checkbox>
</div>
</form>

189
ui-ngx/src/app/modules/home/components/widget/legend-config.component.ts

@ -14,32 +14,17 @@
/// limitations under the License.
///
import { Component, forwardRef, Input, OnDestroy, OnInit } from '@angular/core';
import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR } from '@angular/forms';
import { isDefined } from '@core/utils';
import {
Component,
forwardRef,
Inject,
Injector,
Input,
OnDestroy,
OnInit,
StaticProvider,
ViewChild,
ViewContainerRef
} from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { DOCUMENT } from '@angular/common';
import { CdkOverlayOrigin, ConnectedPosition, Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';
import { MediaBreakpoints } from '@shared/models/constants';
import { BreakpointObserver } from '@angular/cdk/layout';
import { WINDOW } from '@core/services/window.service';
import { deepClone } from '@core/utils';
import { LegendConfig } from '@shared/models/widget.models';
import {
LEGEND_CONFIG_PANEL_DATA,
LegendConfigPanelComponent,
LegendConfigPanelData
} from '@home/components/widget/legend-config-panel.component';
LegendConfig,
LegendDirection,
legendDirectionTranslationMap,
LegendPosition,
legendPositionTranslationMap
} from '@shared/models/widget.models';
import { Subscription } from 'rxjs';
// @dynamic
@Component({
@ -58,105 +43,60 @@ export class LegendConfigComponent implements OnInit, OnDestroy, ControlValueAcc
@Input() disabled: boolean;
@ViewChild('legendConfigPanelOrigin') legendConfigPanelOrigin: CdkOverlayOrigin;
innerValue: LegendConfig;
legendConfigForm: FormGroup;
legendDirection = LegendDirection;
legendDirections = Object.keys(LegendDirection);
legendDirectionTranslations = legendDirectionTranslationMap;
legendPosition = LegendPosition;
legendPositions = Object.keys(LegendPosition);
legendPositionTranslations = legendPositionTranslationMap;
private legendSettingsFormChanges$: Subscription;
private legendSettingsFormDirectionChanges$: Subscription;
private propagateChange = (_: any) => {};
constructor(private overlay: Overlay,
public viewContainerRef: ViewContainerRef,
public breakpointObserver: BreakpointObserver,
@Inject(DOCUMENT) private document: Document,
@Inject(WINDOW) private window: Window) {
constructor(private fb: FormBuilder) {
}
ngOnInit(): void {
this.legendConfigForm = this.fb.group({
direction: [null, []],
position: [null, []],
sortDataKeys: [null, []],
showMin: [null, []],
showMax: [null, []],
showAvg: [null, []],
showTotal: [null, []]
});
this.legendSettingsFormDirectionChanges$ = this.legendConfigForm.get('direction').valueChanges
.subscribe((direction: LegendDirection) => {
this.onDirectionChanged(direction);
});
this.legendSettingsFormChanges$ = this.legendConfigForm.valueChanges.subscribe(
() => this.legendConfigUpdated()
);
}
ngOnDestroy(): void {
}
openEditMode() {
if (this.disabled) {
return;
}
const isGtSm = this.breakpointObserver.isMatched(MediaBreakpoints['gt-sm']);
const position = this.overlay.position();
const config = new OverlayConfig({
panelClass: 'tb-legend-config-panel',
backdropClass: 'cdk-overlay-transparent-backdrop',
hasBackdrop: isGtSm,
});
if (isGtSm) {
config.minWidth = '220px';
config.maxHeight = '300px';
const panelHeight = 220;
const panelWidth = 220;
const el = this.legendConfigPanelOrigin.elementRef.nativeElement;
const offset = el.getBoundingClientRect();
const scrollTop = this.window.pageYOffset || this.document.documentElement.scrollTop || this.document.body.scrollTop || 0;
const scrollLeft = this.window.pageXOffset || this.document.documentElement.scrollLeft || this.document.body.scrollLeft || 0;
const bottomY = offset.bottom - scrollTop;
const leftX = offset.left - scrollLeft;
let originX;
let originY;
let overlayX;
let overlayY;
const wHeight = this.document.documentElement.clientHeight;
const wWidth = this.document.documentElement.clientWidth;
if (bottomY + panelHeight > wHeight) {
originY = 'top';
overlayY = 'bottom';
} else {
originY = 'bottom';
overlayY = 'top';
private onDirectionChanged(direction: LegendDirection) {
if (direction === LegendDirection.row) {
let position: LegendPosition = this.legendConfigForm.get('position').value;
if (position !== LegendPosition.bottom && position !== LegendPosition.top) {
position = LegendPosition.bottom;
}
if (leftX + panelWidth > wWidth) {
originX = 'end';
overlayX = 'end';
} else {
originX = 'start';
overlayX = 'start';
}
const connectedPosition: ConnectedPosition = {
originX,
originY,
overlayX,
overlayY
};
config.positionStrategy = position.flexibleConnectedTo(this.legendConfigPanelOrigin.elementRef)
.withPositions([connectedPosition]);
} else {
config.minWidth = '100%';
config.minHeight = '100%';
config.positionStrategy = position.global().top('0%').left('0%')
.right('0%').bottom('0%');
this.legendConfigForm.patchValue({position}, {emitEvent: false}
);
}
const overlayRef = this.overlay.create(config);
overlayRef.backdropClick().subscribe(() => {
overlayRef.dispose();
});
const injector = this._createLegendConfigPanelInjector(
overlayRef,
{
legendConfig: deepClone(this.innerValue),
legendConfigUpdated: this.legendConfigUpdated.bind(this)
}
);
overlayRef.attach(new ComponentPortal(LegendConfigPanelComponent, this.viewContainerRef, injector));
}
private _createLegendConfigPanelInjector(overlayRef: OverlayRef, data: LegendConfigPanelData): Injector {
const providers: StaticProvider[] = [
{provide: LEGEND_CONFIG_PANEL_DATA, useValue: data},
{provide: OverlayRef, useValue: overlayRef}
];
return Injector.create({parent: this.viewContainerRef.injector, providers});
ngOnDestroy(): void {
if (this.legendSettingsFormDirectionChanges$) {
this.legendSettingsFormDirectionChanges$.unsubscribe();
this.legendSettingsFormDirectionChanges$ = null;
}
if (this.legendSettingsFormChanges$) {
this.legendSettingsFormChanges$.unsubscribe();
this.legendSettingsFormChanges$ = null;
}
}
registerOnChange(fn: any): void {
@ -168,14 +108,29 @@ export class LegendConfigComponent implements OnInit, OnDestroy, ControlValueAcc
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
if (this.disabled) {
this.legendConfigForm.disable({emitEvent: false});
} else {
this.legendConfigForm.enable({emitEvent: false});
}
}
writeValue(obj: LegendConfig): void {
this.innerValue = obj;
writeValue(legendConfig: LegendConfig): void {
if (legendConfig) {
this.legendConfigForm.patchValue({
direction: legendConfig.direction,
position: legendConfig.position,
sortDataKeys: isDefined(legendConfig.sortDataKeys) ? legendConfig.sortDataKeys : false,
showMin: isDefined(legendConfig.showMin) ? legendConfig.showMin : false,
showMax: isDefined(legendConfig.showMax) ? legendConfig.showMax : false,
showAvg: isDefined(legendConfig.showAvg) ? legendConfig.showAvg : false,
showTotal: isDefined(legendConfig.showTotal) ? legendConfig.showTotal : false
}, {emitEvent: false});
}
this.onDirectionChanged(legendConfig.direction);
}
private legendConfigUpdated(legendConfig: LegendConfig) {
this.innerValue = legendConfig;
this.propagateChange(this.innerValue);
private legendConfigUpdated() {
this.propagateChange(this.legendConfigForm.value);
}
}

697
ui-ngx/src/app/modules/home/components/widget/widget-config.component.html

@ -82,222 +82,246 @@
</mat-checkbox>
</div>
</div>
<mat-expansion-panel class="tb-datasources" *ngIf="widgetType !== widgetTypes.rpc &&
widgetType !== widgetTypes.alarm &&
modelValue?.isDataEnabled" [expanded]="true">
<mat-expansion-panel-header>
<mat-panel-title fxLayout="column">
<div class="tb-panel-title" translate>widget-config.datasources</div>
<div *ngIf="modelValue?.typeParameters && modelValue?.typeParameters.maxDatasources > -1"
class="tb-panel-hint">{{ 'widget-config.maximum-datasources' | translate:{count: modelValue?.typeParameters.maxDatasources} }}</div>
</mat-panel-title>
</mat-expansion-panel-header>
<div *ngIf="datasourcesFormArray().length === 0; else datasourcesTemplate">
<span translate fxLayoutAlign="center center"
class="tb-prompt">datasource.add-datasource-prompt</span>
</div>
<ng-template #datasourcesTemplate>
<div fxFlex fxLayout="row" fxLayoutAlign="start center">
<span style="width: 60px;"></span>
<div fxFlex fxLayout="row" fxLayoutAlign="start center"
style="padding: 0 0 0 10px; margin: 5px;">
<span translate style="min-width: 120px;">widget-config.datasource-type</span>
<span fxHide fxShow.gt-sm translate fxFlex
style="padding-left: 10px;">widget-config.datasource-parameters</span>
<span style="min-width: 40px;"></span>
</div>
<mat-accordion multi>
<mat-expansion-panel class="tb-datasources" *ngIf="widgetType !== widgetTypes.rpc &&
widgetType !== widgetTypes.alarm &&
modelValue?.isDataEnabled" [expanded]="true">
<mat-expansion-panel-header>
<mat-panel-title fxLayout="column">
<div class="tb-panel-title" translate>widget-config.datasources</div>
<div *ngIf="modelValue?.typeParameters && modelValue?.typeParameters.maxDatasources > -1"
class="tb-panel-hint">{{ 'widget-config.maximum-datasources' | translate:{count: modelValue?.typeParameters.maxDatasources} }}</div>
</mat-panel-title>
</mat-expansion-panel-header>
<div *ngIf="datasourcesFormArray().length === 0; else datasourcesTemplate">
<span translate fxLayoutAlign="center center"
class="tb-prompt">datasource.add-datasource-prompt</span>
</div>
<div style="overflow: auto; padding-bottom: 15px;">
<mat-list dndDropzone dndEffectAllowed="move"
(dndDrop)="onDatasourceDrop($event)"
[dndDisableIf]="disabled" formArrayName="datasources">
<mat-list-item dndPlaceholderRef
class="dndPlaceholder">
</mat-list-item>
<mat-list-item *ngFor="let datasourceControl of datasourcesFormArray().controls; let $index = index;"
[dndDraggable]="datasourceControl.value"
(dndMoved)="dndDatasourceMoved($index)"
[dndDisableIf]="disabled"
dndEffectAllowed="move">
<div fxFlex fxLayout="row" fxLayoutAlign="start center">
<div style="width: 60px;">
<button *ngIf="!disabled" mat-icon-button color="primary"
class="handle"
style="min-width: 40px; margin: 0"
dndHandle
matTooltip="{{ 'action.drag' | translate }}"
matTooltipPosition="above">
<mat-icon>drag_handle</mat-icon>
</button>
<span>{{$index + 1}}.</span>
</div>
<div class="mat-elevation-z4" fxFlex
fxLayout="row"
fxLayoutAlign="start center"
style="padding: 0 0 0 10px; margin: 5px;">
<section fxFlex
fxLayout="column"
fxLayoutAlign="center"
fxLayout.gt-sm="row"
fxLayoutAlign.gt-sm="start center">
<mat-form-field class="tb-datasource-type">
<mat-select [formControl]="datasourceControl.get('type')">
<mat-option *ngFor="let datasourceType of datasourceTypes" [value]="datasourceType">
{{ datasourceTypesTranslations.get(datasourceType) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<section fxLayout="column" class="tb-datasource" [ngSwitch]="datasourceControl.get('type').value">
<ng-template [ngSwitchCase]="datasourceType.function">
<mat-form-field floatLabel="always"
class="tb-datasource-name" style="min-width: 200px;">
<mat-label></mat-label>
<input matInput
placeholder="{{ 'datasource.label' | translate }}"
[formControl]="datasourceControl.get('name')">
</mat-form-field>
</ng-template>
<ng-template [ngSwitchCase]="datasourceControl.get('type').value === datasourceType.entity ||
datasourceControl.get('type').value === datasourceType.entityCount ? datasourceControl.get('type').value : ''">
<tb-entity-alias-select
[showLabel]="true"
[tbRequired]="true"
[aliasController]="aliasController"
[formControl]="datasourceControl.get('entityAliasId')"
[callbacks]="widgetConfigCallbacks">
</tb-entity-alias-select>
<tb-filter-select
[showLabel]="true"
[aliasController]="aliasController"
[formControl]="datasourceControl.get('filterId')"
[callbacks]="widgetConfigCallbacks">
</tb-filter-select>
<mat-form-field *ngIf="datasourceControl.get('type').value === datasourceType.entityCount"
floatLabel="always"
class="tb-datasource-name no-border-top" style="min-width: 200px;">
<mat-label></mat-label>
<input matInput
placeholder="{{ 'datasource.label' | translate }}"
[formControl]="datasourceControl.get('name')">
</mat-form-field>
</ng-template>
<ng-template #datasourcesTemplate>
<div fxFlex fxLayout="row" fxLayoutAlign="start center">
<span style="width: 60px;"></span>
<div fxFlex fxLayout="row" fxLayoutAlign="start center"
style="padding: 0 0 0 10px; margin: 5px;">
<span translate style="min-width: 120px;">widget-config.datasource-type</span>
<span fxHide fxShow.gt-sm translate fxFlex
style="padding-left: 10px;">widget-config.datasource-parameters</span>
<span style="min-width: 40px;"></span>
</div>
</div>
<div style="overflow: auto; padding-bottom: 15px;">
<mat-list dndDropzone dndEffectAllowed="move"
(dndDrop)="onDatasourceDrop($event)"
[dndDisableIf]="disabled" formArrayName="datasources">
<mat-list-item dndPlaceholderRef
class="dndPlaceholder">
</mat-list-item>
<mat-list-item *ngFor="let datasourceControl of datasourcesFormArray().controls; let $index = index;"
[dndDraggable]="datasourceControl.value"
(dndMoved)="dndDatasourceMoved($index)"
[dndDisableIf]="disabled"
dndEffectAllowed="move">
<div fxFlex fxLayout="row" fxLayoutAlign="start center">
<div style="width: 60px;">
<button *ngIf="!disabled" mat-icon-button color="primary"
class="handle"
style="min-width: 40px; margin: 0"
dndHandle
matTooltip="{{ 'action.drag' | translate }}"
matTooltipPosition="above">
<mat-icon>drag_handle</mat-icon>
</button>
<span>{{$index + 1}}.</span>
</div>
<div class="mat-elevation-z4" fxFlex
fxLayout="row"
fxLayoutAlign="start center"
style="padding: 0 0 0 10px; margin: 5px;">
<section fxFlex
fxLayout="column"
fxLayoutAlign="center"
fxLayout.gt-sm="row"
fxLayoutAlign.gt-sm="start center">
<mat-form-field class="tb-datasource-type">
<mat-select [formControl]="datasourceControl.get('type')">
<mat-option *ngFor="let datasourceType of datasourceTypes" [value]="datasourceType">
{{ datasourceTypesTranslations.get(datasourceType) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<section fxLayout="column" class="tb-datasource" [ngSwitch]="datasourceControl.get('type').value">
<ng-template [ngSwitchCase]="datasourceType.function">
<mat-form-field floatLabel="always"
class="tb-datasource-name" style="min-width: 200px;">
<mat-label></mat-label>
<input matInput
placeholder="{{ 'datasource.label' | translate }}"
[formControl]="datasourceControl.get('name')">
</mat-form-field>
</ng-template>
<ng-template [ngSwitchCase]="datasourceControl.get('type').value === datasourceType.entity ||
datasourceControl.get('type').value === datasourceType.entityCount ? datasourceControl.get('type').value : ''">
<tb-entity-alias-select
[showLabel]="true"
[tbRequired]="true"
[aliasController]="aliasController"
[formControl]="datasourceControl.get('entityAliasId')"
[callbacks]="widgetConfigCallbacks">
</tb-entity-alias-select>
<tb-filter-select
[showLabel]="true"
[aliasController]="aliasController"
[formControl]="datasourceControl.get('filterId')"
[callbacks]="widgetConfigCallbacks">
</tb-filter-select>
<mat-form-field *ngIf="datasourceControl.get('type').value === datasourceType.entityCount"
floatLabel="always"
class="tb-datasource-name no-border-top" style="min-width: 200px;">
<mat-label></mat-label>
<input matInput
placeholder="{{ 'datasource.label' | translate }}"
[formControl]="datasourceControl.get('name')">
</mat-form-field>
</ng-template>
</section>
<tb-data-keys class="tb-data-keys" fxFlex
[widgetType]="widgetType"
[datasourceType]="datasourceControl.get('type').value"
[maxDataKeys]="modelValue?.typeParameters?.maxDataKeys"
[optDataKeys]="modelValue?.typeParameters?.dataKeysOptional"
[aliasController]="aliasController"
[datakeySettingsSchema]="modelValue?.dataKeySettingsSchema"
[callbacks]="widgetConfigCallbacks"
[entityAliasId]="datasourceControl.get('entityAliasId').value"
[formControl]="datasourceControl.get('dataKeys')">
</tb-data-keys>
</section>
<tb-data-keys class="tb-data-keys" fxFlex
[widgetType]="widgetType"
[datasourceType]="datasourceControl.get('type').value"
[maxDataKeys]="modelValue?.typeParameters?.maxDataKeys"
[optDataKeys]="modelValue?.typeParameters?.dataKeysOptional"
[aliasController]="aliasController"
[datakeySettingsSchema]="modelValue?.dataKeySettingsSchema"
[callbacks]="widgetConfigCallbacks"
[entityAliasId]="datasourceControl.get('entityAliasId').value"
[formControl]="datasourceControl.get('dataKeys')">
</tb-data-keys>
</section>
<button [disabled]="isLoading$ | async"
type="button"
mat-icon-button color="primary"
style="min-width: 40px;"
(click)="removeDatasource($index)"
matTooltip="{{ 'widget-config.remove-datasource' | translate }}"
matTooltipPosition="above">
<mat-icon>close</mat-icon>
</button>
<button [disabled]="isLoading$ | async"
type="button"
mat-icon-button color="primary"
style="min-width: 40px;"
(click)="removeDatasource($index)"
matTooltip="{{ 'widget-config.remove-datasource' | translate }}"
matTooltipPosition="above">
<mat-icon>close</mat-icon>
</button>
</div>
</div>
</div>
</mat-list-item>
</mat-list>
</mat-list-item>
</mat-list>
</div>
</ng-template>
<div fxFlex fxLayout="row" fxLayoutAlign="start center">
<button [disabled]="isLoading$ | async"
type="button"
mat-raised-button color="primary"
[fxShow]="modelValue?.typeParameters &&
(modelValue?.typeParameters.maxDatasources == -1 || datasourcesFormArray().controls.length < modelValue?.typeParameters.maxDatasources)"
(click)="addDatasource()"
matTooltip="{{ 'widget-config.add-datasource' | translate }}"
matTooltipPosition="above">
<mat-icon>add</mat-icon>
<span translate>action.add</span>
</button>
</div>
</ng-template>
<div fxFlex fxLayout="row" fxLayoutAlign="start center">
<button [disabled]="isLoading$ | async"
type="button"
mat-raised-button color="primary"
[fxShow]="modelValue?.typeParameters &&
(modelValue?.typeParameters.maxDatasources == -1 || datasourcesFormArray().controls.length < modelValue?.typeParameters.maxDatasources)"
(click)="addDatasource()"
matTooltip="{{ 'widget-config.add-datasource' | translate }}"
matTooltipPosition="above">
<mat-icon>add</mat-icon>
<span translate>action.add</span>
</button>
</div>
</mat-expansion-panel>
<mat-expansion-panel class="tb-datasources" *ngIf="widgetType === widgetTypes.rpc &&
modelValue?.isDataEnabled" [expanded]="true">
<mat-expansion-panel-header>
<mat-panel-title>
{{ 'widget-config.target-device' | translate }}
</mat-panel-title>
</mat-expansion-panel-header>
<div [formGroup]="targetDeviceSettings" style="padding: 0 5px;">
<tb-entity-alias-select fxFlex
[tbRequired]="!widgetEditMode"
[aliasController]="aliasController"
[allowedEntityTypes]="[entityTypes.DEVICE]"
[callbacks]="widgetConfigCallbacks"
formControlName="targetDeviceAliasId">
</tb-entity-alias-select>
</div>
</mat-expansion-panel>
<mat-expansion-panel class="tb-datasources" *ngIf="widgetType === widgetTypes.alarm &&
modelValue?.isDataEnabled" [expanded]="true">
<mat-expansion-panel-header>
<mat-panel-title>
{{ 'widget-config.alarm-source' | translate }}
</mat-panel-title>
</mat-expansion-panel-header>
<div [formGroup]="alarmSourceSettings" style="padding: 0 5px;">
<section fxFlex
fxLayout="column"
fxLayoutAlign="center"
fxLayout.gt-sm="row"
fxLayoutAlign.gt-sm="start center">
<mat-form-field class="tb-datasource-type">
<mat-select formControlName="type">
<mat-option *ngFor="let datasourceType of datasourceTypes" [value]="datasourceType">
{{ datasourceTypesTranslations.get(datasourceType) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<section class="tb-datasource" [ngSwitch]="alarmSourceSettings.get('type').value">
<ng-template [ngSwitchCase]="datasourceType.entity">
<tb-entity-alias-select
[showLabel]="true"
[tbRequired]="alarmSourceSettings.get('type').value === datasourceType.entity"
[aliasController]="aliasController"
formControlName="entityAliasId"
[callbacks]="widgetConfigCallbacks">
</tb-entity-alias-select>
<tb-filter-select
[showLabel]="true"
[aliasController]="aliasController"
formControlName="filterId"
[callbacks]="widgetConfigCallbacks">
</tb-filter-select>
</ng-template>
</mat-expansion-panel>
<mat-expansion-panel class="tb-datasources" *ngIf="widgetType === widgetTypes.rpc &&
modelValue?.isDataEnabled" [expanded]="true">
<mat-expansion-panel-header>
<mat-panel-title>
{{ 'widget-config.target-device' | translate }}
</mat-panel-title>
</mat-expansion-panel-header>
<div [formGroup]="targetDeviceSettings" style="padding: 0 5px;">
<tb-entity-alias-select fxFlex
[tbRequired]="!widgetEditMode"
[aliasController]="aliasController"
[allowedEntityTypes]="[entityTypes.DEVICE]"
[callbacks]="widgetConfigCallbacks"
formControlName="targetDeviceAliasId">
</tb-entity-alias-select>
</div>
</mat-expansion-panel>
<mat-expansion-panel class="tb-datasources" *ngIf="widgetType === widgetTypes.alarm &&
modelValue?.isDataEnabled" [expanded]="true">
<mat-expansion-panel-header>
<mat-panel-title>
{{ 'widget-config.alarm-source' | translate }}
</mat-panel-title>
</mat-expansion-panel-header>
<div [formGroup]="alarmSourceSettings" style="padding: 0 5px;">
<section fxFlex
fxLayout="column"
fxLayoutAlign="center"
fxLayout.gt-sm="row"
fxLayoutAlign.gt-sm="start center">
<mat-form-field class="tb-datasource-type">
<mat-select formControlName="type">
<mat-option *ngFor="let datasourceType of datasourceTypes" [value]="datasourceType">
{{ datasourceTypesTranslations.get(datasourceType) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<section class="tb-datasource" [ngSwitch]="alarmSourceSettings.get('type').value">
<ng-template [ngSwitchCase]="datasourceType.entity">
<tb-entity-alias-select
[showLabel]="true"
[tbRequired]="alarmSourceSettings.get('type').value === datasourceType.entity"
[aliasController]="aliasController"
formControlName="entityAliasId"
[callbacks]="widgetConfigCallbacks">
</tb-entity-alias-select>
<tb-filter-select
[showLabel]="true"
[aliasController]="aliasController"
formControlName="filterId"
[callbacks]="widgetConfigCallbacks">
</tb-filter-select>
</ng-template>
</section>
<tb-data-keys class="tb-data-keys" fxFlex
[widgetType]="widgetType"
[datasourceType]="alarmSourceSettings.get('type').value"
[aliasController]="aliasController"
[datakeySettingsSchema]="modelValue?.dataKeySettingsSchema"
[callbacks]="widgetConfigCallbacks"
[entityAliasId]="alarmSourceSettings.get('entityAliasId').value"
formControlName="dataKeys">
</tb-data-keys>
</section>
<tb-data-keys class="tb-data-keys" fxFlex
[widgetType]="widgetType"
[datasourceType]="alarmSourceSettings.get('type').value"
[aliasController]="aliasController"
[datakeySettingsSchema]="modelValue?.dataKeySettingsSchema"
[callbacks]="widgetConfigCallbacks"
[entityAliasId]="alarmSourceSettings.get('entityAliasId').value"
formControlName="dataKeys">
</tb-data-keys>
</section>
</div>
</mat-expansion-panel>
</div>
</mat-expansion-panel>
<mat-expansion-panel [formGroup]="widgetSettings">
<mat-expansion-panel-header>
<mat-panel-title translate>widget-config.data-settings</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<div fxLayout.xs="column" fxLayoutAlign.xs="center" fxLayout="row" fxLayoutAlign="start center"
fxLayoutGap="8px">
<mat-form-field fxFlex>
<mat-label translate>widget-config.units</mat-label>
<input matInput formControlName="units">
</mat-form-field>
<mat-form-field fxFlex>
<mat-label translate>widget-config.decimals</mat-label>
<input matInput formControlName="decimals" type="number" min="0" max="15" step="1">
</mat-form-field>
</div>
</ng-template>
</mat-expansion-panel>
</mat-accordion>
</div>
</mat-tab>
<mat-tab label="{{ 'widget-config.settings' | translate }}">
<div class="mat-content mat-padding" fxLayout="column" fxLayoutGap="8px">
<div [formGroup]="widgetSettings" fxLayout="column" fxLayoutGap="8px">
<span translate>widget-config.general-settings</span>
<div fxLayout.xs="column" fxLayoutAlign.xs="center" fxLayout="row" fxLayoutAlign="start center">
<div fxLayout="column" fxLayoutAlign="center" fxFlex.sm="40%" fxFlex.gt-sm="30%">
<mat-form-field fxFlex class="mat-block">
<div class="mat-content mat-padding" fxLayout="column">
<div [formGroup]="widgetSettings" fxLayout="column">
<fieldset class="fields-group" fxLayout="column">
<legend class="group-title" translate>widget-config.title</legend>
<mat-slide-toggle formControlName="showTitle" style="margin: 8px 0">
{{ 'widget-config.display-title' | translate }}
</mat-slide-toggle>
<div fxLayout.xs="column" fxLayoutAlign.xs="center" fxLayout="row" fxLayoutAlign="start center"
fxLayoutGap="8px">
<mat-form-field fxFlex>
<mat-label translate>widget-config.title</mat-label>
<input matInput formControlName="title">
</mat-form-field>
@ -306,130 +330,143 @@
<input matInput formControlName="titleTooltip">
</mat-form-field>
</div>
<div fxFlex [fxShow]="widgetSettings.get('showTitle').value">
<tb-json-object-edit
[editorStyle]="{minHeight: '100px'}"
required
label="{{ 'widget-config.title-style' | translate }}"
formControlName="titleStyle"
></tb-json-object-edit>
</div>
</div>
<div fxLayout="column" fxLayoutAlign="center" fxLayout.gt-md="row" fxLayoutAlign.gt-md="start center" fxFlex="100%"
fxLayoutGap="8px">
<div fxLayout.xs="column" fxLayoutAlign.xs="center" fxLayout="row" fxLayoutAlign="start center"
fxLayoutGap="8px" fxFlex.gt-md>
<mat-checkbox fxFlex formControlName="showTitleIcon">
<fieldset class="fields-group" fxLayout="column" fxLayoutGap="8px" style="margin: 0">
<legend class="group-title" translate>widget-config.title-icon</legend>
<mat-slide-toggle formControlName="showTitleIcon">
{{ 'widget-config.display-icon' | translate }}
</mat-checkbox>
<tb-material-icon-select fxFlex
formControlName="titleIcon">
</tb-material-icon-select>
</div>
<div fxLayout.xs="column" fxLayoutAlign.xs="center" fxLayout="row" fxLayoutAlign="start center"
fxLayoutGap="8px" fxFlex.gt-md>
<tb-color-input fxFlex
label="{{'widget-config.icon-color' | translate}}"
icon="format_color_fill"
openOnInput
formControlName="iconColor">
</tb-color-input>
<mat-form-field fxFlex>
<mat-label translate>widget-config.icon-size</mat-label>
<input matInput formControlName="iconSize">
</mat-form-field>
</div>
</div>
<div fxLayout.xs="column" fxLayoutAlign.xs="center" fxLayout="row" fxLayoutAlign="start center"
fxLayoutGap="8px">
<div fxLayout="column" fxLayoutAlign="center" fxLayoutGap="8px" fxFlex.sm="40%" fxFlex.gt-sm="30%">
<mat-checkbox formControlName="showTitle">
{{ 'widget-config.display-title' | translate }}
</mat-checkbox>
<mat-checkbox formControlName="dropShadow">
{{ 'widget-config.drop-shadow' | translate }}
</mat-checkbox>
<mat-checkbox formControlName="enableFullscreen">
{{ 'widget-config.enable-fullscreen' | translate }}
</mat-checkbox>
</div>
<div fxFlex>
<tb-json-object-edit
[editorStyle]="{minHeight: '100px'}"
required
label="{{ 'widget-config.widget-style' | translate }}"
formControlName="widgetStyle"
></tb-json-object-edit>
</div>
</div>
<div fxLayout="column" fxLayoutAlign="center" fxLayout.gt-md="row" fxLayoutAlign.gt-md="start center"
fxFlex="100%" fxLayoutGap="8px">
<div fxLayout.xs="column" fxLayoutAlign.xs="center" fxLayout="row" fxLayoutAlign="start center"
fxLayoutGap="8px" fxFlex.gt-md>
<tb-color-input fxFlex
label="{{'widget-config.background-color' | translate}}"
icon="format_color_fill"
openOnInput
formControlName="backgroundColor">
</tb-color-input>
<tb-color-input fxFlex
label="{{'widget-config.text-color' | translate}}"
icon="format_color_fill"
openOnInput
formControlName="color">
</tb-color-input>
</div>
<div fxLayout.xs="column" fxLayoutAlign.xs="center" fxLayout="row" fxLayoutAlign="start center"
fxLayoutGap="8px" fxFlex.gt-md>
<mat-form-field fxFlex>
<mat-label translate>widget-config.padding</mat-label>
<input matInput formControlName="padding">
</mat-form-field>
<mat-form-field fxFlex>
<mat-label translate>widget-config.margin</mat-label>
<input matInput formControlName="margin">
</mat-form-field>
</mat-slide-toggle>
<div fxLayout.xs="column" fxLayoutAlign.xs="center" fxLayout="row wrap" fxLayoutAlign="start center"
fxLayoutGap="8px">
<tb-material-icon-select fxFlex
formControlName="titleIcon">
</tb-material-icon-select>
<tb-color-input fxFlex
label="{{'widget-config.icon-color' | translate}}"
icon="format_color_fill"
openOnInput
formControlName="iconColor">
</tb-color-input>
<mat-form-field fxFlex>
<mat-label translate>widget-config.icon-size</mat-label>
<input matInput formControlName="iconSize">
</mat-form-field>
</div>
</fieldset>
<mat-expansion-panel class="tb-settings">
<mat-expansion-panel-header>
<mat-panel-description fxLayoutAlign="end" translate>
widget-config.advanced-settings
</mat-panel-description>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<tb-json-object-edit
[editorStyle]="{minHeight: '100px'}"
required
label="{{ 'widget-config.title-style' | translate }}"
formControlName="titleStyle"
></tb-json-object-edit>
</ng-template>
</mat-expansion-panel>
</fieldset>
<fieldset class="fields-group" fxLayout="column">
<legend class="group-title" translate>widget-config.widget-style</legend>
<div fxLayout="column" fxLayoutAlign="center" fxLayout.gt-md="row" fxLayoutAlign.gt-md="start center"
fxFlex="100%" fxLayoutGap="8px" class="tb-widget-style">
<div fxLayout.xs="column" fxLayoutAlign.xs="center" fxLayout="row" fxLayoutAlign="start center"
fxLayoutGap="8px" fxFlex.gt-md>
<tb-color-input fxFlex
label="{{'widget-config.background-color' | translate}}"
icon="format_color_fill"
openOnInput
formControlName="backgroundColor">
</tb-color-input>
<tb-color-input fxFlex
label="{{'widget-config.text-color' | translate}}"
icon="format_color_fill"
openOnInput
formControlName="color">
</tb-color-input>
</div>
<div fxLayout.xs="column" fxLayoutAlign.xs="center" fxLayout="row" fxLayoutAlign="start center"
fxLayoutGap="8px" fxFlex.gt-md>
<mat-form-field fxFlex>
<mat-label translate>widget-config.padding</mat-label>
<input matInput formControlName="padding">
</mat-form-field>
<mat-form-field fxFlex>
<mat-label translate>widget-config.margin</mat-label>
<input matInput formControlName="margin">
</mat-form-field>
</div>
</div>
</div>
<div fxLayout.xs="column" fxLayoutAlign.xs="center" fxLayout="row" fxLayoutAlign="start center"
fxLayoutGap="8px">
<mat-form-field fxFlex>
<mat-label translate>widget-config.units</mat-label>
<input matInput formControlName="units">
</mat-form-field>
<mat-form-field fxFlex>
<mat-label translate>widget-config.decimals</mat-label>
<input matInput formControlName="decimals" type="number" min="0" max="15" step="1">
</mat-form-field>
</div>
<div [fxShow]="widgetType === widgetTypes.timeseries || widgetType === widgetTypes.latest"
fxLayout.xs="column" fxLayoutAlign.xs="center" fxLayout="row" fxLayoutAlign="start center"
fxLayoutGap="8px" fxFlex="100%">
<mat-checkbox fxFlex.gt-xs formControlName="showLegend">
{{ 'widget-config.display-legend' | translate }}
</mat-checkbox>
<section fxFlex.gt-xs fxLayout="row" fxLayoutAlign="start center" style="margin-bottom: 16px;">
<tb-legend-config formControlName="legendConfig">
</tb-legend-config>
</section>
</div>
</div>
<div [formGroup]="layoutSettings" fxLayout="column" fxLayoutGap="8px">
<span translate>widget-config.mobile-mode-settings</span>
<div fxLayout.xs="column" fxLayoutAlign.xs="center" fxLayout="row" fxLayoutAlign="start center"
fxLayoutGap="8px">
<mat-checkbox formControlName="mobileHide">
{{ 'widget-config.mobile-hide' | translate }}
</mat-checkbox>
<mat-form-field fxFlex>
<mat-label translate>widget-config.order</mat-label>
<input matInput formControlName="mobileOrder" type="number" step="1">
</mat-form-field>
<mat-form-field fxFlex>
<mat-label translate>widget-config.height</mat-label>
<input matInput formControlName="mobileHeight" type="number" min="1" max="10" step="1">
</mat-form-field>
</div>
<mat-slide-toggle formControlName="dropShadow" style="margin-bottom: 8px">
{{ 'widget-config.drop-shadow' | translate }}
</mat-slide-toggle>
<mat-slide-toggle formControlName="enableFullscreen">
{{ 'widget-config.enable-fullscreen' | translate }}
</mat-slide-toggle>
<mat-expansion-panel class="tb-settings">
<mat-expansion-panel-header>
<mat-panel-description fxLayoutAlign="end" translate>
widget-config.advanced-settings
</mat-panel-description>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<tb-json-object-edit
[editorStyle]="{minHeight: '100px'}"
required
label="{{ 'widget-config.widget-style' | translate }}"
formControlName="widgetStyle"
></tb-json-object-edit>
</ng-template>
</mat-expansion-panel>
</fieldset>
<fieldset class="fields-group fields-group-slider" fxLayout="column">
<legend class="group-title" translate>widget-config.legend</legend>
<mat-expansion-panel class="tb-settings">
<mat-expansion-panel-header fxLayout="row wrap">
<mat-panel-title>
<mat-slide-toggle formControlName="showLegend" (click)="$event.stopPropagation()" fxLayoutAlign="center">
{{ 'widget-config.display-legend' | translate }}
</mat-slide-toggle>
</mat-panel-title>
<mat-panel-description fxLayoutAlign="end center" fxHide.xs translate>
widget-config.advanced-settings
</mat-panel-description>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<tb-legend-config formControlName="legendConfig"></tb-legend-config>
</ng-template>
</mat-expansion-panel>
</fieldset>
<fieldset [formGroup]="layoutSettings" class="fields-group fields-group-slider" fxLayout="column">
<legend class="group-title" translate>widget-config.mobile-mode-settings</legend>
<mat-expansion-panel class="tb-settings">
<mat-expansion-panel-header>
<mat-panel-title>
<mat-slide-toggle formControlName="mobileHide" (click)="$event.stopPropagation()" fxLayoutAlign="center">
{{ 'widget-config.mobile-hide' | translate }}
</mat-slide-toggle>
</mat-panel-title>
<mat-panel-description fxLayoutAlign="end center" fxHide.xs translate>
widget-config.advanced-settings
</mat-panel-description>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<div fxLayout.xs="column" fxLayoutAlign.xs="center" fxLayout="row" fxLayoutAlign="start center"
fxLayoutGap="8px">
<mat-form-field fxFlex>
<mat-label translate>widget-config.order</mat-label>
<input matInput formControlName="mobileOrder" type="number" step="1">
</mat-form-field>
<mat-form-field fxFlex>
<mat-label translate>widget-config.height</mat-label>
<input matInput formControlName="mobileHeight" type="number" min="1" max="10" step="1">
</mat-form-field>
</div>
</ng-template>
</mat-expansion-panel>
</fieldset>
</div>
</div>
</mat-tab>

58
ui-ngx/src/app/modules/home/components/widget/widget-config.component.scss

@ -17,9 +17,6 @@
:host {
.tb-widget-config {
.tb-advanced-widget-config {
height: 100%;
}
.tb-advanced-widget-config {
height: 100%;
}
@ -69,6 +66,28 @@
padding-left: 8px;
}
}
.fields-group {
padding: 0 16px 8px;
margin-bottom: 10px;
border: 1px groove rgba(0, 0, 0, .25);
border-radius: 4px;
legend {
color: rgba(0, 0, 0, .7);
width: fit-content;
}
}
.fields-group-slider {
padding: 0;
legend {
margin-left: 16px;
}
.tb-settings {
padding: 0 16px 8px;
}
}
.tb-widget-style {
margin-top: 16px;
}
}
}
@ -94,6 +113,36 @@
white-space: normal;
}
.mat-expansion-panel {
&.tb-settings {
box-shadow: none;
.mat-content {
overflow: visible;
}
.mat-expansion-panel-header {
padding: 0;
&:hover {
background: none;
}
.mat-expansion-indicator {
padding: 2px;
}
}
.mat-expansion-panel-header-description {
align-items: center;
}
.mat-expansion-panel-body{
padding: 0;
}
.tb-json-object-panel {
margin: 0 0 8px;
}
.mat-checkbox-layout {
margin: 5px 0;
}
.mat-checkbox-inner-container {
margin-right: 12px;
}
}
&.tb-datasources {
&.mat-expanded {
overflow: visible;
@ -152,5 +201,8 @@
}
}
}
.mat-slide-toggle-content {
white-space: normal;
}
}
}

37
ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts

@ -212,11 +212,28 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, Cont
showLegend: [null, []],
legendConfig: [null, []]
});
this.widgetSettings.get('showTitle').valueChanges.subscribe((value: boolean) => {
if (value) {
this.widgetSettings.get('titleStyle').enable({emitEvent: false});
this.widgetSettings.get('titleTooltip').enable({emitEvent: false});
this.widgetSettings.get('showTitleIcon').enable({emitEvent: false});
} else {
this.widgetSettings.get('titleStyle').disable({emitEvent: false});
this.widgetSettings.get('titleTooltip').disable({emitEvent: false});
this.widgetSettings.get('showTitleIcon').patchValue(false);
this.widgetSettings.get('showTitleIcon').disable({emitEvent: false});
}
});
this.widgetSettings.get('showTitleIcon').valueChanges.subscribe((value: boolean) => {
if (value) {
this.widgetSettings.get('titleIcon').enable({emitEvent: false});
this.widgetSettings.get('iconColor').enable({emitEvent: false});
this.widgetSettings.get('iconSize').enable({emitEvent: false});
} else {
this.widgetSettings.get('titleIcon').disable({emitEvent: false});
this.widgetSettings.get('iconColor').disable({emitEvent: false});
this.widgetSettings.get('iconSize').disable({emitEvent: false});
}
});
this.widgetSettings.get('showLegend').valueChanges.subscribe((value: boolean) => {
@ -236,6 +253,10 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, Cont
});
}
ngOnDestroy(): void {
this.removeChangeSubscriptions();
}
private removeChangeSubscriptions() {
if (this.dataSettingsChangesSubscription) {
this.dataSettingsChangesSubscription.unsubscribe();
@ -376,7 +397,7 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, Cont
iconColor: isDefined(config.iconColor) ? config.iconColor : 'rgba(0, 0, 0, 0.87)',
iconSize: isDefined(config.iconSize) ? config.iconSize : '24px',
titleTooltip: isDefined(config.titleTooltip) ? config.titleTooltip : '',
showTitle: config.showTitle,
showTitle: isDefined(config.showTitle) ? config.showTitle : false,
dropShadow: isDefined(config.dropShadow) ? config.dropShadow : true,
enableFullscreen: isDefined(config.enableFullscreen) ? config.enableFullscreen : true,
backgroundColor: config.backgroundColor,
@ -396,11 +417,25 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, Cont
},
{emitEvent: false}
);
const showTitle: boolean = this.widgetSettings.get('showTitle').value;
if (showTitle) {
this.widgetSettings.get('titleTooltip').enable({emitEvent: false});
this.widgetSettings.get('titleStyle').enable({emitEvent: false});
this.widgetSettings.get('showTitleIcon').enable({emitEvent: false});
} else {
this.widgetSettings.get('titleTooltip').disable({emitEvent: false});
this.widgetSettings.get('titleStyle').disable({emitEvent: false});
this.widgetSettings.get('showTitleIcon').disable({emitEvent: false});
}
const showTitleIcon: boolean = this.widgetSettings.get('showTitleIcon').value;
if (showTitleIcon) {
this.widgetSettings.get('titleIcon').enable({emitEvent: false});
this.widgetSettings.get('iconColor').enable({emitEvent: false});
this.widgetSettings.get('iconSize').enable({emitEvent: false});
} else {
this.widgetSettings.get('titleIcon').disable({emitEvent: false});
this.widgetSettings.get('iconColor').disable({emitEvent: false});
this.widgetSettings.get('iconSize').disable({emitEvent: false});
}
const showLegend: boolean = this.widgetSettings.get('showLegend').value;
if (showLegend) {

1
ui-ngx/src/app/modules/home/components/widget/widget.component.ts

@ -279,6 +279,7 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI
this.widgetContext.servicesMap = ServicesMap;
this.widgetContext.isEdit = this.isEdit;
this.widgetContext.isMobile = this.isMobile;
this.widgetContext.toastTargetId = this.toastTargetId;
this.widgetContext.subscriptionApi = {
createSubscription: this.createSubscription.bind(this),

1
ui-ngx/src/app/modules/home/models/widget-component.models.ts

@ -230,6 +230,7 @@ export class WidgetContext {
$scope: IDynamicWidgetComponent;
isEdit: boolean;
isMobile: boolean;
toastTargetId: string;
widgetNamespace?: string;
subscriptionApi?: WidgetSubscriptionApi;

9
ui-ngx/src/app/shared/components/marked-options.service.ts

@ -21,6 +21,7 @@ import { DOCUMENT } from '@angular/common';
import { WINDOW } from '@core/services/window.service';
import { Tokenizer } from 'marked';
import * as marked from 'marked';
import { Clipboard } from '@angular/cdk/clipboard';
const copyCodeBlock = '{:copy-code}';
const codeStyleRegex = '^{:code-style="(.*)"}\n';
@ -47,6 +48,7 @@ export class MarkedOptionsService extends MarkedOptions {
private id = 1;
constructor(private translate: TranslateService,
private clipboardService: Clipboard,
@Inject(WINDOW) private readonly window: Window,
@Inject(DOCUMENT) private readonly document: Document) {
super();
@ -162,7 +164,7 @@ export class MarkedOptionsService extends MarkedOptions {
const copyWrapper = $('#codeWrapper' + id);
if (copyWrapper.hasClass('noChars')) {
const text = decodeURIComponent($('#copyCodeId' + id).text());
this.window.navigator.clipboard.writeText(text).then(() => {
if (this.clipboardService.copy(text)) {
import('tooltipster').then(
() => {
if (!copyWrapper.hasClass('tooltipstered')) {
@ -186,9 +188,8 @@ export class MarkedOptionsService extends MarkedOptions {
}
const tooltip = copyWrapper.tooltipster('instance');
tooltip.open();
}
);
});
});
}
}
}
}

18
ui-ngx/src/app/shared/components/material-icon-select.component.ts

@ -92,14 +92,16 @@ export class MaterialIconSelectComponent extends PageComponent implements OnInit
}
openIconDialog() {
this.dialogs.materialIconPicker(this.materialIconFormGroup.get('icon').value).subscribe(
(icon) => {
if (icon) {
this.materialIconFormGroup.patchValue(
{icon}, {emitEvent: true}
);
if (!this.disabled) {
this.dialogs.materialIconPicker(this.materialIconFormGroup.get('icon').value).subscribe(
(icon) => {
if (icon) {
this.materialIconFormGroup.patchValue(
{icon}, {emitEvent: true}
);
}
}
}
);
);
}
}
}

43
ui-ngx/src/app/shared/components/protobuf-content.component.html

@ -0,0 +1,43 @@
<!--
Copyright © 2016-2021 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.
-->
<div style="background: #fff;" [ngClass]="{'fill-height': fillHeight}"
tb-fullscreen
[fullscreen]="fullscreen" (fullscreenChanged)="onFullscreen()" fxLayout="column">
<div fxLayout="row" fxLayoutAlign="start center" style="height: 40px;" class="tb-protobuf-content-toolbar">
<label class="tb-title no-padding">{{ label }}</label>
<span fxFlex></span>
<button type="button"
mat-button *ngIf="!readonly && !disabled" class="tidy" (click)="beautifyProtobuf()">
{{'js-func.tidy' | translate }}
</button>
<fieldset style="width: initial">
<div matTooltip="{{(fullscreen ? 'fullscreen.exit' : 'fullscreen.expand') | translate}}"
matTooltipPosition="above"
style="border-radius: 50%"
(click)="fullscreen = !fullscreen">
<button type='button' mat-button mat-icon-button class="tb-mat-32">
<mat-icon class="material-icons">{{ fullscreen ? 'fullscreen_exit' : 'fullscreen' }}</mat-icon>
</button>
</div>
</fieldset>
</div>
<div id="tb-protobuf-panel" tb-toast toastTarget="{{toastTargetId}}"
class="tb-protobuf-content-panel" fxLayout="column">
<div #protobufEditor id="tb-protobuf-input" [ngStyle]="editorStyle" [ngClass]="{'fill-height': fillHeight}"></div>
</div>
</div>

57
ui-ngx/src/app/shared/components/protobuf-content.component.scss

@ -0,0 +1,57 @@
/**
* Copyright © 2016-2021 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.
*/
:host {
position: relative;
.fill-height {
height: 100%;
}
}
.tb-protobuf-content-toolbar {
button.mat-button, button.mat-icon-button, button.mat-icon-button.tb-mat-32 {
align-items: center;
vertical-align: middle;
min-width: 32px;
min-height: 15px;
padding: 4px;
margin: 0;
font-size: .8rem;
line-height: 15px;
color: #7b7b7b;
background: rgba(220, 220, 220, .35);
&:not(:last-child) {
margin-right: 4px;
}
}
}
.tb-protobuf-content-panel {
height: 100%;
margin-left: 15px;
border: 1px solid #c0c0c0;
#tb-protobuf-input {
width: 100%;
min-width: 200px;
min-height: 160px;
height: 100%;
&:not(.fill-height) {
min-height: 200px;
}
}
}

193
ui-ngx/src/app/shared/components/protobuf-content.component.ts

@ -0,0 +1,193 @@
///
/// Copyright © 2016-2021 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,
ElementRef,
forwardRef,
Input,
OnDestroy,
OnInit,
ViewChild
} from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { Ace } from 'ace-builds';
import { CancelAnimationFrame, RafService } from '@core/services/raf.service';
import { ResizeObserver } from '@juggle/resize-observer';
import { guid } from '@core/utils';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { getAce } from '@shared/models/ace/ace.models';
import { beautifyJs } from '@shared/models/beautify.models';
@Component({
selector: 'tb-protobuf-content',
templateUrl: './protobuf-content.component.html',
styleUrls: ['./protobuf-content.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => ProtobufContentComponent),
multi: true
}
]
})
export class ProtobufContentComponent implements OnInit, ControlValueAccessor, OnDestroy {
@ViewChild('protobufEditor', {static: true})
protobufEditorElmRef: ElementRef;
private protobufEditor: Ace.Editor;
private editorsResizeCaf: CancelAnimationFrame;
private editorResize$: ResizeObserver;
private ignoreChange = false;
toastTargetId = `protobufContentEditor-${guid()}`;
@Input() label: string;
@Input() disabled: boolean;
@Input() fillHeight: boolean;
@Input() editorStyle: {[klass: string]: any};
@Input() tbPlaceholder: string;
private readonlyValue: boolean;
get readonly(): boolean {
return this.readonlyValue;
}
@Input()
set readonly(value: boolean) {
this.readonlyValue = coerceBooleanProperty(value);
}
fullscreen = false;
contentBody: string;
errorShowed = false;
private propagateChange = null;
constructor(public elementRef: ElementRef,
protected store: Store<AppState>,
private raf: RafService) {
}
ngOnInit(): void {
const editorElement = this.protobufEditorElmRef.nativeElement;
let editorOptions: Partial<Ace.EditorOptions> = {
mode: `ace/mode/protobuf`,
showGutter: true,
showPrintMargin: false,
readOnly: this.disabled || this.readonly,
};
const advancedOptions = {
enableSnippets: true,
enableBasicAutocompletion: true,
enableLiveAutocompletion: true
};
editorOptions = {...editorOptions, ...advancedOptions};
getAce().subscribe(
(ace) => {
this.protobufEditor = ace.edit(editorElement, editorOptions);
this.protobufEditor.session.setUseWrapMode(true);
this.protobufEditor.setValue(this.contentBody ? this.contentBody : '', -1);
this.protobufEditor.setReadOnly(this.disabled || this.readonly);
this.protobufEditor.on('change', () => {
if (!this.ignoreChange) {
this.updateView();
}
});
this.editorResize$ = new ResizeObserver(() => {
this.onAceEditorResize();
});
this.editorResize$.observe(editorElement);
}
);
}
ngOnDestroy(): void {
if (this.editorResize$) {
this.editorResize$.disconnect();
}
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
if (this.protobufEditor) {
this.protobufEditor.setReadOnly(this.disabled || this.readonly);
}
}
writeValue(value: string): void {
this.contentBody = value;
if (this.protobufEditor) {
this.ignoreChange = true;
this.protobufEditor.setValue(this.contentBody ? this.contentBody : '', -1);
this.ignoreChange = false;
}
}
updateView() {
const editorValue = this.protobufEditor.getValue();
if (this.contentBody !== editorValue) {
this.contentBody = editorValue;
this.propagateChange(this.contentBody);
}
}
beautifyProtobuf() {
beautifyJs(this.contentBody, {indent_size: 4, wrap_line_length: 60}).subscribe(
(res) => {
this.protobufEditor.setValue(res ? res : '', -1);
this.updateView();
}
);
}
onFullscreen() {
if (this.protobufEditor) {
setTimeout(() => {
this.protobufEditor.resize();
}, 0);
}
}
private onAceEditorResize() {
if (this.editorsResizeCaf) {
this.editorsResizeCaf();
this.editorsResizeCaf = null;
}
this.editorsResizeCaf = this.raf.raf(() => {
this.protobufEditor.resize();
this.protobufEditor.renderer.updateFull();
});
}
}

4
ui-ngx/src/app/shared/models/ace/ace.models.ts

@ -36,6 +36,8 @@ export function loadAceDependencies(): Observable<any> {
aceObservables.push(from(import('ace-builds/src-noconflict/mode-text')));
aceObservables.push(from(import('ace-builds/src-noconflict/mode-markdown')));
aceObservables.push(from(import('ace-builds/src-noconflict/mode-html')));
aceObservables.push(from(import('ace-builds/src-noconflict/mode-c_cpp')));
aceObservables.push(from(import('ace-builds/src-noconflict/mode-protobuf')));
aceObservables.push(from(import('ace-builds/src-noconflict/snippets/java')));
aceObservables.push(from(import('ace-builds/src-noconflict/snippets/css')));
aceObservables.push(from(import('ace-builds/src-noconflict/snippets/json')));
@ -43,6 +45,8 @@ export function loadAceDependencies(): Observable<any> {
aceObservables.push(from(import('ace-builds/src-noconflict/snippets/text')));
aceObservables.push(from(import('ace-builds/src-noconflict/snippets/markdown')));
aceObservables.push(from(import('ace-builds/src-noconflict/snippets/html')));
aceObservables.push(from(import('ace-builds/src-noconflict/snippets/c_cpp')));
aceObservables.push(from(import('ace-builds/src-noconflict/snippets/protobuf')));
aceObservables.push(from(import('ace-builds/src-noconflict/theme-textmate')));
aceObservables.push(from(import('ace-builds/src-noconflict/theme-github')));
return forkJoin(aceObservables).pipe(

7
ui-ngx/src/app/shared/shared.module.ts

@ -155,6 +155,7 @@ import { MarkedOptionsService } from '@shared/components/marked-options.service'
import { TbPopoverService } from '@shared/components/popover.service';
import { HELP_MARKDOWN_COMPONENT_TOKEN, SHARED_MODULE_TOKEN } from '@shared/components/tokens';
import { TbMarkdownComponent } from '@shared/components/markdown.component';
import { ProtobufContentComponent } from './components/protobuf-content.component';
export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) {
return markedOptionsService;
@ -268,7 +269,8 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
OtaPackageAutocompleteComponent,
WidgetsBundleSearchComponent,
CopyButtonComponent,
TogglePasswordComponent
TogglePasswordComponent,
ProtobufContentComponent
],
imports: [
CommonModule,
@ -458,7 +460,8 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
OtaPackageAutocompleteComponent,
WidgetsBundleSearchComponent,
CopyButtonComponent,
TogglePasswordComponent
TogglePasswordComponent,
ProtobufContentComponent
]
})
export class SharedModule { }

6
ui-ngx/src/assets/locale/locale.constant-el_GR.json

@ -596,8 +596,8 @@
"make-private-dashboard-title": "Είστε σίγουροι ότι θέλετε να κάνετε το dashboard '{{dashboardTitle}}' ιδιωτικό",
"make-private-dashboard-text": "Μετά την επιβεβαίωση το dashboard θα γίνουν ιδιωτικά και δεν θα είναι διαθέσιμα από τρίτους.",
"make-private-dashboard": "Κάνε το dashboard ιδιωτικό",
"socialshare-text": "'{{dashboardTitle}}' powered by EyeTech",
"socialshare-title": "'{{dashboardTitle}}' powered by EyeTech",
"socialshare-text": "'{{dashboardTitle}}' powered by ThingsBoard",
"socialshare-title": "'{{dashboardTitle}}' powered by ThingsBoard",
"select-dashboard": "Επιλογή dashboard",
"no-dashboards-matching": "Δεν βρέθηκαν dashboards που να αντιστοιχούν σε '{{entity}}'.",
"dashboard-required": "Απαιτείται dashboard.",
@ -2496,7 +2496,7 @@
"domain-name": "Όνομα Domain",
"help-link-base-url": "Base url για συνδέσμους βοηθείας",
"enable-help-links": "Ενεργοποίηση συνδέσμων βοηθείας",
"error-verification-url": "Ένα όνομα domain δεν πρέπει να περιέχει σύμβολα '/' και ':'. Παράδειγμα: gprs.cloud",
"error-verification-url": "Ένα όνομα domain δεν πρέπει να περιέχει σύμβολα '/' και ':'. Παράδειγμα: thingsboard.io",
"show-platform-name-version": "Εμφάνιση ονόματος και έκδοσης πλατφόρμας",
"platform-name": "Όνομα πλατφόρμας",
"platform-version": "Έκδοση πλατφόρμας",

10
ui-ngx/src/assets/locale/locale.constant-en_US.json

@ -3030,7 +3030,7 @@
"title": "Title",
"title-tooltip": "Title Tooltip",
"general-settings": "General settings",
"display-title": "Display title",
"display-title": "Display widget title",
"drop-shadow": "Drop shadow",
"enable-fullscreen": "Enable fullscreen",
"background-color": "Background color",
@ -3039,7 +3039,7 @@
"margin": "Margin",
"widget-style": "Widget style",
"title-style": "Title style",
"mobile-mode-settings": "Mobile mode settings",
"mobile-mode-settings": "Mobile mode",
"order": "Order",
"height": "Height",
"mobile-hide": "Hide widget in mobile mode",
@ -3048,6 +3048,7 @@
"timewindow": "Timewindow",
"use-dashboard-timewindow": "Use dashboard timewindow",
"display-timewindow": "Display timewindow",
"legend": "Legend",
"display-legend": "Display legend",
"datasources": "Datasources",
"maximum-datasources": "Maximum { count, plural, 1 {1 datasource is allowed.} other {# datasources are allowed} }",
@ -3075,9 +3076,12 @@
"delete-action": "Delete action",
"delete-action-title": "Delete widget action",
"delete-action-text": "Are you sure you want delete widget action with name '{{actionName}}'?",
"title-icon": "Title icon",
"display-icon": "Display title icon",
"icon-color": "Icon color",
"icon-size": "Icon size"
"icon-size": "Icon size",
"advanced-settings": "Advanced settings",
"data-settings": "Data settings"
},
"widget-type": {
"import": "Import widget type",

4
ui-ngx/src/assets/locale/locale.constant-fr_FR.json

@ -1470,7 +1470,7 @@
"rulechain-required": "Chaîne de règles requise",
"rulechains": "Chaînes de règles",
"select-rulechain": "Sélectionner la chaîne de règles",
"set-root": "Rend la chaîne de règles racine (root) ",
"set-root": "Rendre la chaîne de règles racine (root) ",
"set-root-rulechain-text": "Après la confirmation, la chaîne de règles deviendra racine (root) et gérera tous les messages de transport entrants.",
"set-root-rulechain-title": "Voulez-vous vraiment que la chaîne de règles '{{ruleChainName}} soit racine (root) ?",
"system": "Système",
@ -1498,7 +1498,7 @@
"edge-template-root": "Racine du modèle",
"search": "Rechercher des chaînes de règles",
"selected-rulechains": "{count, plural, 1 {1 rule chain} other {# rule chains} } sélectionné",
"open-rulechain": "Chaîne de règles ouverte",
"open-rulechain": "Ouvrir la Chaîne de règles",
"assign-to-edge": "Attribuer à Bordure",
"edge-rulechain": "Chaîne de règles Bordure",
"unassign-rulechains-from-edge-title": "Voulez-vous vraiment annuler l'attribution de {count, plural, 1 {1 rulechain} other {# rulechains} }?"

2
ui-ngx/src/assets/locale/locale.constant-ka_GE.json

@ -492,7 +492,7 @@
"make-private-dashboard-text": "დადასტურების შემდეგ დეშბორდი გახდება პრივატული და აღარ იქნება ხელმისაწვდომი სხვა მომხმარებლებისთვის",
"make-private-dashboard": "აქციე დეშბორდი პრივატულად",
"socialshare-text": "სოციალური ტექსტი",
"socialshare-title": "'{{dashboardTitle}}' Powered by Giot",
"socialshare-title": "'{{dashboardTitle}}' Powered by ThingsBoard",
"select-dashboard": "აირჩიე დეშბორდი",
"no-dashboards-matching": "'{{entity}}'-ს მზგავსი დეშბორდი არ იქნა ნაპოვნი.",
"dashboard-required": "დეშბორდი აუცილებელია",

4
ui-ngx/src/assets/locale/locale.constant-lv_LV.json

@ -453,8 +453,8 @@
"make-private-dashboard-title": "Vai esat pārliecināts, ka vēlaties veidot paneli '{{dashboardTitle}}' privātu?",
"make-private-dashboard-text": "Pēc apstiprinājuma panelis būs privāts un nebūs pieejams citiem.",
"make-private-dashboard": "Veidot paneli privātu",
"socialshare-text": "'{{dashboardTitle}}' atbalsts no TeT",
"socialshare-title": "'{{dashboardTitle}}' atbalsts no TeT",
"socialshare-text": "'{{dashboardTitle}}' atbalsts no ThingsBoard",
"socialshare-title": "'{{dashboardTitle}}' atbalsts no ThingsBoard",
"select-dashboard": "Atlasīt paneli",
"no-dashboards-matching": "Nav atbilstoši paneļi '{{entity}}' atrasti.",
"dashboard-required": "Penelis ir nepieciešams.",

12
ui-ngx/src/assets/locale/locale.constant-ru_RU.json

@ -1631,7 +1631,7 @@
"advanced": "Дополнительно",
"title": "Название",
"general-settings": "Общие настройки",
"display-title": "Показать название",
"display-title": "Показать название на виджете",
"drop-shadow": "Тень",
"enable-fullscreen": "Во весь экран",
"background-color": "Цвет фона",
@ -1640,7 +1640,7 @@
"margin": "Margin",
"widget-style": "Стиль виджета",
"title-style": "Стиль названия",
"mobile-mode-settings": "Настройки мобильного режима",
"mobile-mode-settings": "Мобильный режим",
"order": "Порядок",
"height": "Высота",
"units": "Специальный символ после значения",
@ -1648,6 +1648,7 @@
"timewindow": "Временное окно",
"use-dashboard-timewindow": "Использовать временное окно дашборда",
"display-timewindow": "Показывать временное окно",
"legend": "Легенда",
"display-legend": "Показать легенду",
"datasources": "Источники данных",
"maximum-datasources": "Максимальной количество источников данных равно {{count}}",
@ -1673,9 +1674,12 @@
"delete-action": "Удалить действие",
"delete-action-title": "Удалить действие виджета",
"delete-action-text": "Вы точно хотите удалить действие виджета '{{actionName}}'?",
"display-icon": "Показывать иконку в названии",
"title-icon": "Иконка в названии виджета",
"display-icon": "Показывать иконку в названии виджета",
"icon-color": "Цвет иконки",
"icon-size": "Размер иконки"
"icon-size": "Размер иконки",
"advanced-settings": "Расширенные настройки",
"data-settings": "Настройки данных"
},
"widget-type": {
"import": "Импортировать тип виджета",

12
ui-ngx/src/assets/locale/locale.constant-uk_UA.json

@ -2202,7 +2202,7 @@
"advanced": "Додатково",
"title": "Назва",
"general-settings": "Загальні налаштування",
"display-title": "Відобразити назву",
"display-title": "Відобразити назву у віджеті",
"drop-shadow": "Тінь",
"enable-fullscreen": "Увімкнути повноекранний режим",
"enable-data-export": "Увімкнути експорт даних",
@ -2212,7 +2212,7 @@
"margin": "Границі",
"widget-style": "Стиль віджетів",
"title-style": "Стиль заголовка",
"mobile-mode-settings": "Налаштування мобільного режиму",
"mobile-mode-settings": "мобільний режим",
"order": "Порядок",
"height": "Висота",
"units": "Спеціальний символ після значення",
@ -2220,6 +2220,7 @@
"timewindow": "Вікно часу",
"use-dashboard-timewindow": "Використати вікно часу на панелі візуалізації",
"display-timewindow": "Показувати вікно часу",
"legend": "Легенда",
"display-legend": "Показати легенду",
"datasources": "Джерела даних",
"maximum-datasources": "Максимально { count, plural, 1 {1 дозволене джерело даних.} other {# дозволені джерела даних } }",
@ -2245,9 +2246,12 @@
"delete-action": "Видалити дію",
"delete-action-title": "Видалити дію віджета",
"delete-action-text": "Ви впевнені, що хочете видалити дію віджета '{{actionName}}'?",
"display-icon": "Показувати іконку у назві",
"title-icon": "Іконка у назві віджету",
"display-icon": "Показувати іконку у назві віджету",
"icon-color": "Колір іконки",
"icon-size": "Розмір іконки"
"icon-size": "Розмір іконки",
"advanced-settings": "Розширені налаштування",
"data-settings": "Налаштування даних"
},
"widget-type": {
"import": "Імпортувати тип віджета",

Loading…
Cancel
Save