{
+ 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");
diff --git a/common/util/src/main/java/org/thingsboard/common/util/ThingsBoardScheduledThreadPoolExecutor.java b/common/util/src/main/java/org/thingsboard/common/util/ThingsBoardScheduledThreadPoolExecutor.java
index fd3ecb085c..831982e102 100644
--- a/common/util/src/main/java/org/thingsboard/common/util/ThingsBoardScheduledThreadPoolExecutor.java
+++ b/common/util/src/main/java/org/thingsboard/common/util/ThingsBoardScheduledThreadPoolExecutor.java
@@ -1,12 +1,12 @@
/**
* 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
- *
+ *
+ * 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.
diff --git a/msa/js-executor/api/jsInvokeMessageProcessor.ts b/msa/js-executor/api/jsInvokeMessageProcessor.ts
index 98b9c0fe0c..fe18c320bc 100644
--- a/msa/js-executor/api/jsInvokeMessageProcessor.ts
+++ b/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) {
diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/SemaphoreWithTbMsgQueue.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/SemaphoreWithTbMsgQueue.java
index fa00856b4b..50a109163c 100644
--- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/SemaphoreWithTbMsgQueue.java
+++ b/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
}
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts
index a32d7c2311..800deec7e5 100644
--- a/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts
+++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts
@@ -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
+
+
+
+
+
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/button/segmented-button-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/button/segmented-button-basic-config.component.ts
new file mode 100644
index 0000000000..cf9c830fe8
--- /dev/null
+++ b/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,
+ 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;
+ }
+
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/button/segmented-button-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/button/segmented-button-widget.models.ts
new file mode 100644
index 0000000000..3c0417a406
--- /dev/null
+++ b/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.squared, 'widgets.segmented-button.layout-squared'],
+ [SegmentedButtonLayout.rounded, 'widgets.segmented-button.layout-rounded']
+ ]
+);
+
+export const segmentedButtonLayoutImages = new Map(
+ [
+ [SegmentedButtonLayout.squared, 'assets/widget/segmented-button/squared-layout.svg'],
+ [SegmentedButtonLayout.rounded, 'assets/widget/segmented-button/rounded-layout.svg']
+ ]
+);
+
+export const segmentedButtonLayoutBorder = new Map(
+ [
+ [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;
+ disabledState: GetValueSettings;
+ 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.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;
+
+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.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`+
+ `}`;
+};
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/button/two-segment-button-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/button/two-segment-button-widget.component.html
new file mode 100644
index 0000000000..10cf016e11
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/button/two-segment-button-widget.component.html
@@ -0,0 +1,33 @@
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/button/two-segment-button-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/button/two-segment-button-widget.component.scss
new file mode 100644
index 0000000000..66aec79aa4
--- /dev/null
+++ b/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;
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/button/two-segment-button-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/button/two-segment-button-widget.component.ts
new file mode 100644
index 0000000000..042f1518b6
--- /dev/null
+++ b/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);
+ }
+ }
+ }
+
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/segmented-button-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/segmented-button-widget-settings.component.html
new file mode 100644
index 0000000000..e259919cc6
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/segmented-button-widget-settings.component.html
@@ -0,0 +1,263 @@
+
+
+
+
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/segmented-button-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/segmented-button-widget-settings.component.ts
new file mode 100644
index 0000000000..7dab81ccad
--- /dev/null
+++ b/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,
+ 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, []],
+ })
+ }),
+ })
+ });
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-toggle-custom-style-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-toggle-custom-style-panel.component.html
new file mode 100644
index 0000000000..f0e50b03e6
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-toggle-custom-style-panel.component.html
@@ -0,0 +1,92 @@
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-toggle-custom-style-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-toggle-custom-style-panel.component.scss
new file mode 100644
index 0000000000..dfbbb91620
--- /dev/null
+++ b/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;
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-toggle-custom-style-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-toggle-custom-style-panel.component.ts
new file mode 100644
index 0000000000..98ced2dc2e
--- /dev/null
+++ b/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;
+
+ @Input()
+ set popover(popover: TbPopoverComponent) {
+ this.popoverValue = popover;
+ popover.tbAnimationDone.subscribe(() => {
+ this.widgetButtonTogglePreview?.validateSize();
+ });
+ }
+
+ get popover(): TbPopoverComponent {
+ return this.popoverValue;
+ }
+
+ @Output()
+ customStyleApplied = new EventEmitter();
+
+ widgetButtonToggleStatesTranslationsMap = widgetButtonToggleStatesTranslations;
+
+ WidgetButtonToggleState = WidgetButtonToggleState;
+
+ previewAppearance: ButtonToggleAppearance;
+
+ copyFromStates: WidgetButtonToggleState[];
+
+ customStyleFormGroup: UntypedFormGroup;
+
+ style: SegmentedButtonStyles;
+
+ constructor(private fb: UntypedFormBuilder,
+ protected store: Store,
+ 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();
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-toggle-custom-style.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-toggle-custom-style.component.html
new file mode 100644
index 0000000000..624d17af16
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-toggle-custom-style.component.html
@@ -0,0 +1,44 @@
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-toggle-custom-style.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-toggle-custom-style.component.scss
new file mode 100644
index 0000000000..6badf1f804
--- /dev/null
+++ b/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;
+ }
+ }
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-toggle-custom-style.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-toggle-custom-style.component.ts
new file mode 100644
index 0000000000..366c78a0c6
--- /dev/null
+++ b/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();
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts
index fbfffc973f..037096f406 100644
--- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts
@@ -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,
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts
index 37f61b11fb..4b5f3a1ad0 100644
--- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts
+++ b/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
+
+
+
diff --git a/ui-ngx/src/app/shared/components/button/widget-button-toggle.component.scss b/ui-ngx/src/app/shared/components/button/widget-button-toggle.component.scss
new file mode 100644
index 0000000000..4ce8c69ddb
--- /dev/null
+++ b/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;
+ }
+ }
+}
diff --git a/ui-ngx/src/app/shared/components/button/widget-button-toggle.component.ts b/ui-ngx/src/app/shared/components/button/widget-button-toggle.component.ts
new file mode 100644
index 0000000000..4f281b2aa0
--- /dev/null
+++ b/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;
+
+ @ViewChild('widgetButton', {read: ElementRef})
+ widgetButton: ElementRef;
+
+ @ViewChild('leftButtonContent', {static: false})
+ leftButtonContent: ElementRef;
+ @ViewChild('rightButtonContent', {static: false})
+ rightButtonContent: ElementRef;
+
+ @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();
+
+ leftLabel$: Observable;
+ rightLabel$: Observable;
+
+ 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})`);
+ }
+}
diff --git a/ui-ngx/src/app/shared/shared.module.ts b/ui-ngx/src/app/shared/shared.module.ts
index 560c0151c0..c1f243ae14 100644
--- a/ui-ngx/src/app/shared/shared.module.ts
+++ b/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,
diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json
index aa21a9c0c3..3ab8283064 100644
--- a/ui-ngx/src/assets/locale/locale.constant-en_US.json
+++ b/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",
diff --git a/ui-ngx/src/assets/widget/segmented-button/rounded-layout.svg b/ui-ngx/src/assets/widget/segmented-button/rounded-layout.svg
new file mode 100644
index 0000000000..6f0963016c
--- /dev/null
+++ b/ui-ngx/src/assets/widget/segmented-button/rounded-layout.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui-ngx/src/assets/widget/segmented-button/squared-layout.svg b/ui-ngx/src/assets/widget/segmented-button/squared-layout.svg
new file mode 100644
index 0000000000..6fb5abd30b
--- /dev/null
+++ b/ui-ngx/src/assets/widget/segmented-button/squared-layout.svg
@@ -0,0 +1 @@
+
\ No newline at end of file