Browse Source

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

pull/12184/head
Igor Kulikov 2 years ago
parent
commit
28f39dee4a
  1. 1
      application/src/main/data/json/system/widget_bundles/buttons.json
  2. 1
      application/src/main/data/json/system/widget_bundles/control_widgets.json
  3. 43
      application/src/main/data/json/system/widget_types/two_segment_button.json
  4. 9
      application/src/main/java/org/thingsboard/server/service/housekeeper/HousekeeperService.java
  5. 11
      application/src/main/java/org/thingsboard/server/service/housekeeper/processor/HousekeeperTaskProcessor.java
  6. 2
      application/src/main/java/org/thingsboard/server/service/housekeeper/processor/LatestTsDeletionTaskProcessor.java
  7. 2
      application/src/main/java/org/thingsboard/server/service/housekeeper/processor/TsHistoryDeletionTaskProcessor.java
  8. 58
      application/src/test/java/org/thingsboard/server/service/housekeeper/HousekeeperServiceTest.java
  9. 8
      common/util/src/main/java/org/thingsboard/common/util/ThingsBoardScheduledThreadPoolExecutor.java
  10. 6
      msa/js-executor/api/jsInvokeMessageProcessor.ts
  11. 2
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/SemaphoreWithTbMsgQueue.java
  12. 6
      ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts
  13. 271
      ui-ngx/src/app/modules/home/components/widget/config/basic/button/segmented-button-basic-config.component.html
  14. 145
      ui-ngx/src/app/modules/home/components/widget/config/basic/button/segmented-button-basic-config.component.ts
  15. 346
      ui-ngx/src/app/modules/home/components/widget/lib/button/segmented-button-widget.models.ts
  16. 33
      ui-ngx/src/app/modules/home/components/widget/lib/button/two-segment-button-widget.component.html
  17. 37
      ui-ngx/src/app/modules/home/components/widget/lib/button/two-segment-button-widget.component.scss
  18. 118
      ui-ngx/src/app/modules/home/components/widget/lib/button/two-segment-button-widget.component.ts
  19. 263
      ui-ngx/src/app/modules/home/components/widget/lib/settings/button/segmented-button-widget-settings.component.html
  20. 134
      ui-ngx/src/app/modules/home/components/widget/lib/settings/button/segmented-button-widget-settings.component.ts
  21. 92
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-toggle-custom-style-panel.component.html
  22. 68
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-toggle-custom-style-panel.component.scss
  23. 210
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-toggle-custom-style-panel.component.ts
  24. 44
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-toggle-custom-style.component.html
  25. 46
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-toggle-custom-style.component.scss
  26. 169
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-toggle-custom-style.component.ts
  27. 10
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts
  28. 6
      ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts
  29. 3
      ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts
  30. 42
      ui-ngx/src/app/shared/components/button/widget-button-toggle.component.html
  31. 189
      ui-ngx/src/app/shared/components/button/widget-button-toggle.component.scss
  32. 234
      ui-ngx/src/app/shared/components/button/widget-button-toggle.component.ts
  33. 3
      ui-ngx/src/app/shared/shared.module.ts
  34. 27
      ui-ngx/src/assets/locale/locale.constant-en_US.json
  35. 1
      ui-ngx/src/assets/widget/segmented-button/rounded-layout.svg
  36. 1
      ui-ngx/src/assets/widget/segmented-button/squared-layout.svg

1
application/src/main/data/json/system/widget_bundles/buttons.json

@ -11,6 +11,7 @@
"action_button",
"command_button",
"toggle_button",
"two_segment_button",
"power_button"
]
}

1
application/src/main/data/json/system/widget_bundles/control_widgets.json

@ -11,6 +11,7 @@
"single_switch",
"command_button",
"toggle_button",
"two_segment_button",
"power_button",
"slider",
"control_widgets.switch_control",

43
application/src/main/data/json/system/widget_types/two_segment_button.json

File diff suppressed because one or more lines are too long

9
application/src/main/java/org/thingsboard/server/service/housekeeper/HousekeeperService.java

@ -117,9 +117,10 @@ public class HousekeeperService {
throw new IllegalArgumentException("Unsupported task type " + taskType);
}
Future<Object> future = null;
try {
long startTs = System.currentTimeMillis();
Future<Object> future = taskExecutor.submit(() -> {
future = taskExecutor.submit(() -> {
taskProcessor.process((T) task);
return null;
});
@ -137,7 +138,7 @@ public class HousekeeperService {
if (e instanceof ExecutionException) {
error = e.getCause();
} else if (e instanceof TimeoutException) {
error = new TimeoutException("Timeout after " + config.getTaskProcessingTimeout() + " seconds");
error = new TimeoutException("Timeout after " + config.getTaskProcessingTimeout() + " ms");
}
if (msg.getTask().getAttempt() < config.getMaxReprocessingAttempts()) {
@ -153,6 +154,10 @@ public class HousekeeperService {
.build());
}
statsService.ifPresent(statsService -> statsService.reportFailure(taskType, msg));
} finally {
if (future != null && !future.isDone()) {
future.cancel(true);
}
}
}

11
application/src/main/java/org/thingsboard/server/service/housekeeper/processor/HousekeeperTaskProcessor.java

@ -20,6 +20,8 @@ import org.thingsboard.server.common.data.housekeeper.HousekeeperTask;
import org.thingsboard.server.common.data.housekeeper.HousekeeperTaskType;
import org.thingsboard.server.common.msg.housekeeper.HousekeeperClient;
import java.util.concurrent.Future;
public abstract class HousekeeperTaskProcessor<T extends HousekeeperTask> {
@Autowired
@ -29,4 +31,13 @@ public abstract class HousekeeperTaskProcessor<T extends HousekeeperTask> {
public abstract HousekeeperTaskType getTaskType();
public <V> V wait(Future<V> future) throws Exception {
try {
return future.get(); // will be interrupted after taskProcessingTimeout
} catch (InterruptedException e) {
future.cancel(true); // interrupting the underlying task
throw e;
}
}
}

2
application/src/main/java/org/thingsboard/server/service/housekeeper/processor/LatestTsDeletionTaskProcessor.java

@ -33,7 +33,7 @@ public class LatestTsDeletionTaskProcessor extends HousekeeperTaskProcessor<Late
@Override
public void process(LatestTsDeletionHousekeeperTask task) throws Exception {
timeseriesService.removeLatest(task.getTenantId(), task.getEntityId(), List.of(task.getKey())).get();
wait(timeseriesService.removeLatest(task.getTenantId(), task.getEntityId(), List.of(task.getKey())));
log.debug("[{}][{}][{}] Deleted latest telemetry for key '{}'", task.getTenantId(), task.getEntityId().getEntityType(), task.getEntityId(), task.getKey());
}

2
application/src/main/java/org/thingsboard/server/service/housekeeper/processor/TsHistoryDeletionTaskProcessor.java

@ -36,7 +36,7 @@ public class TsHistoryDeletionTaskProcessor extends HousekeeperTaskProcessor<TsH
@Override
public void process(TsHistoryDeletionHousekeeperTask task) throws Exception {
DeleteTsKvQuery deleteQuery = new BaseDeleteTsKvQuery(task.getKey(), 0, System.currentTimeMillis(), false, false);
timeseriesService.remove(task.getTenantId(), task.getEntityId(), List.of(deleteQuery)).get();
wait(timeseriesService.remove(task.getTenantId(), task.getEntityId(), List.of(deleteQuery)));
log.debug("[{}][{}][{}] Deleted timeseries history for key '{}'", task.getTenantId(), task.getEntityId().getEntityType(), task.getEntityId(), task.getKey());
}

58
application/src/test/java/org/thingsboard/server/service/housekeeper/HousekeeperServiceTest.java

@ -93,8 +93,12 @@ import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
@ -103,6 +107,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doCallRealMethod;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
@ -113,7 +118,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@TestPropertySource(properties = {
"queue.core.housekeeper.task-reprocessing-delay-ms=2000",
"queue.core.housekeeper.poll-interval-ms=1000",
"queue.core.housekeeper.max-reprocessing-attempts=5"
"queue.core.housekeeper.max-reprocessing-attempts=5",
"queue.core.housekeeper.task-processing-timeout-ms=5000",
})
public class HousekeeperServiceTest extends AbstractControllerTest {
@ -320,7 +326,7 @@ public class HousekeeperServiceTest extends AbstractControllerTest {
@Test
public void whenTaskProcessingFails_thenReprocess() throws Exception {
TimeoutException error = new TimeoutException("Test timeout");
Exception error = new RuntimeException("Just a test");
doThrow(error).when(tsHistoryDeletionTaskProcessor).process(any());
Device device = createDevice("test", "test");
@ -344,6 +350,54 @@ public class HousekeeperServiceTest extends AbstractControllerTest {
});
}
@Test
public void whenTaskProcessingTimedOut_thenInterruptAndReprocess() throws Exception {
ExecutorService someExecutor = Executors.newSingleThreadExecutor();
AtomicBoolean taskInterrupted = new AtomicBoolean(false);
AtomicBoolean underlyingTaskInterrupted = new AtomicBoolean(false);
doAnswer(invocationOnMock -> {
Future<?> future = someExecutor.submit(() -> {
try {
Thread.sleep(TimeUnit.HOURS.toMillis(24));
} catch (InterruptedException e) {
underlyingTaskInterrupted.set(true);
}
});
try {
future.get();
} catch (InterruptedException e) {
taskInterrupted.set(true);
future.cancel(true);
throw e;
}
return null;
}).when(tsHistoryDeletionTaskProcessor).process(any());
Device device = createDevice("test", "test");
createRelatedData(device.getId());
doDelete("/api/device/" + device.getId()).andExpect(status().isOk());
int attempts = 2;
await().atMost(30, TimeUnit.SECONDS).pollInterval(1, TimeUnit.SECONDS).untilAsserted(() -> {
for (int i = 0; i <= attempts; i++) {
int attempt = i;
verify(housekeeperReprocessingService).submitForReprocessing(argThat(getTaskMatcher(device.getId(), HousekeeperTaskType.DELETE_TS_HISTORY,
task -> task.getAttempt() == attempt)), argThat(error -> error instanceof TimeoutException));
}
});
assertThat(taskInterrupted).isTrue();
assertThat(underlyingTaskInterrupted).isTrue();
assertThat(getTimeseriesHistory(device.getId())).isNotEmpty();
doCallRealMethod().when(tsHistoryDeletionTaskProcessor).process(any());
someExecutor.shutdown();
await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> {
assertThat(getTimeseriesHistory(device.getId())).isEmpty();
});
}
@Test
public void whenReprocessingAttemptsExceeded_thenDropTheTask() throws Exception {
TimeoutException error = new TimeoutException("Test timeout");

8
common/util/src/main/java/org/thingsboard/common/util/ThingsBoardScheduledThreadPoolExecutor.java

@ -1,12 +1,12 @@
/**
* Copyright © 2016-2024 The Thingsboard Authors
* <p>
*
* 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
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
*
* 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.

6
msa/js-executor/api/jsInvokeMessageProcessor.ts

@ -130,7 +130,7 @@ export class JsInvokeMessageProcessor {
processCompileRequest(requestId: string, responseTopic: string, headers: any, compileRequest: JsCompileRequest) {
const scriptId = JsInvokeMessageProcessor.getScriptId(compileRequest);
this.logger.debug('[%s] Processing compile request, scriptId: [%s]', requestId, scriptId);
this.logger.debug('[%s] Processing compile request, scriptId: [%s], compileRequest [%s]', requestId, scriptId, compileRequest);
if (this.scriptMap.has(scriptId)) {
const compileResponse = JsInvokeMessageProcessor.createCompileResponse(scriptId, true);
this.logger.debug('[%s] Script was already compiled, scriptId: [%s]', requestId, scriptId);
@ -154,7 +154,7 @@ export class JsInvokeMessageProcessor {
processInvokeRequest(requestId: string, responseTopic: string, headers: any, invokeRequest: JsInvokeRequest) {
const scriptId = JsInvokeMessageProcessor.getScriptId(invokeRequest);
this.logger.debug('[%s] Processing invoke request, scriptId: [%s]', requestId, scriptId);
this.logger.debug('[%s] Processing invoke request, scriptId: [%s], invokeRequest [%s]', requestId, scriptId, invokeRequest);
this.executedScriptsCounter++;
if (this.executedScriptsCounter % statFrequency == 0) {
const nowMs = performance.now();
@ -217,7 +217,7 @@ export class JsInvokeMessageProcessor {
processReleaseRequest(requestId: string, responseTopic: string, headers: any, releaseRequest: JsReleaseRequest) {
const scriptId = JsInvokeMessageProcessor.getScriptId(releaseRequest);
this.logger.debug('[%s] Processing release request, scriptId: [%s]', requestId, scriptId);
this.logger.debug('[%s] Processing release request, scriptId: [%s], releaseRequest [%s]', requestId, scriptId, releaseRequest);
if (this.scriptMap.has(scriptId)) {
const index = this.scriptIds.indexOf(scriptId);
if (index > -1) {

2
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/SemaphoreWithTbMsgQueue.java

@ -115,7 +115,7 @@ public class SemaphoreWithTbMsgQueue {
}
TbMsg msg = tbMsgTbContext.msg();
TbContext ctx = tbMsgTbContext.ctx();
log.warn("[{}] Failed to process message: {}", entityId, msg, t);
log.debug("[{}] Failed to process message: {}", entityId, msg, t);
ctx.tellFailure(msg, t); // you are not allowed to throw here, because queue will remain unprocessed
continue; // We are probably the last who process the queue. We have to continue poll until get successful callback or queue is empty
}

6
ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts

@ -143,6 +143,9 @@ import {
UnreadNotificationBasicConfigComponent
} from '@home/components/widget/config/basic/cards/unread-notification-basic-config.component';
import { ScadaSymbolBasicConfigComponent } from '@home/components/widget/config/basic/scada/scada-symbol-basic-config.component';
import {
SegmentedButtonBasicConfigComponent
} from '@home/components/widget/config/basic/button/segmented-button-basic-config.component';
@NgModule({
declarations: [
@ -174,6 +177,7 @@ import { ScadaSymbolBasicConfigComponent } from '@home/components/widget/config/
BarChartWithLabelsBasicConfigComponent,
SingleSwitchBasicConfigComponent,
ActionButtonBasicConfigComponent,
SegmentedButtonBasicConfigComponent,
CommandButtonBasicConfigComponent,
PowerButtonBasicConfigComponent,
SliderBasicConfigComponent,
@ -227,6 +231,7 @@ import { ScadaSymbolBasicConfigComponent } from '@home/components/widget/config/
BarChartWithLabelsBasicConfigComponent,
SingleSwitchBasicConfigComponent,
ActionButtonBasicConfigComponent,
SegmentedButtonBasicConfigComponent,
CommandButtonBasicConfigComponent,
PowerButtonBasicConfigComponent,
SliderBasicConfigComponent,
@ -272,6 +277,7 @@ export const basicWidgetConfigComponentsMap: {[key: string]: Type<IBasicWidgetCo
'tb-bar-chart-with-labels-basic-config': BarChartWithLabelsBasicConfigComponent,
'tb-single-switch-basic-config': SingleSwitchBasicConfigComponent,
'tb-action-button-basic-config': ActionButtonBasicConfigComponent,
'tb-segmented-button-basic-config': SegmentedButtonBasicConfigComponent,
'tb-command-button-basic-config': CommandButtonBasicConfigComponent,
'tb-power-button-basic-config': PowerButtonBasicConfigComponent,
'tb-slider-basic-config': SliderBasicConfigComponent,

271
ui-ngx/src/app/modules/home/components/widget/config/basic/button/segmented-button-basic-config.component.html

@ -0,0 +1,271 @@
<!--
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.
-->
<ng-container [formGroup]="segmentedButtonWidgetConfigForm">
<tb-datasources
*ngIf="!widgetEditMode"
[configMode]="basicMode"
hideDatasourceLabel
hideDataKeys
forceSingleDatasource
formControlName="datasources">
</tb-datasources>
<div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widgets.action-button.behavior</div>
<div class="tb-form-row">
<div class="fixed-title-width" tb-hint-tooltip-icon="{{'widgets.button-state.selected-state-hint' | translate}}" translate>widgets.button-state.selected-state</div>
<tb-get-value-action-settings class="flex-1"
panelTitle="{{ 'widgets.button-state.selected-state' | translate }}"
[valueType]="valueType.BOOLEAN"
trueLabel="{{ 'widgets.button-state.first' | translate }}"
falseLabel="{{ 'widgets.button-state.second' | translate }}"
stateLabel="{{ 'widgets.button-state.initial' | translate }}"
[aliasController]="aliasController"
[targetDevice]="targetDevice"
[widgetType]="widgetType"
formControlName="initialState"></tb-get-value-action-settings>
</div>
<div class="tb-form-row">
<div class="fixed-title-width" tb-hint-tooltip-icon="{{'widgets.action-button.button-click-hint' | translate}}" translate>widgets.action-button.first-button-click</div>
<tb-widget-action-settings class="flex-1"
panelTitle="{{ 'widgets.action-button.first-button-click' | translate }}"
[callbacks]="callbacks"
[widgetType]="widgetType"
formControlName="leftButtonClick">
</tb-widget-action-settings>
</div>
<div class="tb-form-row">
<div class="fixed-title-width" tb-hint-tooltip-icon="{{'widgets.action-button.button-click-hint' | translate}}" translate>widgets.action-button.second-button-click</div>
<tb-widget-action-settings class="flex-1"
panelTitle="{{ 'widgets.action-button.second-button-click' | translate }}"
[callbacks]="callbacks"
[widgetType]="widgetType"
formControlName="rightButtonClick">
</tb-widget-action-settings>
</div>
<div class="tb-form-row">
<div class="fixed-title-width" tb-hint-tooltip-icon="{{'widgets.button-state.disabled-state-hint' | translate}}" translate>widgets.button-state.disabled-state</div>
<tb-get-value-action-settings class="flex-1"
panelTitle="{{ 'widgets.button-state.disabled-state' | translate }}"
[valueType]="valueType.BOOLEAN"
stateLabel="{{ 'widgets.button-state.disabled' | translate }}"
[aliasController]="aliasController"
[targetDevice]="targetDevice"
[widgetType]="widgetType"
formControlName="disabledState"></tb-get-value-action-settings>
</div>
</div>
<div class="tb-form-panel" formGroupName="appearance">
<div class="tb-form-panel-title" translate>widget-config.appearance</div>
<tb-image-cards-select rowHeight="3:1"
[cols]="{columns: 2,
breakpoints: {
'lt-sm': 1
}}"
label="{{ 'widgets.segmented-button.layout' | translate }}" formControlName="layout">
<tb-image-cards-select-option *ngFor="let layout of segmentedButtonLayouts"
[value]="layout"
[image]="segmentedButtonLayoutImageMap.get(layout)">
{{ segmentedButtonLayoutTranslationMap.get(layout) | translate }}
</tb-image-cards-select-option>
</tb-image-cards-select>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide" formControlName="autoScale">
{{ 'widgets.button.auto-scale' | translate }}
</mat-slide-toggle>
</div>
<div class="tb-form-row space-between">
<div translate>widgets.segmented-button.card-border</div>
<div class="flex flex-row items-center gap-2">
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput formControlName="cardBorder" type="number" min="0" step="1" placeholder="{{ 'widget-config.set' | translate }}">
<div matSuffix class="lt-md:!hidden">px</div>
</mat-form-field>
<tb-color-input asBoxInput
formControlName="cardBorderColor">
</tb-color-input>
</div>
</div>
</div>
<div class="tb-form-panel" formGroupName="appearance">
<div class="flex flex-row items-center justify-between">
<div class="tb-form-panel-title" translate>widgets.segmented-button.button-appearance</div>
<tb-toggle-select [(ngModel)]="segmentedButtonAppearanceType" [ngModelOptions]="{standalone: true}">
<tb-toggle-option value="first">{{ 'widgets.segmented-button.first' | translate }}</tb-toggle-option>
<tb-toggle-option value="second">{{ 'widgets.segmented-button.second' | translate }}</tb-toggle-option>
</tb-toggle-select>
</div>
<div class="tb-form-panel no-border no-padding" formGroupName="leftAppearance" [class.!hidden]="segmentedButtonAppearanceType !== 'first'">
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showLabel">
{{ 'widgets.button.label' | translate }}
</mat-slide-toggle>
<div class="flex flex-1 flex-row items-center justify-start gap-2">
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="label" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
<tb-font-settings formControlName="labelFont"
[previewText]="segmentedButtonWidgetConfigForm.get('appearance.leftAppearance.label').value">
</tb-font-settings>
</div>
</div>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showIcon">
{{ 'widgets.button.icon' | translate }}
</mat-slide-toggle>
<div class="flex flex-1 flex-row items-center justify-start gap-2">
<mat-form-field appearance="outline" class="number flex" subscriptSizing="dynamic">
<input matInput type="number" min="0" formControlName="iconSize" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
<tb-css-unit-select class="flex-1" formControlName="iconSizeUnit"></tb-css-unit-select>
<tb-material-icon-select asBoxInput
iconClearButton
formControlName="icon">
</tb-material-icon-select>
</div>
</div>
</div>
<div class="tb-form-panel no-border no-padding" formGroupName="rightAppearance" [class.!hidden]="segmentedButtonAppearanceType !== 'second'">
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showLabel">
{{ 'widgets.button.label' | translate }}
</mat-slide-toggle>
<div class="flex flex-1 flex-row items-center justify-start gap-2">
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="label" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
<tb-font-settings formControlName="labelFont"
[previewText]="segmentedButtonWidgetConfigForm.get('appearance.rightAppearance.label').value">
</tb-font-settings>
</div>
</div>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showIcon">
{{ 'widgets.button.icon' | translate }}
</mat-slide-toggle>
<div class="flex flex-1 flex-row items-center justify-start gap-2">
<mat-form-field appearance="outline" class="number flex" subscriptSizing="dynamic">
<input matInput type="number" min="0" formControlName="iconSize" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
<tb-css-unit-select class="flex-1" formControlName="iconSizeUnit"></tb-css-unit-select>
<tb-material-icon-select asBoxInput
iconClearButton
formControlName="icon">
</tb-material-icon-select>
</div>
</div>
</div>
</div>
<div class="tb-form-panel" formGroupName="appearance">
<div class="flex flex-row items-center justify-between">
<div class="tb-form-panel-title" translate>widgets.segmented-button.color-styles</div>
<tb-toggle-select [(ngModel)]="segmentedButtonColorStylesType" [ngModelOptions]="{standalone: true}">
<tb-toggle-option value="selected">{{ 'widgets.segmented-button.selected' | translate }}</tb-toggle-option>
<tb-toggle-option value="unselected">{{ 'widgets.segmented-button.unselected' | translate }}</tb-toggle-option>
</tb-toggle-select>
</div>
<div class="tb-form-panel no-border no-padding" formGroupName="selectedStyle" [class.!hidden]="segmentedButtonColorStylesType !== 'selected'">
<div class="tb-form-row space-between column-xs">
<div>{{ 'widgets.button.color-palette' | translate }}</div>
<div class="flex flex-row items-center justify-start gap-3">
<div class="flex flex-row items-center justify-start gap-2">
<div translate>widgets.button.main</div>
<tb-color-input asBoxInput
formControlName="mainColor">
</tb-color-input>
</div>
<mat-divider vertical></mat-divider>
<div class="flex flex-row items-center justify-start gap-2">
<div translate>widgets.button.background</div>
<tb-color-input asBoxInput
formControlName="backgroundColor">
</tb-color-input>
</div>
</div>
</div>
<div class="tb-form-panel stroked" formGroupName="customStyle">
<mat-expansion-panel class="tb-settings">
<mat-expansion-panel-header class="flex flex-row flex-wrap">
<mat-panel-title>
<div class="tb-form-panel-title" translate>widgets.button.custom-styles</div>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<div class="tb-form-row space-between" *ngFor="let state of widgetButtonToggleStates">
<div>{{ widgetButtonToggleStatesTranslationsMap.get(state) | translate }}</div>
<tb-widget-button-toggle-custom-style
[value]="true"
[state]="state"
[appearance]="segmentedButtonWidgetConfigForm.get('appearance').value"
[autoScale]="true"
[formControlName]="state">
</tb-widget-button-toggle-custom-style>
</div>
</ng-template>
</mat-expansion-panel>
</div>
</div>
<div class="tb-form-panel no-border no-padding" formGroupName="unselectedStyle" [class.!hidden]="segmentedButtonColorStylesType !== 'unselected'">
<div class="tb-form-row space-between column-xs">
<div>{{ 'widgets.button.color-palette' | translate }}</div>
<div class="flex flex-row items-center justify-start gap-3">
<div class="flex flex-row items-center justify-start gap-2">
<div translate>widgets.button.main</div>
<tb-color-input asBoxInput
formControlName="mainColor">
</tb-color-input>
</div>
<mat-divider vertical></mat-divider>
<div class="flex flex-row items-center justify-start gap-2">
<div translate>widgets.button.background</div>
<tb-color-input asBoxInput
formControlName="backgroundColor">
</tb-color-input>
</div>
</div>
</div>
<div class="tb-form-panel stroked" formGroupName="customStyle">
<mat-expansion-panel class="tb-settings">
<mat-expansion-panel-header class="flex flex-row flex-wrap">
<mat-panel-title>
<div class="tb-form-panel-title" translate>widgets.button.custom-styles</div>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<div class="tb-form-row space-between" *ngFor="let state of widgetButtonToggleStates">
<div>{{ widgetButtonToggleStatesTranslationsMap.get(state) | translate }}</div>
<tb-widget-button-toggle-custom-style
[value]="false"
[state]="state"
[appearance]="segmentedButtonWidgetConfigForm.get('appearance').value"
[autoScale]="true"
[formControlName]="state">
</tb-widget-button-toggle-custom-style>
</div>
</ng-template>
</mat-expansion-panel>
</div>
</div>
</div>
</ng-container>

145
ui-ngx/src/app/modules/home/components/widget/config/basic/button/segmented-button-basic-config.component.ts

@ -0,0 +1,145 @@
///
/// 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 { Datasource, TargetDevice, } from '@shared/models/widget.models';
import { WidgetConfigComponent } from '@home/components/widget/widget-config.component';
import { ValueType } from '@shared/models/constants';
import { getTargetDeviceFromDatasources } from '@shared/models/widget-settings.models';
import {
SegmentedButtonAppearanceType,
SegmentedButtonColorStylesType,
segmentedButtonDefaultSettings,
segmentedButtonLayoutBorder,
segmentedButtonLayoutImages,
segmentedButtonLayouts,
segmentedButtonLayoutTranslations,
SegmentedButtonWidgetSettings,
WidgetButtonToggleState,
widgetButtonToggleStatesTranslations
} from '@home/components/widget/lib/button/segmented-button-widget.models';
@Component({
selector: 'tb-segmented-button-basic-config',
templateUrl: './segmented-button-basic-config.component.html',
styleUrls: ['../basic-config.scss']
})
export class SegmentedButtonBasicConfigComponent extends BasicWidgetConfigComponent {
get targetDevice(): TargetDevice {
const datasources: Datasource[] = this.segmentedButtonWidgetConfigForm.get('datasources').value;
return getTargetDeviceFromDatasources(datasources);
}
segmentedButtonAppearanceType: SegmentedButtonAppearanceType = 'first';
segmentedButtonColorStylesType: SegmentedButtonColorStylesType = 'selected';
widgetButtonToggleStates = Object.keys(WidgetButtonToggleState) as WidgetButtonToggleState[];
widgetButtonToggleStatesTranslationsMap = widgetButtonToggleStatesTranslations;
segmentedButtonLayouts = segmentedButtonLayouts;
segmentedButtonLayoutTranslationMap = segmentedButtonLayoutTranslations;
segmentedButtonLayoutImageMap = segmentedButtonLayoutImages;
segmentedButtonLayoutBorderMap = segmentedButtonLayoutBorder;
valueType = ValueType;
segmentedButtonWidgetConfigForm: UntypedFormGroup;
constructor(protected store: Store<AppState>,
protected widgetConfigComponent: WidgetConfigComponent,
private fb: UntypedFormBuilder) {
super(store, widgetConfigComponent);
}
protected configForm(): UntypedFormGroup {
return this.segmentedButtonWidgetConfigForm;
}
protected onConfigSet(configData: WidgetConfigComponentData) {
const settings: SegmentedButtonWidgetSettings = {...segmentedButtonDefaultSettings, ...(configData.config.settings || {})};
this.segmentedButtonWidgetConfigForm = this.fb.group({
datasources: [configData.config.datasources, []],
initialState: [settings.initialState, []],
leftButtonClick: [settings.leftButtonClick, []],
rightButtonClick: [settings.rightButtonClick, []],
disabledState: [settings.disabledState, []],
appearance: this.fb.group({
layout: [settings.appearance.layout, []],
autoScale: [settings.appearance.autoScale, []],
cardBorder: [settings.appearance.cardBorder, []],
cardBorderColor: [settings.appearance.cardBorderColor, []],
leftAppearance: this.fb.group({
showLabel: [settings.appearance.leftAppearance.showLabel, []],
label: [settings.appearance.leftAppearance.label, []],
labelFont: [settings.appearance.leftAppearance.labelFont, []],
showIcon: [settings.appearance.leftAppearance.showIcon, []],
icon: [settings.appearance.leftAppearance.icon, []],
iconSize: [settings.appearance.leftAppearance.iconSize, []],
iconSizeUnit: [settings.appearance.leftAppearance.iconSizeUnit, []],
}),
rightAppearance: this.fb.group({
showLabel: [settings.appearance.rightAppearance.showLabel, []],
label: [settings.appearance.rightAppearance.label, []],
labelFont: [settings.appearance.rightAppearance.labelFont, []],
showIcon: [settings.appearance.rightAppearance.showIcon, []],
icon: [settings.appearance.rightAppearance.icon, []],
iconSize: [settings.appearance.rightAppearance.iconSize, []],
iconSizeUnit: [settings.appearance.rightAppearance.iconSizeUnit, []],
}),
selectedStyle: this.fb.group({
mainColor: [settings.appearance.selectedStyle.mainColor, []],
backgroundColor: [settings.appearance.selectedStyle.backgroundColor, []],
customStyle: this.fb.group({
enabled: [settings.appearance.selectedStyle.customStyle.enabled, []],
hovered: [settings.appearance.selectedStyle.customStyle.hovered, []],
disabled: [settings.appearance.selectedStyle.customStyle.disabled, []],
})
}),
unselectedStyle: this.fb.group({
mainColor: [settings.appearance.unselectedStyle.mainColor, []],
backgroundColor: [settings.appearance.unselectedStyle.backgroundColor, []],
customStyle: this.fb.group({
enabled: [settings.appearance.unselectedStyle.customStyle.enabled, []],
hovered: [settings.appearance.unselectedStyle.customStyle.hovered, []],
disabled: [settings.appearance.unselectedStyle.customStyle.disabled, []],
})
}),
})
});
}
protected prepareOutputConfig(config: any): WidgetConfigComponentData {
this.widgetConfig.config.datasources = config.datasources;
this.widgetConfig.config.settings = this.widgetConfig.config.settings || {};
this.widgetConfig.config.settings.initialState = config.initialState;
this.widgetConfig.config.settings.disabledState = config.disabledState;
this.widgetConfig.config.settings.leftButtonClick = config.leftButtonClick;
this.widgetConfig.config.settings.rightButtonClick = config.rightButtonClick;
this.widgetConfig.config.settings.appearance = config.appearance;
this.widgetConfig.config.borderRadius = this.segmentedButtonLayoutBorderMap.get(config.appearance.layout);
return this.widgetConfig;
}
}

346
ui-ngx/src/app/modules/home/components/widget/lib/button/segmented-button-widget.models.ts

@ -0,0 +1,346 @@
///
/// 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 { DataToValueType, GetValueAction, GetValueSettings } from '@shared/models/action-widget-settings.models';
import { cssUnit, Font } from '@shared/models/widget-settings.models';
import { defaultWidgetAction, WidgetAction } from '@shared/models/widget.models';
const defaultMainColor = '#305680';
const defaultBackgroundColor = '#E8E8E8';
export const defaultMainColorDisabled = 'rgba(0, 0, 0, 0.38)';
export const defaultBackgroundColorDisabled = 'rgba(0, 0, 0, 0.03)';
export const defaultBorderColorDisabled = defaultBackgroundColorDisabled;
const defaultBorderColor = defaultBackgroundColor;
export enum SegmentedButtonLayout {
squared = 'squared',
rounded = 'rounded'
}
export type SegmentedButtonAppearanceType = 'first' | 'second';
export type SegmentedButtonColorStylesType = 'selected' | 'unselected';
export const segmentedButtonLayouts = Object.keys(SegmentedButtonLayout) as SegmentedButtonLayout[];
export const segmentedButtonLayoutTranslations = new Map<SegmentedButtonLayout, string>(
[
[SegmentedButtonLayout.squared, 'widgets.segmented-button.layout-squared'],
[SegmentedButtonLayout.rounded, 'widgets.segmented-button.layout-rounded']
]
);
export const segmentedButtonLayoutImages = new Map<SegmentedButtonLayout, string>(
[
[SegmentedButtonLayout.squared, 'assets/widget/segmented-button/squared-layout.svg'],
[SegmentedButtonLayout.rounded, 'assets/widget/segmented-button/rounded-layout.svg']
]
);
export const segmentedButtonLayoutBorder = new Map<SegmentedButtonLayout, string>(
[
[SegmentedButtonLayout.squared, '4px'],
[SegmentedButtonLayout.rounded, '40px']
]
);
export interface SegmentedButtonAppearance {
showLabel: boolean;
label: string;
labelFont: Font;
showIcon: boolean;
icon: string;
iconSize: number;
iconSizeUnit: cssUnit;
}
export interface SegmentedButtonStyles {
mainColor: string;
backgroundColor: string;
customStyle: WidgetButtonToggleCustomStyles;
}
export interface ButtonToggleAppearance {
layout: SegmentedButtonLayout;
autoScale: boolean;
cardBorder: number;
cardBorderColor: string;
leftAppearance: SegmentedButtonAppearance;
rightAppearance: SegmentedButtonAppearance;
selectedStyle: SegmentedButtonStyles;
unselectedStyle: SegmentedButtonStyles;
}
export interface SegmentedButtonWidgetSettings {
initialState: GetValueSettings<boolean>;
disabledState: GetValueSettings<boolean>;
leftButtonClick: WidgetAction;
rightButtonClick: WidgetAction;
appearance: ButtonToggleAppearance;
}
export const segmentedButtonDefaultAppearance: ButtonToggleAppearance = {
layout: SegmentedButtonLayout.squared,
autoScale: true,
cardBorder: 1,
cardBorderColor: '#305680',
leftAppearance: {
showLabel: true,
label: 'Traditional',
labelFont: {
family: 'Roboto',
weight: '500',
style: 'normal',
size: 14,
sizeUnit: 'px',
lineHeight: '18px'
},
showIcon: true,
icon: 'home',
iconSize: 24,
iconSizeUnit: 'px',
},
rightAppearance: {
showLabel: true,
label: 'Hi-Perf',
labelFont: {
family: 'Roboto',
weight: '500',
style: 'normal',
size: 14,
sizeUnit: 'px',
lineHeight: '18px'
},
showIcon: true,
icon: 'home',
iconSize: 24,
iconSizeUnit: 'px',
},
selectedStyle: {
mainColor: '#FFFFFF',
backgroundColor: '#00695C',
customStyle: {
enabled: null,
hovered: null,
disabled: null
}
},
unselectedStyle: {
mainColor: '#000000C2',
backgroundColor: '#E8E8E8',
customStyle: {
enabled: null,
hovered: null,
disabled: null
}
}
}
export const segmentedButtonDefaultSettings: SegmentedButtonWidgetSettings = {
initialState: {
action: GetValueAction.DO_NOTHING,
defaultValue: true,
getAttribute: {
key: 'state',
scope: null
},
getTimeSeries: {
key: 'state'
},
getAlarmStatus: {
severityList: null,
typeList: null
},
dataToValue: {
type: DataToValueType.NONE,
compareToValue: true,
dataToValueFunction: '/* Should return boolean value */\nreturn data;'
}
},
disabledState: {
action: GetValueAction.DO_NOTHING,
defaultValue: false,
getAttribute: {
key: 'state',
scope: null
},
getTimeSeries: {
key: 'state'
},
getAlarmStatus: {
severityList: null,
typeList: null
},
dataToValue: {
type: DataToValueType.NONE,
compareToValue: true,
dataToValueFunction: '/* Should return boolean value */\nreturn data;'
}
},
leftButtonClick: defaultWidgetAction(),
rightButtonClick: defaultWidgetAction(),
appearance: segmentedButtonDefaultAppearance
};
const mainCheckedColorVarPrefix = '--tb-widget-button-toggle-main-checked-color-';
const backgroundCheckedColorVarPrefix = '--tb-widget-button-toggle-background-checked-color-';
const borderCheckedColorVarPrefix = '--tb-widget-button-toggle-border-checked-color-';
const mainUncheckedColorVarPrefix = '--tb-widget-button-toggle-main-unchecked-color-';
const backgroundUncheckedColorVarPrefix = '--tb-widget-button-toggle-background-unchecked-color-';
const borderUncheckedColorVarPrefix = '--tb-widget-button-toggle-border-unchecked-color-';
export enum WidgetButtonToggleState {
enabled = 'enabled',
hovered = 'hovered',
disabled = 'disabled'
}
export const widgetButtonToggleStates = Object.keys(WidgetButtonToggleState) as WidgetButtonToggleState[];
export const widgetButtonToggleStatesTranslations = new Map<WidgetButtonToggleState, string>(
[
[WidgetButtonToggleState.enabled, 'widgets.button-state.enabled'],
[WidgetButtonToggleState.hovered, 'widgets.button-state.hovered'],
[WidgetButtonToggleState.disabled, 'widgets.button-state.disabled']
]
);
export interface WidgetButtonToggleCustomStyle {
overrideMainColor?: boolean;
mainColor?: string;
overrideBackgroundColor?: boolean;
backgroundColor?: string;
overrideBorderColor?: boolean;
borderColor?: string;
}
export type WidgetButtonToggleCustomStyles = Record<WidgetButtonToggleState, WidgetButtonToggleCustomStyle>;
export abstract class ButtonToggleStateCssGenerator {
constructor() {}
public generateStateCss(selectedAppearance: SegmentedButtonStyles, unselectedAppearance: SegmentedButtonStyles): string {
const selectedColor = this.getColors(selectedAppearance);
const unselectedColor = this.getColors(unselectedAppearance);
return `${mainCheckedColorVarPrefix}${this.state}: ${selectedColor.mainColor};\n`+
`${backgroundCheckedColorVarPrefix}${this.state}: ${selectedColor.backgroundColor};\n`+
`${borderCheckedColorVarPrefix}${this.state}: ${selectedColor.borderColor};\n`+
`${mainUncheckedColorVarPrefix}${this.state}: ${unselectedColor.mainColor};\n`+
`${backgroundUncheckedColorVarPrefix}${this.state}: ${unselectedColor.backgroundColor};\n`+
`${borderUncheckedColorVarPrefix}${this.state}: ${unselectedColor.borderColor};`;
}
private getColors(appearance: SegmentedButtonStyles) {
let mainColor = this.getMainColor(appearance);
let backgroundColor = this.getBackgroundColor(appearance);
let borderColor = this.getBorderColor();
const stateCustomStyle = appearance.customStyle[this.state];
if (stateCustomStyle?.overrideMainColor && stateCustomStyle?.mainColor) {
mainColor = stateCustomStyle.mainColor;
}
if (stateCustomStyle?.overrideBackgroundColor && stateCustomStyle?.backgroundColor) {
backgroundColor = stateCustomStyle.backgroundColor;
}
if (stateCustomStyle?.overrideBorderColor && stateCustomStyle?.borderColor) {
borderColor = stateCustomStyle.borderColor;
}
return {
mainColor,
backgroundColor,
borderColor
}
}
protected abstract get state(): WidgetButtonToggleState;
protected getMainColor(appearance: SegmentedButtonStyles): string {
return appearance.mainColor || defaultMainColor;
}
protected getBackgroundColor(appearance: SegmentedButtonStyles): string {
return appearance.backgroundColor || defaultBackgroundColor;
}
protected getBorderColor(): string {
return defaultBorderColor;
}
}
class EnabledButtonStateCssGenerator extends ButtonToggleStateCssGenerator {
protected get state(): WidgetButtonToggleState {
return WidgetButtonToggleState.enabled;
}
}
class HoveredButtonStateCssGenerator extends ButtonToggleStateCssGenerator {
protected get state(): WidgetButtonToggleState {
return WidgetButtonToggleState.hovered;
}
}
class DisabledButtonStateCssGenerator extends ButtonToggleStateCssGenerator {
protected get state(): WidgetButtonToggleState {
return WidgetButtonToggleState.disabled;
}
protected getMainColor(): string {
return defaultMainColorDisabled;
}
protected getBackgroundColor(): string {
return defaultBackgroundColorDisabled;
}
protected getBorderColor(): string {
return defaultBorderColorDisabled;
}
}
const buttonToggleStateCssGeneratorsMap = new Map<WidgetButtonToggleState, ButtonToggleStateCssGenerator>(
[
[WidgetButtonToggleState.enabled, new EnabledButtonStateCssGenerator()],
[WidgetButtonToggleState.hovered, new HoveredButtonStateCssGenerator()],
[WidgetButtonToggleState.disabled, new DisabledButtonStateCssGenerator()]
]
);
const widgetButtonCssSelector = '.mat-button-toggle-group.mat-button-toggle-group-appearance-standard.tb-toggle-header';
export const generateWidgetButtonToggleAppearanceCss = (selectedAppearance: SegmentedButtonStyles, unselectedAppearance: SegmentedButtonStyles): string => {
let statesCss = '';
for (const state of widgetButtonToggleStates) {
const generator = buttonToggleStateCssGeneratorsMap.get(state);
statesCss += `\n${generator.generateStateCss(selectedAppearance, unselectedAppearance)}`;
}
return `${widgetButtonCssSelector} {\n`+
`${statesCss}\n`+
`}`;
};
export const generateWidgetButtonToggleBorderLayout = (layout: SegmentedButtonLayout): string => {
return `${widgetButtonCssSelector} {\n`+
`--tb-widget-button-toggle-border-radius: ${segmentedButtonLayoutBorder.get(layout)}\n`+
`}`;
};

33
ui-ngx/src/app/modules/home/components/widget/lib/button/two-segment-button-widget.component.html

@ -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.
-->
<div class="tb-segmented-button-widget" [class.no-pointer-events]="ctx.isEdit">
<ng-container *ngTemplateOutlet="widgetTitlePanel"></ng-container>
<div class="tb-segmented-button-container">
<tb-widget-button-toggle
class="tb-widget-button-toggle"
[appearance]="appearance"
[autoScale]="autoScale"
[disabled]="disabled || (loading$ | async)"
[value]="value"
[ctx]="ctx"
(clicked)="onClick($event)">
</tb-widget-button-toggle>
</div>
<mat-progress-bar class="tb-action-widget-progress" style="height: 4px;" color="accent" mode="indeterminate" *ngIf="loading$ | async"></mat-progress-bar>
</div>

37
ui-ngx/src/app/modules/home/components/widget/lib/button/two-segment-button-widget.component.scss

@ -0,0 +1,37 @@
/**
* 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-segmented-button-widget {
width: 100%;
height: 100%;
position: relative;
.tb-segmented-button-container {
height: 100%;
.tb-widget-button-toggle {
display: block;
height: 100%;
}
}
> div.tb-segmented-button-widget-title-panel {
position: absolute;
top: 12px;
left: 12px;
right: 12px;
z-index: 2;
}
}

118
ui-ngx/src/app/modules/home/components/widget/lib/button/two-segment-button-widget.component.ts

@ -0,0 +1,118 @@
///
/// 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, OnDestroy, OnInit, 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 { ValueType } from '@shared/models/constants';
import {
ButtonToggleAppearance,
segmentedButtonDefaultSettings,
SegmentedButtonWidgetSettings
} from '@home/components/widget/lib/button/segmented-button-widget.models';
import { ComponentStyle } from '@shared/models/widget-settings.models';
import { MatButtonToggleChange } from '@angular/material/button-toggle';
@Component({
selector: 'tb-two-segment-button-widget',
templateUrl: './two-segment-button-widget.component.html',
styleUrls: ['../action/action-widget.scss', './two-segment-button-widget.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class TwoSegmentButtonWidgetComponent extends
BasicActionWidgetComponent implements OnInit, AfterViewInit, OnDestroy {
settings: SegmentedButtonWidgetSettings;
overlayStyle: ComponentStyle = {};
value = false;
disabled = false;
autoScale: boolean;
appearance: ButtonToggleAppearance;
constructor(protected imagePipe: ImagePipe,
protected sanitizer: DomSanitizer,
protected cd: ChangeDetectorRef) {
super(cd);
}
ngOnInit(): void {
super.ngOnInit();
this.settings = {...segmentedButtonDefaultSettings, ...this.ctx.settings};
this.autoScale = this.settings.appearance.autoScale;
const getInitialStateSettings =
{...this.settings.initialState, actionLabel: this.ctx.translate.instant('widgets.rpc-state.initial-state')};
this.createValueGetter(getInitialStateSettings, ValueType.BOOLEAN, {
next: (value) => this.onValue(value)
});
const disabledStateSettings =
{...this.settings.disabledState, actionLabel: this.ctx.translate.instant('widgets.button-state.disabled-state')};
this.createValueGetter(disabledStateSettings, ValueType.BOOLEAN, {
next: (value) => this.onDisabled(value)
});
this.appearance = this.settings.appearance;
}
ngAfterViewInit(): void {
super.ngAfterViewInit();
}
ngOnDestroy() {
super.ngOnDestroy();
}
public onInit() {
super.onInit();
const borderRadius = this.ctx.$widgetElement.css('borderRadius');
this.overlayStyle = {...this.overlayStyle, ...{borderRadius}};
this.cd.detectChanges();
}
private onValue(value: boolean): void {
const newValue = !!value;
if (this.value !== newValue) {
this.value = newValue;
this.appearance = this.settings.appearance;
this.cd.markForCheck();
}
}
private onDisabled(value: boolean): void {
const newDisabled = !!value;
if (this.disabled !== newDisabled) {
this.disabled = newDisabled;
this.cd.markForCheck();
}
}
public onClick(_$event: MatButtonToggleChange) {
if (!this.ctx.isEdit && !this.ctx.isPreview) {
if (_$event.value) {
this.ctx.actionsApi.onWidgetAction(event, this.settings.leftButtonClick);
} else {
this.ctx.actionsApi.onWidgetAction(event, this.settings.rightButtonClick);
}
}
}
}

263
ui-ngx/src/app/modules/home/components/widget/lib/settings/button/segmented-button-widget-settings.component.html

@ -0,0 +1,263 @@
<!--
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.
-->
<ng-container [formGroup]="segmentedButtonWidgetSettingsForm">
<div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widgets.action-button.behavior</div>
<div class="tb-form-row">
<div class="fixed-title-width" tb-hint-tooltip-icon="{{'widgets.button-state.selected-state-hint' | translate}}" translate>widgets.button-state.selected-state</div>
<tb-get-value-action-settings class="flex-1"
panelTitle="{{ 'widgets.button-state.selected-state' | translate }}"
[valueType]="valueType.BOOLEAN"
trueLabel="{{ 'widgets.button-state.first' | translate }}"
falseLabel="{{ 'widgets.button-state.right' | translate }}"
stateLabel="{{ 'widgets.button-state.initial' | translate }}"
[aliasController]="aliasController"
[targetDevice]="targetDevice"
[widgetType]="widgetType"
formControlName="initialState"></tb-get-value-action-settings>
</div>
<div class="tb-form-row">
<div class="fixed-title-width" tb-hint-tooltip-icon="{{'widgets.action-button.first-button-click-hint' | translate}}" translate>widgets.action-button.first-button-click</div>
<tb-widget-action-settings class="flex-1"
panelTitle="{{ 'widgets.action-button.first-button-click' | translate }}"
[callbacks]="callbacks"
[widgetType]="widgetType"
formControlName="leftButtonClick">
</tb-widget-action-settings>
</div>
<div class="tb-form-row">
<div class="fixed-title-width" tb-hint-tooltip-icon="{{'widgets.action-button.right-button-click-hint' | translate}}" translate>widgets.action-button.second-button-click</div>
<tb-widget-action-settings class="flex-1"
panelTitle="{{ 'widgets.action-button.second-button-click' | translate }}"
[callbacks]="callbacks"
[widgetType]="widgetType"
formControlName="rightButtonClick">
</tb-widget-action-settings>
</div>
<div class="tb-form-row">
<div class="fixed-title-width" tb-hint-tooltip-icon="{{'widgets.button-state.disabled-state-hint' | translate}}" translate>widgets.button-state.disabled-state</div>
<tb-get-value-action-settings class="flex-1"
panelTitle="{{ 'widgets.button-state.disabled-state' | translate }}"
[valueType]="valueType.BOOLEAN"
stateLabel="{{ 'widgets.button-state.disabled' | translate }}"
[aliasController]="aliasController"
[targetDevice]="targetDevice"
[widgetType]="widgetType"
formControlName="disabledState"></tb-get-value-action-settings>
</div>
</div>
<div class="tb-form-panel" formGroupName="appearance">
<div class="tb-form-panel-title" translate>widget-config.appearance</div>
<tb-image-cards-select rowHeight="3:1"
[cols]="{columns: 2,
breakpoints: {
'lt-sm': 1
}}"
label="{{ 'widgets.segmented-button.layout' | translate }}" formControlName="layout">
<tb-image-cards-select-option *ngFor="let layout of segmentedButtonLayouts"
[value]="layout"
[image]="segmentedButtonLayoutImageMap.get(layout)">
{{ segmentedButtonLayoutTranslationMap.get(layout) | translate }}
</tb-image-cards-select-option>
</tb-image-cards-select>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide" formControlName="autoScale">
{{ 'widgets.button.auto-scale' | translate }}
</mat-slide-toggle>
</div>
<div class="tb-form-row space-between">
<div translate>widgets.segmented-button.card-border</div>
<div class="flex flex-row items-center gap-2">
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput formControlName="cardBorder" type="number" min="0" step="1" placeholder="{{ 'widget-config.set' | translate }}">
<div matSuffix class="lt-md:!hidden">px</div>
</mat-form-field>
<tb-color-input asBoxInput
formControlName="cardBorderColor">
</tb-color-input>
</div>
</div>
</div>
<div class="tb-form-panel" formGroupName="appearance">
<div class="flex flex-row items-center justify-between">
<div class="tb-form-panel-title" translate>widgets.segmented-button.button-appearance</div>
<tb-toggle-select [(ngModel)]="segmentedButtonAppearanceType" [ngModelOptions]="{standalone: true}">
<tb-toggle-option value="first">{{ 'widgets.segmented-button.first' | translate }}</tb-toggle-option>
<tb-toggle-option value="second">{{ 'widgets.segmented-button.second' | translate }}</tb-toggle-option>
</tb-toggle-select>
</div>
<div class="tb-form-panel no-border no-padding" formGroupName="leftAppearance" [class.!hidden]="segmentedButtonAppearanceType !== 'first'">
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showLabel">
{{ 'widgets.button.label' | translate }}
</mat-slide-toggle>
<div class="flex flex-1 flex-row items-center justify-start gap-2">
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="label" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
<tb-font-settings formControlName="labelFont"
[previewText]="segmentedButtonWidgetSettingsForm.get('appearance.leftAppearance.label').value">
</tb-font-settings>
</div>
</div>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showIcon">
{{ 'widgets.button.icon' | translate }}
</mat-slide-toggle>
<div class="flex flex-1 flex-row items-center justify-start gap-2">
<mat-form-field appearance="outline" class="number flex" subscriptSizing="dynamic">
<input matInput type="number" min="0" formControlName="iconSize" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
<tb-css-unit-select class="flex-1" formControlName="iconSizeUnit"></tb-css-unit-select>
<tb-material-icon-select asBoxInput
iconClearButton
formControlName="icon">
</tb-material-icon-select>
</div>
</div>
</div>
<div class="tb-form-panel no-border no-padding" formGroupName="rightAppearance" [class.!hidden]="segmentedButtonAppearanceType !== 'second'">
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showLabel">
{{ 'widgets.button.label' | translate }}
</mat-slide-toggle>
<div class="flex flex-1 flex-row items-center justify-start gap-2">
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="label" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
<tb-font-settings formControlName="labelFont"
[previewText]="segmentedButtonWidgetSettingsForm.get('appearance.rightAppearance.label').value">
</tb-font-settings>
</div>
</div>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showIcon">
{{ 'widgets.button.icon' | translate }}
</mat-slide-toggle>
<div class="flex flex-1 flex-row items-center justify-start gap-2">
<mat-form-field appearance="outline" class="number flex" subscriptSizing="dynamic">
<input matInput type="number" min="0" formControlName="iconSize" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
<tb-css-unit-select class="flex-1" formControlName="iconSizeUnit"></tb-css-unit-select>
<tb-material-icon-select asBoxInput
iconClearButton
formControlName="icon">
</tb-material-icon-select>
</div>
</div>
</div>
</div>
<div class="tb-form-panel" formGroupName="appearance">
<div class="flex flex-row items-center justify-between">
<div class="tb-form-panel-title" translate>widgets.segmented-button.color-styles</div>
<tb-toggle-select [(ngModel)]="segmentedButtonColorStylesType" [ngModelOptions]="{standalone: true}">
<tb-toggle-option value="selected">{{ 'widgets.segmented-button.selected' | translate }}</tb-toggle-option>
<tb-toggle-option value="unselected">{{ 'widgets.segmented-button.unselected' | translate }}</tb-toggle-option>
</tb-toggle-select>
</div>
<div class="tb-form-panel no-border no-padding" formGroupName="selectedStyle" [class.!hidden]="segmentedButtonColorStylesType !== 'selected'">
<div class="tb-form-row space-between column-xs">
<div>{{ 'widgets.button.color-palette' | translate }}</div>
<div class="flex flex-row items-center justify-start gap-3">
<div class="flex flex-row items-center justify-start gap-2">
<div translate>widgets.button.main</div>
<tb-color-input asBoxInput
formControlName="mainColor">
</tb-color-input>
</div>
<mat-divider vertical></mat-divider>
<div class="flex flex-row items-center justify-start gap-2">
<div translate>widgets.button.background</div>
<tb-color-input asBoxInput
formControlName="backgroundColor">
</tb-color-input>
</div>
</div>
</div>
<div class="tb-form-panel stroked" formGroupName="customStyle">
<mat-expansion-panel class="tb-settings">
<mat-expansion-panel-header class="flex flex-row flex-wrap">
<mat-panel-title>
<div class="tb-form-panel-title" translate>widgets.button.custom-styles</div>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<div class="tb-form-row space-between" *ngFor="let state of widgetButtonToggleStates">
<div>{{ widgetButtonToggleStatesTranslationsMap.get(state) | translate }}</div>
<tb-widget-button-toggle-custom-style
[value]="true"
[state]="state"
[appearance]="segmentedButtonWidgetSettingsForm.get('appearance').value"
[autoScale]="true"
[formControlName]="state">
</tb-widget-button-toggle-custom-style>
</div>
</ng-template>
</mat-expansion-panel>
</div>
</div>
<div class="tb-form-panel no-border no-padding" formGroupName="unselectedStyle" [class.!hidden]="segmentedButtonColorStylesType !== 'unselected'">
<div class="tb-form-row space-between column-xs">
<div>{{ 'widgets.button.color-palette' | translate }}</div>
<div class="flex flex-row items-center justify-start gap-3">
<div class="flex flex-row items-center justify-start gap-2">
<div translate>widgets.button.main</div>
<tb-color-input asBoxInput
formControlName="mainColor">
</tb-color-input>
</div>
<mat-divider vertical></mat-divider>
<div class="flex flex-row items-center justify-start gap-2">
<div translate>widgets.button.background</div>
<tb-color-input asBoxInput
formControlName="backgroundColor">
</tb-color-input>
</div>
</div>
</div>
<div class="tb-form-panel stroked" formGroupName="customStyle">
<mat-expansion-panel class="tb-settings">
<mat-expansion-panel-header class="flex flex-row flex-wrap">
<mat-panel-title>
<div class="tb-form-panel-title" translate>widgets.button.custom-styles</div>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<div class="tb-form-row space-between" *ngFor="let state of widgetButtonToggleStates">
<div>{{ widgetButtonToggleStatesTranslationsMap.get(state) | translate }}</div>
<tb-widget-button-toggle-custom-style
[value]="false"
[state]="state"
[appearance]="segmentedButtonWidgetSettingsForm.get('appearance').value"
[autoScale]="true"
[formControlName]="state">
</tb-widget-button-toggle-custom-style>
</div>
</ng-template>
</mat-expansion-panel>
</div>
</div>
</div>
</ng-container>

134
ui-ngx/src/app/modules/home/components/widget/lib/settings/button/segmented-button-widget-settings.component.ts

@ -0,0 +1,134 @@
///
/// 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 { TargetDevice, WidgetSettings, WidgetSettingsComponent, widgetType } from '@shared/models/widget.models';
import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { ValueType } from '@shared/models/constants';
import { getTargetDeviceFromDatasources } from '@shared/models/widget-settings.models';
import {
SegmentedButtonAppearanceType,
SegmentedButtonColorStylesType,
segmentedButtonDefaultSettings,
segmentedButtonLayoutBorder,
segmentedButtonLayoutImages,
segmentedButtonLayouts,
segmentedButtonLayoutTranslations,
WidgetButtonToggleState,
widgetButtonToggleStatesTranslations
} from '@home/components/widget/lib/button/segmented-button-widget.models';
@Component({
selector: 'tb-segmented-button-widget-settings',
templateUrl: './segmented-button-widget-settings.component.html',
styleUrls: ['./../widget-settings.scss']
})
export class SegmentedButtonWidgetSettingsComponent extends WidgetSettingsComponent {
get targetDevice(): TargetDevice {
const datasources = this.widgetConfig?.config?.datasources;
return getTargetDeviceFromDatasources(datasources);
}
get widgetType(): widgetType {
return this.widgetConfig?.widgetType;
}
get borderRadius(): string {
return this.segmentedButtonLayoutBorderMap.get(this.segmentedButtonWidgetSettingsForm.get('appearance.layout').value);
}
segmentedButtonAppearanceType: SegmentedButtonAppearanceType = 'first';
segmentedButtonColorStylesType: SegmentedButtonColorStylesType = 'selected';
widgetButtonToggleStates = Object.keys(WidgetButtonToggleState) as WidgetButtonToggleState[];
widgetButtonToggleStatesTranslationsMap = widgetButtonToggleStatesTranslations;
segmentedButtonLayouts = segmentedButtonLayouts;
segmentedButtonLayoutTranslationMap = segmentedButtonLayoutTranslations;
segmentedButtonLayoutImageMap = segmentedButtonLayoutImages;
segmentedButtonLayoutBorderMap = segmentedButtonLayoutBorder;
valueType = ValueType;
segmentedButtonWidgetSettingsForm: UntypedFormGroup;
constructor(protected store: Store<AppState>,
private fb: UntypedFormBuilder) {
super(store);
}
protected settingsForm(): UntypedFormGroup {
return this.segmentedButtonWidgetSettingsForm;
}
protected defaultSettings(): WidgetSettings {
return {...segmentedButtonDefaultSettings};
}
protected onSettingsSet(settings: WidgetSettings) {
this.segmentedButtonWidgetSettingsForm = this.fb.group({
initialState: [settings.initialState, []],
leftButtonClick: [settings.leftButtonClick, []],
rightButtonClick: [settings.rightButtonClick, []],
disabledState: [settings.disabledState, []],
appearance: this.fb.group({
layout: [settings.appearance.layout, []],
autoScale: [settings.appearance.autoScale, []],
cardBorder: [settings.appearance.cardBorder, []],
cardBorderColor: [settings.appearance.cardBorderColor, []],
leftAppearance: this.fb.group({
showLabel: [settings.appearance.leftAppearance.showLabel, []],
label: [settings.appearance.leftAppearance.label, []],
labelFont: [settings.appearance.leftAppearance.labelFont, []],
showIcon: [settings.appearance.leftAppearance.showIcon, []],
icon: [settings.appearance.leftAppearance.icon, []],
iconSize: [settings.appearance.leftAppearance.iconSize, []],
iconSizeUnit: [settings.appearance.leftAppearance.iconSizeUnit, []],
}),
rightAppearance: this.fb.group({
showLabel: [settings.appearance.rightAppearance.showLabel, []],
label: [settings.appearance.rightAppearance.label, []],
labelFont: [settings.appearance.rightAppearance.labelFont, []],
showIcon: [settings.appearance.rightAppearance.showIcon, []],
icon: [settings.appearance.rightAppearance.icon, []],
iconSize: [settings.appearance.rightAppearance.iconSize, []],
iconSizeUnit: [settings.appearance.rightAppearance.iconSizeUnit, []],
}),
selectedStyle: this.fb.group({
mainColor: [settings.appearance.selectedStyle.mainColor, []],
backgroundColor: [settings.appearance.selectedStyle.backgroundColor, []],
customStyle: this.fb.group({
enabled: [settings.appearance.selectedStyle.customStyle.enabled, []],
hovered: [settings.appearance.selectedStyle.customStyle.hovered, []],
disabled: [settings.appearance.selectedStyle.customStyle.disabled, []],
})
}),
unselectedStyle: this.fb.group({
mainColor: [settings.appearance.unselectedStyle.mainColor, []],
backgroundColor: [settings.appearance.unselectedStyle.backgroundColor, []],
customStyle: this.fb.group({
enabled: [settings.appearance.unselectedStyle.customStyle.enabled, []],
hovered: [settings.appearance.unselectedStyle.customStyle.hovered, []],
disabled: [settings.appearance.unselectedStyle.customStyle.disabled, []],
})
}),
})
});
}
}

92
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-toggle-custom-style-panel.component.html

@ -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.
-->
<div class="tb-widget-button-custom-style-panel" [formGroup]="customStyleFormGroup">
<div class="tb-widget-button-custom-style-title">{{ widgetButtonToggleStatesTranslationsMap.get(state) | translate }}</div>
<div class="tb-widget-button-custom-style-panel-content">
<div class="tb-form-row space-between">
<mat-slide-toggle class="mat-slide" formControlName="overrideMainColor">
{{ 'widgets.button.main' | translate }}
</mat-slide-toggle>
<tb-color-input asBoxInput
formControlName="mainColor">
</tb-color-input>
</div>
<div class="tb-form-row space-between">
<mat-slide-toggle class="mat-slide" formControlName="overrideBackgroundColor">
{{ 'widgets.button.background' | translate }}
</mat-slide-toggle>
<tb-color-input asBoxInput
formControlName="backgroundColor">
</tb-color-input>
</div>
<div class="tb-form-row space-between">
<mat-slide-toggle class="mat-slide" formControlName="overrideBorderColor">
{{ 'widgets.button.border' | translate }}
</mat-slide-toggle>
<tb-color-input asBoxInput
formControlName="borderColor">
</tb-color-input>
</div>
<div class="tb-widget-button-custom-style-preview">
<div class="tb-widget-button-custom-style-preview-title" translate>
widgets.button.preview
</div>
<tb-widget-button-toggle
#widgetButtonTogglePreview
[appearance]="previewAppearance"
[autoScale]="autoScale"
[value]="value"
disableEvents
[hovered]="state === WidgetButtonToggleState.hovered"
[disabled]="state === WidgetButtonToggleState.disabled">
</tb-widget-button-toggle>
</div>
</div>
<div class="tb-widget-button-custom-style-panel-buttons">
<button *ngIf="copyFromStates?.length"
#copyStyleButton
class="tb-nowrap"
mat-stroked-button
color="primary"
type="button"
[matMenuTriggerFor]="styleSourcesMenu" [matMenuTriggerData]="{menuWidth: copyStyleButton._elementRef.nativeElement.clientWidth}">
{{ 'widgets.button.copy-style-from' | translate }}
</button>
<mat-menu #styleSourcesMenu="matMenu">
<ng-template matMenuContent let-menuWidth="menuWidth">
<div [style.min-width.px]="menuWidth">
<button mat-menu-item *ngFor="let state of copyFromStates" (click)="copyStyle(state)">{{ widgetButtonToggleStatesTranslationsMap.get(state) | translate }}</button>
</div>
</ng-template>
</mat-menu>
<span class="flex-1"></span>
<button mat-button
color="primary"
type="button"
(click)="cancel()">
{{ 'action.cancel' | translate }}
</button>
<button mat-raised-button
color="primary"
type="button"
(click)="applyCustomStyle()"
[disabled]="customStyleFormGroup.invalid || !customStyleFormGroup.dirty">
{{ 'action.apply' | translate }}
</button>
</div>
</div>

68
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-toggle-custom-style-panel.component.scss

@ -0,0 +1,68 @@
/**
* 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-widget-button-custom-style-panel {
width: 530px;
display: flex;
flex-direction: column;
gap: 16px;
@media #{$mat-lt-md} {
width: 90vw;
}
.tb-widget-button-custom-style-panel-content {
display: flex;
flex-direction: column;
gap: 16px;
overflow: auto;
}
.tb-widget-button-custom-style-title {
font-size: 16px;
font-weight: 500;
line-height: 24px;
letter-spacing: 0.25px;
color: rgba(0, 0, 0, 0.87);
}
.tb-widget-button-custom-style-preview {
flex: 1;
background: rgba(0, 0, 0, 0.04);
display: flex;
flex-direction: column;
padding: 12px 16px 24px 16px;
align-items: center;
gap: 12px;
.tb-widget-button-custom-style-preview-title {
align-self: stretch;
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: 24px;
color: rgba(0, 0, 0, 0.38);
}
tb-widget-button {
width: 200px;
height: 60px;
}
}
.tb-widget-button-custom-style-panel-buttons {
height: 40px;
display: flex;
flex-direction: row;
gap: 16px;
justify-content: flex-end;
align-items: flex-end;
}
}

210
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-toggle-custom-style-panel.component.ts

@ -0,0 +1,210 @@
///
/// 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,
EventEmitter,
Input,
OnInit,
Output,
ViewChild,
ViewEncapsulation
} from '@angular/core';
import { PageComponent } from '@shared/components/page.component';
import { TbPopoverComponent } from '@shared/components/popover.component';
import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import {
defaultBackgroundColorDisabled,
defaultMainColorDisabled
} from '@shared/components/button/widget-button.models';
import { merge } from 'rxjs';
import { deepClone } from '@core/utils';
import { WidgetButtonToggleComponent } from '@shared/components/button/widget-button-toggle.component';
import {
ButtonToggleAppearance,
SegmentedButtonStyles,
WidgetButtonToggleCustomStyle,
WidgetButtonToggleState,
widgetButtonToggleStates,
widgetButtonToggleStatesTranslations
} from '@home/components/widget/lib/button/segmented-button-widget.models';
@Component({
selector: 'tb-widget-button-toggle-custom-style-panel',
templateUrl: './widget-button-toggle-custom-style-panel.component.html',
providers: [],
styleUrls: ['./widget-button-toggle-custom-style-panel.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class WidgetButtonToggleCustomStylePanelComponent extends PageComponent implements OnInit {
@ViewChild('widgetButtonTogglePreview')
widgetButtonTogglePreview: WidgetButtonToggleComponent;
@Input()
appearance: ButtonToggleAppearance;
@Input()
value: boolean = false;
@Input()
borderRadius: string;
@Input()
autoScale: boolean;
@Input()
state: WidgetButtonToggleState;
@Input()
customStyle: WidgetButtonToggleCustomStyle;
private popoverValue: TbPopoverComponent<WidgetButtonToggleCustomStylePanelComponent>;
@Input()
set popover(popover: TbPopoverComponent<WidgetButtonToggleCustomStylePanelComponent>) {
this.popoverValue = popover;
popover.tbAnimationDone.subscribe(() => {
this.widgetButtonTogglePreview?.validateSize();
});
}
get popover(): TbPopoverComponent<WidgetButtonToggleCustomStylePanelComponent> {
return this.popoverValue;
}
@Output()
customStyleApplied = new EventEmitter<WidgetButtonToggleCustomStyle>();
widgetButtonToggleStatesTranslationsMap = widgetButtonToggleStatesTranslations;
WidgetButtonToggleState = WidgetButtonToggleState;
previewAppearance: ButtonToggleAppearance;
copyFromStates: WidgetButtonToggleState[];
customStyleFormGroup: UntypedFormGroup;
style: SegmentedButtonStyles;
constructor(private fb: UntypedFormBuilder,
protected store: Store<AppState>,
private cd: ChangeDetectorRef) {
super(store);
}
ngOnInit(): void {
this.style = this.value ? this.appearance.selectedStyle : this.appearance.unselectedStyle;
this.copyFromStates = widgetButtonToggleStates.filter(state =>
state !== this.state && !!this.style.customStyle[state]);
this.customStyleFormGroup = this.fb.group(
{
overrideMainColor: [false, []],
mainColor: [null, []],
overrideBackgroundColor: [false, []],
backgroundColor: [null, []],
overrideBorderColor: [false, []],
borderColor: [null, []]
}
);
merge(this.customStyleFormGroup.get('overrideMainColor').valueChanges,
this.customStyleFormGroup.get('overrideBackgroundColor').valueChanges,
this.customStyleFormGroup.get('overrideBorderColor').valueChanges)
.subscribe(() => {
this.updateValidators();
});
this.customStyleFormGroup.valueChanges.subscribe(() => {
this.updatePreviewAppearance();
});
this.setStyle(this.customStyle);
}
copyStyle(state: WidgetButtonToggleState) {
this.customStyle = deepClone(this.style[state]);
this.setStyle(this.customStyle);
this.customStyleFormGroup.markAsDirty();
}
cancel() {
this.popover?.hide();
}
applyCustomStyle() {
const customStyle: SegmentedButtonStyles = this.customStyleFormGroup.value;
this.customStyleApplied.emit(customStyle);
}
private setStyle(customStyle?: WidgetButtonToggleCustomStyle): void {
let mainColor = this.state === WidgetButtonToggleState.disabled ? defaultMainColorDisabled : this.style.mainColor;
if (customStyle?.overrideMainColor) {
mainColor = customStyle?.mainColor;
}
let backgroundColor = this.state === WidgetButtonToggleState.disabled ? defaultBackgroundColorDisabled : this.style.backgroundColor;
if (customStyle?.overrideBackgroundColor) {
backgroundColor = customStyle?.backgroundColor;
}
let borderColor = this.state === WidgetButtonToggleState.disabled ? defaultBackgroundColorDisabled : this.style.backgroundColor;
if (customStyle?.overrideBorderColor) {
borderColor = customStyle?.borderColor;
}
this.customStyleFormGroup.patchValue({
overrideMainColor: customStyle?.overrideMainColor,
mainColor,
overrideBackgroundColor: customStyle?.overrideBackgroundColor,
backgroundColor,
overrideBorderColor: customStyle?.overrideBorderColor,
borderColor
}, {emitEvent: false});
this.updateValidators();
this.updatePreviewAppearance();
}
private updateValidators() {
const overrideMainColor: boolean = this.customStyleFormGroup.get('overrideMainColor').value;
const overrideBackgroundColor: boolean = this.customStyleFormGroup.get('overrideBackgroundColor').value;
const overrideBorderColor: boolean = this.customStyleFormGroup.get('overrideBorderColor').value;
if (overrideMainColor) {
this.customStyleFormGroup.get('mainColor').enable({emitEvent: false});
} else {
this.customStyleFormGroup.get('mainColor').disable({emitEvent: false});
}
if (overrideBackgroundColor) {
this.customStyleFormGroup.get('backgroundColor').enable({emitEvent: false});
} else {
this.customStyleFormGroup.get('backgroundColor').disable({emitEvent: false});
}
if (overrideBorderColor) {
this.customStyleFormGroup.get('borderColor').enable({emitEvent: false});
} else {
this.customStyleFormGroup.get('borderColor').disable({emitEvent: false});
}
}
private updatePreviewAppearance() {
this.previewAppearance = deepClone(this.appearance);
if (this.value) {
this.previewAppearance.selectedStyle.customStyle[this.state] = this.customStyleFormGroup.value;
} else {
this.previewAppearance.unselectedStyle.customStyle[this.state] = this.customStyleFormGroup.value;
}
this.cd.markForCheck();
}
}

44
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-toggle-custom-style.component.html

@ -0,0 +1,44 @@
<!--
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.
-->
<div class="tb-widget-button-custom-style">
<div class="tb-widget-button-preview-panel tb-primary-fill">
<tb-widget-button-toggle
[value]="value"
[appearance]="previewAppearance"
[borderRadius]="borderRadius"
[autoScale]="autoScale"
disableEvents
[hovered]="state === widgetButtonToggleState.hovered"
[disabled]="state === widgetButtonToggleState.disabled">
</tb-widget-button-toggle>
<button *ngIf="modelValue"
mat-icon-button
[matTooltip]="'widgets.button.clear-style' | translate"
matTooltipPosition="above"
class="tb-mat-32"
(click)="clearStyle()">
<tb-icon>mdi:broom</tb-icon>
</button>
</div>
<button mat-icon-button
class="tb-mat-32"
#matIconButton
(click)="openButtonCustomStylePopup($event, matIconButton)">
<mat-icon>edit</mat-icon>
</button>
</div>

46
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-toggle-custom-style.component.scss

@ -0,0 +1,46 @@
/**
* 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-widget-button-custom-style {
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
button.mat-mdc-icon-button {
color: rgba(0,0,0,0.56);
}
.tb-widget-button-preview-panel {
width: 148px;
height: 48px;
padding: 8px 12px;
border-radius: 4px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
tb-widget-button-toggle {
width: 84px;
height: 100%;
}
@media #{$mat-gt-xs} {
width: 188px;
tb-widget-button-toggle {
width: 124px;
}
}
}
}

169
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-toggle-custom-style.component.ts

@ -0,0 +1,169 @@
///
/// 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,
forwardRef,
Input,
OnChanges,
OnInit,
Renderer2,
SimpleChanges,
ViewContainerRef,
ViewEncapsulation
} from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { TbPopoverService } from '@shared/components/popover.service';
import { MatIconButton } from '@angular/material/button';
import { deepClone } from '@core/utils';
import {
ButtonToggleAppearance,
WidgetButtonToggleCustomStyle,
WidgetButtonToggleState
} from '@home/components/widget/lib/button/segmented-button-widget.models';
import {
WidgetButtonToggleCustomStylePanelComponent
} from '@home/components/widget/lib/settings/common/button/widget-button-toggle-custom-style-panel.component';
@Component({
selector: 'tb-widget-button-toggle-custom-style',
templateUrl: './widget-button-toggle-custom-style.component.html',
styleUrls: ['./widget-button-toggle-custom-style.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => WidgetButtonToggleCustomStyleComponent),
multi: true
}
],
encapsulation: ViewEncapsulation.None
})
export class WidgetButtonToggleCustomStyleComponent implements OnInit, OnChanges, ControlValueAccessor {
@Input()
disabled = false;
@Input()
value = false;
@Input()
appearance: ButtonToggleAppearance;
@Input()
borderRadius: string;
@Input()
autoScale: boolean;
@Input()
state: WidgetButtonToggleState;
widgetButtonToggleState = WidgetButtonToggleState;
modelValue: WidgetButtonToggleCustomStyle;
previewAppearance: ButtonToggleAppearance;
private propagateChange = (_val: any) => {};
constructor(private popoverService: TbPopoverService,
private renderer: Renderer2,
private viewContainerRef: ViewContainerRef,
private cd: ChangeDetectorRef) {}
ngOnInit(): void {
this.updatePreviewAppearance();
}
ngOnChanges(changes: SimpleChanges): void {
for (const propName of Object.keys(changes)) {
const change = changes[propName];
if (!change.firstChange) {
if (propName === 'appearance') {
this.updatePreviewAppearance();
}
}
}
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
}
setDisabledState(_isDisabled: boolean): void {
}
writeValue(value: WidgetButtonToggleCustomStyle): void {
this.modelValue = value;
this.updatePreviewAppearance();
}
clearStyle() {
this.updateModel(null);
}
openButtonCustomStylePopup($event: Event, matButton: MatIconButton) {
if ($event) {
$event.stopPropagation();
}
const trigger = matButton._elementRef.nativeElement;
if (this.popoverService.hasPopover(trigger)) {
this.popoverService.hidePopover(trigger);
} else {
const ctx: any = {
appearance: this.appearance,
borderRadius: this.borderRadius,
autoScale: this.autoScale,
state: this.state,
value: this.value,
customStyle: this.modelValue
};
const widgetButtonCustomStylePanelPopover = this.popoverService.displayPopover(trigger, this.renderer,
this.viewContainerRef, WidgetButtonToggleCustomStylePanelComponent,
['leftTopOnly', 'leftOnly', 'leftBottomOnly'], true, null,
ctx,
{},
{}, {}, true);
widgetButtonCustomStylePanelPopover.tbComponentRef.instance.popover = widgetButtonCustomStylePanelPopover;
widgetButtonCustomStylePanelPopover.tbComponentRef.instance.customStyleApplied.subscribe((customStyle) => {
widgetButtonCustomStylePanelPopover.hide();
this.updateModel(customStyle);
});
}
}
private updateModel(value: WidgetButtonToggleCustomStyle): void {
this.modelValue = value;
this.updatePreviewAppearance();
this.propagateChange(this.modelValue);
}
private updatePreviewAppearance() {
this.previewAppearance = deepClone(this.appearance);
if (this.modelValue) {
if (this.value) {
this.previewAppearance.selectedStyle.customStyle[this.state] = this.modelValue;
} else {
this.previewAppearance.unselectedStyle.customStyle[this.state] = this.modelValue;
}
}
this.cd.markForCheck();
}
}

10
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts

@ -158,6 +158,12 @@ import {
import {
ScadaSymbolObjectSettingsComponent
} from '@home/components/widget/lib/settings/common/scada/scada-symbol-object-settings.component';
import {
WidgetButtonToggleCustomStyleComponent
} from '@home/components/widget/lib/settings/common/button/widget-button-toggle-custom-style.component';
import {
WidgetButtonToggleCustomStylePanelComponent
} from '@home/components/widget/lib/settings/common/button/widget-button-toggle-custom-style-panel.component';
@NgModule({
declarations: [
@ -196,7 +202,9 @@ import {
WidgetActionSettingsPanelComponent,
WidgetButtonAppearanceComponent,
WidgetButtonCustomStyleComponent,
WidgetButtonToggleCustomStyleComponent,
WidgetButtonCustomStylePanelComponent,
WidgetButtonToggleCustomStylePanelComponent,
TimeSeriesChartAxisSettingsComponent,
TimeSeriesChartThresholdsPanelComponent,
TimeSeriesChartThresholdRowComponent,
@ -261,7 +269,9 @@ import {
WidgetActionSettingsPanelComponent,
WidgetButtonAppearanceComponent,
WidgetButtonCustomStyleComponent,
WidgetButtonToggleCustomStyleComponent,
WidgetButtonCustomStylePanelComponent,
WidgetButtonToggleCustomStylePanelComponent,
TimeSeriesChartAxisSettingsComponent,
TimeSeriesChartThresholdsPanelComponent,
TimeSeriesChartThresholdRowComponent,

6
ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts

@ -368,6 +368,9 @@ import {
import {
ScadaSymbolWidgetSettingsComponent
} from '@home/components/widget/lib/settings/scada/scada-symbol-widget-settings.component';
import {
SegmentedButtonWidgetSettingsComponent
} from '@home/components/widget/lib/settings/button/segmented-button-widget-settings.component';
@NgModule({
declarations: [
@ -483,6 +486,7 @@ ScadaSymbolWidgetSettingsComponent
BarChartWithLabelsWidgetSettingsComponent,
SingleSwitchWidgetSettingsComponent,
ActionButtonWidgetSettingsComponent,
SegmentedButtonWidgetSettingsComponent,
CommandButtonWidgetSettingsComponent,
PowerButtonWidgetSettingsComponent,
SliderWidgetSettingsComponent,
@ -619,6 +623,7 @@ ScadaSymbolWidgetSettingsComponent
BarChartWithLabelsWidgetSettingsComponent,
SingleSwitchWidgetSettingsComponent,
ActionButtonWidgetSettingsComponent,
SegmentedButtonWidgetSettingsComponent,
CommandButtonWidgetSettingsComponent,
PowerButtonWidgetSettingsComponent,
SliderWidgetSettingsComponent,
@ -723,6 +728,7 @@ export const widgetSettingsComponentsMap: {[key: string]: Type<IWidgetSettingsCo
'tb-bar-chart-with-labels-widget-settings': BarChartWithLabelsWidgetSettingsComponent,
'tb-single-switch-widget-settings': SingleSwitchWidgetSettingsComponent,
'tb-action-button-widget-settings': ActionButtonWidgetSettingsComponent,
'tb-segmented-button-widget-settings': SegmentedButtonWidgetSettingsComponent,
'tb-command-button-widget-settings': CommandButtonWidgetSettingsComponent,
'tb-power-button-widget-settings': PowerButtonWidgetSettingsComponent,
'tb-slider-widget-settings': SliderWidgetSettingsComponent,

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

@ -87,6 +87,7 @@ import {
} from '@home/components/widget/lib/cards/notification-type-filter-panel.component';
import { EllipsisChipListDirective } from '@shared/directives/ellipsis-chip-list.directive';
import { ScadaSymbolWidgetComponent } from '@home/components/widget/lib/scada/scada-symbol-widget.component';
import { TwoSegmentButtonWidgetComponent } from '@home/components/widget/lib/button/two-segment-button-widget.component';
@NgModule({
declarations: [
@ -124,6 +125,7 @@ import { ScadaSymbolWidgetComponent } from '@home/components/widget/lib/scada/sc
BarChartWithLabelsWidgetComponent,
SingleSwitchWidgetComponent,
ActionButtonWidgetComponent,
TwoSegmentButtonWidgetComponent,
CommandButtonWidgetComponent,
PowerButtonWidgetComponent,
SliderWidgetComponent,
@ -185,6 +187,7 @@ import { ScadaSymbolWidgetComponent } from '@home/components/widget/lib/scada/sc
BarChartWithLabelsWidgetComponent,
SingleSwitchWidgetComponent,
ActionButtonWidgetComponent,
TwoSegmentButtonWidgetComponent,
CommandButtonWidgetComponent,
PowerButtonWidgetComponent,
SliderWidgetComponent,

42
ui-ngx/src/app/shared/components/button/widget-button-toggle.component.html

@ -0,0 +1,42 @@
<!--
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.
-->
<div #toggleGroupContainer class="tb-toggle-container">
<mat-button-toggle-group #widgetButton
class="tb-toggle-header"
[ngModel]="value"
(change)="clicked.emit($event)"
[style.border-radius]="computedBorderRadius"
[style.border-color]="disabled ? 'rgba(0,0,0,0.38)': computedBorderColor"
[style.border-width]="computedBorderWidth"
[class.tb-pointer-events]="disableEvents"
[class.tb-disabled-state]="disabled"
[class.tb-disabled]="disabled">
<mat-button-toggle [value]="true" [disabled]="disabled" [class.tb-hover-state]="hovered">
<div #leftButtonContent class="tb-widget-button-content" *ngIf="appearance.leftAppearance.showIcon || appearance.leftAppearance.showLabel">
<tb-icon matButtonIcon *ngIf="appearance.leftAppearance.showIcon" [style]="leftIconStyle">{{ appearance.leftAppearance.icon }}</tb-icon>
<span *ngIf="appearance.leftAppearance.showLabel" [style]="leftLabelStyle" class="tb-widget-button-label">{{ leftLabel$ | async }}</span>
</div>
</mat-button-toggle>
<mat-button-toggle [value]="false" [disabled]="disabled" [class.tb-hover-state]="hovered">
<div #rightButtonContent class="tb-widget-button-content" *ngIf="appearance.rightAppearance.showIcon || appearance.rightAppearance.showLabel">
<tb-icon matButtonIcon *ngIf="appearance.rightAppearance.showIcon" [style]="rightIconStyle">{{ appearance.rightAppearance.icon }}</tb-icon>
<span *ngIf="appearance.rightAppearance.showLabel" [style]="rightLabelStyle" class="tb-widget-button-label">{{ rightLabel$ | async }}</span>
</div>
</mat-button-toggle>
</mat-button-toggle-group>
</div>

189
ui-ngx/src/app/shared/components/button/widget-button-toggle.component.scss

@ -0,0 +1,189 @@
/**
* 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.
*/
$defaultBorderRadius: 4px;
$defaultCheckedMainColor: #FFFFFF;
$defaultCheckedBackgroundColor: #305680;
$defaultCheckedBorderColor: #305680;
$defaultUncheckedMainColor: rgba(0, 0, 0, 0.76);
$defaultUncheckedBackgroundColor: #E8E8E8;
$defaultUncheckedBorderColor: #E8E8E8;
$defaultCheckedMainColorDisabled: #FFFFFFCC;
$defaultCheckedBackgroundColorDisabled: rgba(0, 0, 0, 0.12);
$defaultCheckedBorderColorDisabled: rgba(0, 0, 0, 0.12);
$defaultUncheckedMainColorDisabled: rgba(0, 0, 0, 0.12);
$defaultUncheckedBackgroundColorDisabled: #E8E8E8;
$defaultUncheckedBorderColorDisabled: #E8E8E8;
$buttonBorderRadius: var(--tb-widget-button-toggle-border-radius, $defaultBorderRadius);
$mainCheckedColorEnabled: var(--tb-widget-button-toggle-main-checked-color-enabled, $defaultCheckedMainColor);
$backgroundCheckedColorEnabled: var(--tb-widget-button-toggle-background-checked-color-enabled, $defaultCheckedBackgroundColor);
$borderCheckedColorEnabled: var(--tb-widget-button-toggle-border-checked-color-enabled, $defaultCheckedBorderColor);
$mainUncheckedColorEnabled: var(--tb-widget-button-toggle-main-unchecked-color-enabled, $defaultUncheckedMainColor);
$backgroundUncheckedColorEnabled: var(--tb-widget-button-toggle-background-unchecked-color-enabled, $defaultUncheckedBackgroundColor);
$borderUncheckedColorEnabled: var(--tb-widget-button-toggle-border-unchecked-color-enabled, $defaultUncheckedBorderColor);
$mainCheckedColorDisabled: var(--tb-widget-button-toggle-main-checked-color-disabled, $defaultCheckedMainColorDisabled);
$backgroundCheckedColorDisabled: var(--tb-widget-button-toggle-background-checked-color-disabled, $defaultCheckedBackgroundColorDisabled);
$borderCheckedColorDisabled: var(--tb-widget-button-toggle-border-checked-color-disabled, $defaultCheckedBorderColorDisabled);
$mainUncheckedColorDisabled: var(--tb-widget-button-toggle-main-unchecked-color-disabled, $defaultUncheckedMainColorDisabled);
$backgroundUncheckedColorDisabled: var(--tb-widget-button-toggle-background-unchecked-color-disabled, $defaultUncheckedBackgroundColorDisabled);
$borderUncheckedColorDisabled: var(--tb-widget-button-toggle-border-unchecked-color-disabled, $defaultUncheckedBorderColorDisabled);
$mainCheckedColorHovered: var(--tb-widget-button-toggle-main-checked-color-hovered, $defaultCheckedMainColor);
$backgroundCheckedColorHovered: var(--tb-widget-button-toggle-background-checked-color-hovered, $defaultCheckedBackgroundColor);
$borderCheckedColorHovered: var(--tb-widget-button-toggle-border-checked-color-hovered, $defaultCheckedBorderColor);
$mainUncheckedColorHovered: var(--tb-widget-button-toggle-main-unchecked-color-hovered, $defaultCheckedMainColor);
$backgroundUncheckedColorHovered: var(--tb-widget-button-toggle-background-unchecked-color-hovered, $defaultCheckedBackgroundColor);
$borderUncheckedColorHovered: var(--tb-widget-button-toggle-border-unchecked-color-hovered, $defaultCheckedBorderColor);
@mixin _tb-widget-button-styles($main, $background, $boxShadow) {
color: $main;
background-color: $background;
border: 1px solid $boxShadow;
}
:host {
max-width: 100%;
.tb-toggle-container {
display: block;
height: 100%;
}
.tb-toggle-header {
transition: transform 500ms cubic-bezier(0.35, 0, 0.25, 1);
}
.tb-widget-button-content {
width: 100%;
display: flex;
flex-direction: row;
gap: 4px;
justify-content: center;
align-items: center;
.mat-icon {
margin: 0;
}
span.tb-widget-button-label {
line-height: normal;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
:host ::ng-deep {
.mat-button-toggle-group.mat-button-toggle-group-appearance-standard.tb-toggle-header {
overflow: visible;
width: 100%;
height: 100%;
padding: 3px;
border: 1px solid;
background: transparent;
.mat-button-toggle + .mat-button-toggle {
border-left: none;
}
.mat-button-toggle.mat-button-toggle-appearance-standard {
flex: 1;
width: 50%;
border-radius: $buttonBorderRadius;
&.tb-hover-state {
.mat-button-toggle-button {
@include _tb-widget-button-styles($mainUncheckedColorHovered, $backgroundUncheckedColorHovered, $borderUncheckedColorHovered);
}
&.mat-button-toggle-checked {
.mat-button-toggle-button {
@include _tb-widget-button-styles($mainCheckedColorHovered, $backgroundCheckedColorHovered, $borderCheckedColorHovered);
}
}
}
.mat-button-toggle-button {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
border-radius: $buttonBorderRadius;
@include _tb-widget-button-styles($mainUncheckedColorEnabled, $backgroundUncheckedColorEnabled, $borderUncheckedColorEnabled);
.mat-button-toggle-label-content {
.mat-pseudo-checkbox {
display: none;
}
}
}
&.mat-button-toggle-checked {
.mat-button-toggle-button {
@include _tb-widget-button-styles($mainCheckedColorEnabled, $backgroundCheckedColorEnabled, $borderCheckedColorEnabled);
}
}
}
&.tb-pointer-events {
.mat-button-toggle.mat-button-toggle-appearance-standard {
.mat-button-toggle-button {
pointer-events: none;
}
.mat-button-toggle-focus-overlay, .mat-button-toggle-ripple {
opacity: 0;
}
}
}
&:not(:disabled):not(.tb-disabled-state):not(.tb-pointer-events) {
.mat-button-toggle.mat-button-toggle-appearance-standard {
&:hover {
.mat-button-toggle-button {
@include _tb-widget-button-styles($mainUncheckedColorHovered, $backgroundUncheckedColorHovered, $borderUncheckedColorHovered);
}
&.mat-button-toggle-checked {
.mat-button-toggle-button {
@include _tb-widget-button-styles($mainCheckedColorHovered, $backgroundCheckedColorHovered, $borderCheckedColorHovered);
}
}
}
}
}
&:disabled, &.tb-disabled-state {
pointer-events: none;
.mat-button-toggle.mat-button-toggle-appearance-standard {
.mat-button-toggle-button {
@include _tb-widget-button-styles($mainUncheckedColorDisabled, $backgroundUncheckedColorDisabled, $borderUncheckedColorDisabled);
}
&.mat-button-toggle-checked {
.mat-button-toggle-button {
@include _tb-widget-button-styles($mainCheckedColorDisabled, $backgroundCheckedColorDisabled, $borderCheckedColorDisabled);
}
}
}
.mat-button-toggle-focus-overlay, .mat-button-toggle-ripple {
opacity: 0;
}
}
.mat-button-toggle-focus-overlay, .mat-button-toggle-ripple {
border-radius: inherit;
}
}
}

234
ui-ngx/src/app/shared/components/button/widget-button-toggle.component.ts

@ -0,0 +1,234 @@
///
/// 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,
EventEmitter,
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
Renderer2,
SimpleChanges,
ViewChild
} from '@angular/core';
import { coerceBoolean } from '@shared/decorators/coercion';
import { ComponentStyle, iconStyle, textStyle, validateCssSize } from '@shared/models/widget-settings.models';
import { UtilsService } from '@core/services/utils.service';
import { Observable, of } from 'rxjs';
import { WidgetContext } from '@home/models/widget-component.models';
import { isDefinedAndNotNull } from '@core/utils';
import {
generateWidgetButtonToggleAppearanceCss,
generateWidgetButtonToggleBorderLayout,
segmentedButtonDefaultAppearance,
segmentedButtonLayoutBorder
} from '@home/components/widget/lib/button/segmented-button-widget.models';
import { MatButtonToggleChange } from '@angular/material/button-toggle';
const initialButtonHeight = 60;
const horizontalLayoutPadding = 10;
@Component({
selector: 'tb-widget-button-toggle',
templateUrl: './widget-button-toggle.component.html',
styleUrls: ['./widget-button-toggle.component.scss']
})
export class WidgetButtonToggleComponent implements OnInit, AfterViewInit, OnDestroy, OnChanges {
@ViewChild('toggleGroupContainer', {static: false})
toggleGroupContainer: ElementRef<HTMLElement>;
@ViewChild('widgetButton', {read: ElementRef})
widgetButton: ElementRef<HTMLElement>;
@ViewChild('leftButtonContent', {static: false})
leftButtonContent: ElementRef<HTMLElement>;
@ViewChild('rightButtonContent', {static: false})
rightButtonContent: ElementRef<HTMLElement>;
@Input()
appearance = segmentedButtonDefaultAppearance;
@Input()
borderRadius: string;
@Input()
autoScale: boolean;
@Input()
@coerceBoolean()
value = false;
@Input()
@coerceBoolean()
disabled = false;
@Input()
@coerceBoolean()
hovered = false;
@Input()
@coerceBoolean()
disableEvents = false;
@Input()
ctx: WidgetContext;
@Output()
clicked = new EventEmitter<MatButtonToggleChange>();
leftLabel$: Observable<string>;
rightLabel$: Observable<string>;
leftIconStyle: ComponentStyle = {};
rightIconStyle: ComponentStyle = {};
leftLabelStyle: ComponentStyle = {};
rightLabelStyle: ComponentStyle = {};
computedBorderColor: string;
computedBorderWidth: string;
computedBorderRadius: string;
private buttonResize$: ResizeObserver;
private appearanceCssClass: string;
constructor(private renderer: Renderer2,
private elementRef: ElementRef,
private utils: UtilsService) {}
ngOnInit(): void {
this.updateAppearance();
}
ngOnChanges(changes: SimpleChanges): void {
for (const propName of Object.keys(changes)) {
const change = changes[propName];
if (!change.firstChange) {
if (propName === 'appearance') {
this.updateAppearance();
} else if (propName === 'borderRadius') {
this.updateBorderRadius();
} else if (propName === 'autoScale') {
this.updateAutoScale();
}
}
}
}
ngAfterViewInit(): void {
this.updateAutoScale();
}
ngOnDestroy(): void {
if (this.buttonResize$) {
this.buttonResize$.disconnect();
}
this.clearAppearanceCss();
}
public validateSize() {
if (this.appearance.autoScale && this.widgetButton.nativeElement) {
this.onResize();
}
}
private updateAppearance(): void {
this.clearAppearanceCss();
this.computedBorderColor = this.appearance.cardBorderColor;
this.computedBorderWidth = validateCssSize(this.appearance.cardBorder.toString());
if (this.appearance.leftAppearance.showIcon) {
this.leftIconStyle = iconStyle(this.appearance.leftAppearance.iconSize, this.appearance.leftAppearance.iconSizeUnit);
}
if (this.appearance.rightAppearance.showIcon) {
this.rightIconStyle = iconStyle(this.appearance.rightAppearance.iconSize, this.appearance.rightAppearance.iconSizeUnit);
}
if (this.appearance.leftAppearance.showLabel) {
this.leftLabelStyle = textStyle(this.appearance.leftAppearance.labelFont);
this.leftLabel$ = this.ctx ? this.ctx.registerLabelPattern(this.appearance.leftAppearance.label, this.leftLabel$) : of(this.appearance.leftAppearance.label);
}
if (this.appearance.rightAppearance.showLabel) {
this.rightLabelStyle = textStyle(this.appearance.rightAppearance.labelFont);
this.rightLabel$ = this.ctx ? this.ctx.registerLabelPattern(this.appearance.rightAppearance.label, this.rightLabel$) : of(this.appearance.rightAppearance.label);
}
this.updateBorderRadius();
const appearanceCss = generateWidgetButtonToggleAppearanceCss(this.appearance.selectedStyle, this.appearance.unselectedStyle);
const layoutCss = generateWidgetButtonToggleBorderLayout(this.appearance.layout);
this.appearanceCssClass = this.utils.applyCssToElement(this.renderer, this.elementRef.nativeElement,
'tb-widget-button', appearanceCss + layoutCss);
this.updateAutoScale();
}
private updateBorderRadius(): void {
if (this.borderRadius?.length) {
const validatedBorderRadius = validateCssSize(this.borderRadius);
if (validatedBorderRadius) {
this.computedBorderRadius = validatedBorderRadius;
} else {
this.computedBorderRadius = this.borderRadius;
}
} else {
this.computedBorderRadius = segmentedButtonLayoutBorder.get(this.appearance.layout);
}
}
private clearAppearanceCss(): void {
if (this.appearanceCssClass) {
this.utils.clearCssElement(this.renderer, this.appearanceCssClass, this.elementRef?.nativeElement);
this.appearanceCssClass = null;
}
}
private updateAutoScale() {
if (this.buttonResize$) {
this.buttonResize$.disconnect();
}
if (this.widgetButton && this.rightButtonContent && this.leftButtonContent) {
const autoScale = isDefinedAndNotNull(this.autoScale) ? this.autoScale : this.appearance.autoScale;
if (autoScale) {
this.buttonResize$ = new ResizeObserver(() => {
this.onResize();
});
this.buttonResize$.observe(this.widgetButton.nativeElement);
this.onResize();
} else {
this.renderer.setStyle(this.widgetButton.nativeElement, 'transform', 'none');
this.renderer.setStyle(this.widgetButton.nativeElement, 'width', '100%');
}
}
}
private onResize() {
const height = this.widgetButton.nativeElement.getBoundingClientRect().height;
const buttonScale = height / initialButtonHeight;
const buttonWidth = this.widgetButton.nativeElement.getBoundingClientRect().width;
const buttonHeight = this.widgetButton.nativeElement.getBoundingClientRect().height;
this.renderer.setStyle(this.leftButtonContent.nativeElement, 'transform', `scale(1)`);
this.renderer.setStyle(this.rightButtonContent.nativeElement, 'transform', `scale(1)`);
const contentWidth = this.leftButtonContent.nativeElement.getBoundingClientRect().width;
const contentHeight = this.leftButtonContent.nativeElement.getBoundingClientRect().height;
const maxScale = Math.max(1, buttonScale);
const scale = Math.min(Math.min((buttonWidth / 2 - horizontalLayoutPadding) / contentWidth, buttonHeight / contentHeight), maxScale);
this.renderer.setStyle(this.leftButtonContent.nativeElement, 'transform', `scale(${scale})`);
this.renderer.setStyle(this.rightButtonContent.nativeElement, 'transform', `scale(${scale})`);
}
}

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

@ -154,6 +154,7 @@ import { OtaPackageAutocompleteComponent } from '@shared/components/ota-package/
import { MAT_DATE_LOCALE } from '@angular/material/core';
import { CopyButtonComponent } from '@shared/components/button/copy-button.component';
import { TogglePasswordComponent } from '@shared/components/button/toggle-password.component';
import { WidgetButtonToggleComponent } from '@shared/components/button/widget-button-toggle.component';
import { HelpPopupComponent } from '@shared/components/help-popup.component';
import { TbPopoverComponent, TbPopoverDirective } from '@shared/components/popover.component';
import { TbStringTemplateOutletDirective } from '@shared/components/directives/sring-template-outlet.directive';
@ -395,6 +396,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
WidgetsBundleSearchComponent,
CopyButtonComponent,
TogglePasswordComponent,
WidgetButtonToggleComponent,
ProtobufContentComponent,
BranchAutocompleteComponent,
CountryAutocompleteComponent,
@ -658,6 +660,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
WidgetsBundleSearchComponent,
CopyButtonComponent,
TogglePasswordComponent,
WidgetButtonToggleComponent,
ProtobufContentComponent,
BranchAutocompleteComponent,
CountryAutocompleteComponent,

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

@ -5705,7 +5705,12 @@
"action-button": {
"behavior": "Behavior",
"on-click": "On click",
"on-click-hint": "Action triggered when the button is clicked"
"on-click-hint": "Action triggered when the button is clicked",
"first-button-click": "First button click",
"first-button-click-hint": "Action while pressing on first button.",
"second-button-click": "Second button click",
"second-button-click-hint": "Action while pressing on second button.",
"button-click-hint": "Action while pressing on widget."
},
"command-button": {
"behavior": "Behavior",
@ -5750,6 +5755,18 @@
"vertical-fill": "Vertical fill",
"button-appearance": "Button appearance"
},
"segmented-button": {
"layout": "Layout",
"layout-squared": "Squared",
"layout-rounded": "Rounded",
"card-border": "Card border",
"button-appearance": "Button appearance",
"first": "First",
"second": "Second",
"color-styles": "Color styles",
"selected": "Selected",
"unselected": "Unselected"
},
"button": {
"layout": "Layout",
"outlined": "Outlined",
@ -5763,6 +5780,7 @@
"color-palette": "Color palette",
"main": "Main",
"background": "Background",
"border": "Border",
"custom-styles": "Custom styles",
"clear-style": "Clear style",
"shadow": "Shadow",
@ -5776,11 +5794,16 @@
"activated-state-hint": "Configure condition under which the button is active.",
"disabled-state": "Disabled state",
"disabled-state-hint": "Configure condition under which the button is disabled.",
"selected-state": "Select button",
"selected-state-hint": "Configure condition under which the button is select.",
"enabled": "Enabled",
"hovered": "Hovered",
"pressed": "Pressed",
"activated": "Activated",
"disabled": "Disabled"
"disabled": "Disabled",
"initial": "First button",
"first": "First",
"second": "Second"
},
"background": {
"background": "Background",

1
ui-ngx/src/assets/widget/segmented-button/rounded-layout.svg

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.5 KiB

1
ui-ngx/src/assets/widget/segmented-button/squared-layout.svg

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.5 KiB

Loading…
Cancel
Save