Implement ability to move widget types between widget bundles. Ability to deprecate widget type.

This commit is contained in:
Igor Kulikov 2023-08-28 18:54:12 +03:00
parent e83aebead8
commit acdf5ad48c
20 changed files with 489 additions and 88 deletions

View File

@ -80,7 +80,7 @@ public class WidgetTypeController extends AutoCommitController {
"The newly created Widget Type Id will be present in the response. " +
"Specify existing Widget Type id to update the Widget Type. " +
"Referencing non-existing Widget Type Id will cause 'Not Found' error." +
"\n\nWidget Type alias is unique in the scope of Widget Bundle. " +
"\n\nWidget Type fqn is unique in the scope of System or Tenant. " +
"Special Tenant Id '13814000-1dd2-11b2-8080-808080808080' is automatically used if the create request is sent by user with 'SYS_ADMIN' authority." +
"Remove 'id', 'tenantId' rom the request body example (below) to create new Widget Type entity." +
SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH)
@ -243,8 +243,8 @@ public class WidgetTypeController extends AutoCommitController {
notes = "Set Widget Type deprecated flag. Referencing non-existing Widget Type Id will cause an error." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH)
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')")
@RequestMapping(value = "/widgetType/{widgetTypeId}/deprecate/{deprecated}", method = RequestMethod.POST)
@ResponseStatus(value = HttpStatus.OK)
public void setWidgetTypeDeprecated(
@ResponseBody
public WidgetTypeDetails setWidgetTypeDeprecated(
@ApiParam(value = WIDGET_TYPE_ID_PARAM_DESCRIPTION, required = true)
@PathVariable("widgetTypeId") String strWidgetTypeId,
@PathVariable("deprecated") boolean deprecated) throws Exception {
@ -252,14 +252,45 @@ public class WidgetTypeController extends AutoCommitController {
var currentUser = getCurrentUser();
WidgetTypeId widgetTypeId = new WidgetTypeId(toUUID(strWidgetTypeId));
WidgetTypeDetails wtd = checkWidgetTypeId(widgetTypeId, Operation.WRITE);
widgetTypeService.setWidgetTypeDeprecated(currentUser.getTenantId(), widgetTypeId, deprecated);
if (wtd != null && !Authority.SYS_ADMIN.equals(currentUser.getAuthority())) {
WidgetTypeDetails updated = widgetTypeService.setWidgetTypeDeprecated(currentUser.getTenantId(), widgetTypeId, deprecated);
if (!Authority.SYS_ADMIN.equals(currentUser.getAuthority())) {
WidgetsBundle widgetsBundle = widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(wtd.getTenantId(), wtd.getBundleAlias());
if (widgetsBundle != null) {
autoCommit(currentUser, widgetsBundle.getId());
}
}
return updated;
}
@ApiOperation(value = "Move widget type to target widgets bundle (moveWidgetType)",
notes = "Move Widget Type to target Widgets Bundle. Referencing non-existing Widget Type Id will cause an error." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH)
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')")
@RequestMapping(value = "/widgetType/{widgetTypeId}/move", params = {"targetBundleAlias"}, method = RequestMethod.POST)
@ResponseBody
public WidgetTypeDetails moveWidgetType(
@ApiParam(value = WIDGET_TYPE_ID_PARAM_DESCRIPTION, required = true)
@PathVariable("widgetTypeId") String strWidgetTypeId,
@ApiParam(value = "Target Widget Bundle alias", required = true)
@RequestParam String targetBundleAlias) throws Exception {
checkParameter("widgetTypeId", strWidgetTypeId);
checkParameter("targetBundleAlias", targetBundleAlias);
var currentUser = getCurrentUser();
WidgetTypeId widgetTypeId = new WidgetTypeId(toUUID(strWidgetTypeId));
WidgetTypeDetails wtd = checkWidgetTypeId(widgetTypeId, Operation.WRITE);
if (!wtd.getBundleAlias().equals(targetBundleAlias)) {
wtd = widgetTypeService.moveWidgetType(currentUser.getTenantId(), widgetTypeId, targetBundleAlias);
if (!Authority.SYS_ADMIN.equals(currentUser.getAuthority())) {
WidgetsBundle widgetsBundle = widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(wtd.getTenantId(), wtd.getBundleAlias());
if (widgetsBundle != null) {
autoCommit(currentUser, widgetsBundle.getId());
}
WidgetsBundle targetWidgetsBundle = widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(currentUser.getTenantId(), targetBundleAlias);
if (targetWidgetsBundle != null) {
autoCommit(currentUser, targetWidgetsBundle.getId());
}
}
}
return wtd;
}
}

View File

@ -191,10 +191,30 @@ public class WidgetTypeControllerTest extends AbstractControllerTest {
widgetType.setName("Widget Type");
widgetType.setDescriptor(JacksonUtil.fromString("{ \"someKey\": \"someValue\" }", JsonNode.class));
WidgetTypeDetails savedWidgetType = doPost("/api/widgetType", widgetType, WidgetTypeDetails.class);
WidgetsBundle widgetsBundle2 = new WidgetsBundle();
widgetsBundle2.setTitle("My widgets bundle 2");
WidgetsBundle savedWidgetsBundle2 = doPost("/api/widgetsBundle", widgetsBundle2, WidgetsBundle.class);
savedWidgetType.setBundleAlias(savedWidgetsBundle2.getAlias());
doPost("/api/widgetType", savedWidgetType);
WidgetTypeDetails foundWidgetType = doGet("/api/widgetType/" + savedWidgetType.getId().getId().toString(), WidgetTypeDetails.class);
Assert.assertEquals(savedWidgetsBundle2.getAlias(), foundWidgetType.getBundleAlias());
}
@Test
public void testUpdateWidgetTypeBundleAliasToNonExistent() throws Exception {
WidgetTypeDetails widgetType = new WidgetTypeDetails();
widgetType.setBundleAlias(savedWidgetsBundle.getAlias());
widgetType.setName("Widget Type");
widgetType.setDescriptor(JacksonUtil.fromString("{ \"someKey\": \"someValue\" }", JsonNode.class));
WidgetTypeDetails savedWidgetType = doPost("/api/widgetType", widgetType, WidgetTypeDetails.class);
savedWidgetType.setBundleAlias("some_alias");
doPost("/api/widgetType", savedWidgetType)
.andExpect(status().isBadRequest())
.andExpect(statusReason(containsString("Update of widget type bundle alias is prohibited")));
.andExpect(statusReason(containsString("Widget type is referencing to non-existent widgets bundle")));
}
@ -212,6 +232,51 @@ public class WidgetTypeControllerTest extends AbstractControllerTest {
}
@Test
public void testDeprecateWidgetType() throws Exception {
WidgetTypeDetails widgetType = new WidgetTypeDetails();
widgetType.setBundleAlias(savedWidgetsBundle.getAlias());
widgetType.setName("Widget Type");
widgetType.setDescriptor(JacksonUtil.fromString("{ \"someKey\": \"someValue\" }", JsonNode.class));
WidgetTypeDetails savedWidgetType = doPost("/api/widgetType", widgetType, WidgetTypeDetails.class);
doPost("/api/widgetType/"+savedWidgetType.getId().getId().toString() + "/deprecate/true")
.andExpect(status().isOk());
WidgetTypeDetails foundWidgetType = doGet("/api/widgetType/" + savedWidgetType.getId().getId().toString(), WidgetTypeDetails.class);
Assert.assertTrue(foundWidgetType.isDeprecated());
doPost("/api/widgetType/"+savedWidgetType.getId().getId().toString() + "/deprecate/false")
.andExpect(status().isOk());
foundWidgetType = doGet("/api/widgetType/" + savedWidgetType.getId().getId().toString(), WidgetTypeDetails.class);
Assert.assertFalse(foundWidgetType.isDeprecated());
}
@Test
public void testMoveWidgetType() throws Exception {
WidgetTypeDetails widgetType = new WidgetTypeDetails();
widgetType.setBundleAlias(savedWidgetsBundle.getAlias());
widgetType.setName("Widget Type");
widgetType.setDescriptor(JacksonUtil.fromString("{ \"someKey\": \"someValue\" }", JsonNode.class));
WidgetTypeDetails savedWidgetType = doPost("/api/widgetType", widgetType, WidgetTypeDetails.class);
WidgetsBundle widgetsBundle2 = new WidgetsBundle();
widgetsBundle2.setTitle("My widgets bundle 2");
WidgetsBundle savedWidgetsBundle2 = doPost("/api/widgetsBundle", widgetsBundle2, WidgetsBundle.class);
doPost("/api/widgetType/"+savedWidgetType.getId().getId().toString() + "/move?targetBundleAlias=" + savedWidgetsBundle2.getAlias())
.andExpect(status().isOk());
WidgetTypeDetails foundWidgetType = doGet("/api/widgetType/" + savedWidgetType.getId().getId().toString(), WidgetTypeDetails.class);
Assert.assertEquals(savedWidgetsBundle2.getAlias(), foundWidgetType.getBundleAlias());
}
@Test
public void testMoveWidgetTypeToNonExistentBundle() throws Exception {
WidgetTypeDetails widgetType = new WidgetTypeDetails();
widgetType.setBundleAlias(savedWidgetsBundle.getAlias());
widgetType.setName("Widget Type");
widgetType.setDescriptor(JacksonUtil.fromString("{ \"someKey\": \"someValue\" }", JsonNode.class));
WidgetTypeDetails savedWidgetType = doPost("/api/widgetType", widgetType, WidgetTypeDetails.class);
doPost("/api/widgetType/"+savedWidgetType.getId().getId().toString() + "/move?targetBundleAlias=some_alias")
.andExpect(status().isBadRequest())
.andExpect(statusReason(containsString("Widget type is referencing to non-existent widgets bundle")));
}
@Test
public void testGetBundleWidgetTypes() throws Exception {
List<WidgetType> widgetTypes = new ArrayList<>();

View File

@ -33,9 +33,11 @@ public interface WidgetTypeService extends EntityDaoService {
WidgetTypeDetails saveWidgetType(WidgetTypeDetails widgetType);
WidgetTypeDetails moveWidgetType(TenantId tenantId, WidgetTypeId widgetTypeId, String targetBundleAlias);
void deleteWidgetType(TenantId tenantId, WidgetTypeId widgetTypeId);
void setWidgetTypeDeprecated(TenantId tenantId, WidgetTypeId widgetTypeId, boolean deprecated);
WidgetTypeDetails setWidgetTypeDeprecated(TenantId tenantId, WidgetTypeId widgetTypeId, boolean deprecated);
List<WidgetType> findWidgetTypesByTenantIdAndBundleAlias(TenantId tenantId, String bundleAlias);

View File

@ -87,7 +87,10 @@ public class WidgetTypeDataValidator extends DataValidator<WidgetTypeDetails> {
throw new DataValidationException("Can't move existing widget type to different tenant!");
}
if (!storedWidgetType.getBundleAlias().equals(widgetTypeDetails.getBundleAlias())) {
throw new DataValidationException("Update of widget type bundle alias is prohibited!");
WidgetsBundle widgetsBundle = widgetsBundleDao.findWidgetsBundleByTenantIdAndAlias(widgetTypeDetails.getTenantId().getId(), widgetTypeDetails.getBundleAlias());
if (widgetsBundle == null) {
throw new DataValidationException("Widget type is referencing to non-existent widgets bundle!");
}
}
if (!storedWidgetType.getFqn().equals(widgetTypeDetails.getFqn())) {
throw new DataValidationException("Update of widget type fqn is prohibited!");

View File

@ -84,6 +84,21 @@ public class WidgetTypeServiceImpl extends AbstractEntityService implements Widg
}
}
@Override
public WidgetTypeDetails moveWidgetType(TenantId tenantId, WidgetTypeId widgetTypeId, String targetBundleAlias) {
log.trace("Executing moveWidgetType, widgetTypeId [{}], targetBundleAlias [{}]", widgetTypeId, targetBundleAlias);
Validator.validateId(widgetTypeId, "Incorrect widgetTypeId " + widgetTypeId);
WidgetTypeDetails widgetTypeDetails = widgetTypeDao.findById(tenantId, widgetTypeId.getId());
if (widgetTypeDetails != null && !widgetTypeDetails.getBundleAlias().equals(targetBundleAlias)) {
widgetTypeDetails.setBundleAlias(targetBundleAlias);
widgetTypeValidator.validate(widgetTypeDetails, WidgetType::getTenantId);
widgetTypeDetails = widgetTypeDao.save(widgetTypeDetails.getTenantId(), widgetTypeDetails);
eventPublisher.publishEvent(SaveEntityEvent.builder().tenantId(widgetTypeDetails.getTenantId())
.entityId(widgetTypeDetails.getId()).added(widgetTypeDetails.getId() == null).build());
}
return widgetTypeDetails;
}
@Override
public void deleteWidgetType(TenantId tenantId, WidgetTypeId widgetTypeId) {
log.trace("Executing deleteWidgetType [{}]", widgetTypeId);
@ -93,14 +108,15 @@ public class WidgetTypeServiceImpl extends AbstractEntityService implements Widg
}
@Override
public void setWidgetTypeDeprecated(TenantId tenantId, WidgetTypeId widgetTypeId, boolean deprecated) {
public WidgetTypeDetails setWidgetTypeDeprecated(TenantId tenantId, WidgetTypeId widgetTypeId, boolean deprecated) {
log.trace("Executing setWidgetTypeDeprecated, widgetTypeId [{}], deprecated [{}]", widgetTypeId, deprecated);
Validator.validateId(widgetTypeId, "Incorrect widgetTypeId " + widgetTypeId);
WidgetTypeDetails widgetTypeDetails = widgetTypeDao.findById(tenantId, widgetTypeId.getId());
if (widgetTypeDetails.isDeprecated() != deprecated) {
widgetTypeDetails.setDeprecated(deprecated);
widgetTypeDao.save(widgetTypeDetails.getTenantId(), widgetTypeDetails);
widgetTypeDetails = widgetTypeDao.save(widgetTypeDetails.getTenantId(), widgetTypeDetails);
}
return widgetTypeDetails;
}
@Override
@ -117,6 +133,7 @@ public class WidgetTypeServiceImpl extends AbstractEntityService implements Widg
Validator.validateId(tenantId, INCORRECT_TENANT_ID + tenantId);
Validator.validateString(bundleAlias, INCORRECT_BUNDLE_ALIAS + bundleAlias);
return widgetTypeDao.findWidgetTypesDetailsByTenantIdAndBundleAlias(tenantId.getId(), bundleAlias);
}
@Override

View File

@ -152,9 +152,12 @@ export class WidgetService {
return this.getBundleWidgetTypes(bundleAlias, isSystem, config).pipe(
map((types) => {
types = types.sort((a, b) => {
let result = widgetType[b.descriptor.type].localeCompare(widgetType[a.descriptor.type]);
let result = (a.deprecated ? 1 : 0) - (b.deprecated ? 1 : 0);
if (result === 0) {
result = b.createdTime - a.createdTime;
result = widgetType[b.descriptor.type].localeCompare(widgetType[a.descriptor.type]);
if (result === 0) {
result = b.createdTime - a.createdTime;
}
}
return result;
});
@ -180,6 +183,9 @@ export class WidgetService {
};
widget.config.title = widgetTypeInfo.widgetName;
if (type.deprecated) {
widget.config.title += ` (${this.translate.instant('widget.deprecated')})`;
}
widgetTypes.push(widget);
top += sizeY;
@ -216,6 +222,22 @@ export class WidgetService {
}));
}
public setWidgetTypeDeprecated(widgetTypeId: string, deprecated: boolean, config?: RequestConfig): Observable<WidgetTypeDetails> {
return this.http.post<WidgetTypeDetails>(`/api/widgetType/${widgetTypeId}/deprecate/${deprecated}`,
defaultHttpOptionsFromConfig(config)).pipe(
tap((savedWidgetType) => {
this.widgetTypeUpdated(savedWidgetType);
}));
}
public moveWidgetType(widgetTypeId: string, targetBundleAlias: string, config?: RequestConfig): Observable<WidgetTypeDetails> {
return this.http.post<WidgetTypeDetails>(`/api/widgetType/${widgetTypeId}/move?targetBundleAlias=${targetBundleAlias}`,
defaultHttpOptionsFromConfig(config)).pipe(
tap((savedWidgetType) => {
this.widgetTypeUpdated(savedWidgetType);
}));
}
public saveImportedWidgetTypeDetails(widgetTypeDetails: WidgetTypeDetails,
config?: RequestConfig): Observable<WidgetTypeDetails> {
return this.http.post<WidgetTypeDetails>('/api/widgetType', widgetTypeDetails,

View File

@ -408,7 +408,7 @@
(ngModelChange)="searchBundle = $event">
</tb-widgets-bundle-search>
</div>
<div class="details-buttons" *ngIf="isAddingWidget">
<div class="details-buttons" *ngIf="isAddingWidget" fxLayout="row" fxLayoutAlign="start center">
<button mat-button type="button" (click)="importWidget($event)"
*ngIf="!dashboardWidgetSelectComponent?.widgetsBundle">
<mat-icon>file_upload</mat-icon>{{ 'dashboard.import-widget' | translate }}</button>
@ -419,6 +419,15 @@
matTooltipPosition="above">
<mat-icon>filter_list</mat-icon>
</button>
<tb-toggle-select *ngIf="dashboardWidgetSelectComponent?.hasDeprecated"
appearance="fill-invert"
disablePagination
selectMediaBreakpoint="xs"
[(ngModel)]="dashboardWidgetSelectComponent.widgetsListMode">
<tb-toggle-option value="all">{{ 'widget.all' | translate }}</tb-toggle-option>
<tb-toggle-option value="actual">{{ 'widget.actual' | translate }}</tb-toggle-option>
<tb-toggle-option value="deprecated">{{ 'widget.deprecated' | translate }}</tb-toggle-option>
</tb-toggle-select>
</div>
<tb-dashboard-widget-select #dashboardWidgetSelect
*ngIf="isAddingWidget"

View File

@ -24,7 +24,7 @@
<img class="preview" [src]="getPreviewImage(widget.image)" alt="{{ widget.title }}">
</div>
<div fxFlex fxLayout="column">
<mat-card-title>{{widget.title}}</mat-card-title>
<mat-card-title>{{widget.title}}<div *ngIf="widget.deprecated" class="tb-deprecated" translate>widget.deprecated</div></mat-card-title>
<mat-card-subtitle>{{ 'widget.' + widget.type | translate }}</mat-card-subtitle>
<mat-card-content *ngIf="widget.description">
{{ widget.description }}

View File

@ -54,6 +54,10 @@
font-size: 20px;
line-height: normal;
margin-bottom: 8px;
.tb-deprecated {
font-size: 14px;
color: rgba(209, 39, 48, 0.87);
}
}
.mat-mdc-card-subtitle {

View File

@ -25,6 +25,8 @@ import { BehaviorSubject, combineLatest, Observable, of } from 'rxjs';
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
import { isDefinedAndNotNull } from '@core/utils';
type widgetsListMode = 'all' | 'actual' | 'deprecated';
@Component({
selector: 'tb-dashboard-widget-select',
templateUrl: './dashboard-widget-select.component.html',
@ -34,9 +36,11 @@ export class DashboardWidgetSelectComponent implements OnInit {
private search$ = new BehaviorSubject<string>('');
private filterWidgetTypes$ = new BehaviorSubject<Array<widgetType>>(null);
private widgetsListMode$ = new BehaviorSubject<widgetsListMode>('actual');
private widgetsInfo: Observable<Array<WidgetInfo>>;
private widgetsBundleValue: WidgetsBundle;
widgetTypes = new Set<widgetType>();
hasDeprecated = false;
widgets$: Observable<Array<WidgetInfo>>;
loadingWidgetsSubject: BehaviorSubject<boolean> = new BehaviorSubject(false);
@ -54,8 +58,10 @@ export class DashboardWidgetSelectComponent implements OnInit {
this.widgetsBundleValue = widgetBundle;
if (widgetBundle === null) {
this.widgetTypes.clear();
this.hasDeprecated = false;
}
this.filterWidgetTypes$.next(null);
this.widgetsListMode$.next('actual');
this.widgetsInfo = null;
}
}
@ -81,6 +87,15 @@ export class DashboardWidgetSelectComponent implements OnInit {
return this.filterWidgetTypes$.value;
}
@Input()
set widgetsListMode(mode: widgetsListMode) {
this.widgetsListMode$.next(mode);
}
get widgetsListMode(): widgetsListMode {
return this.widgetsListMode$.value;
}
@Output()
widgetSelected: EventEmitter<WidgetInfo> = new EventEmitter<WidgetInfo>();
@ -94,7 +109,7 @@ export class DashboardWidgetSelectComponent implements OnInit {
distinctUntilChanged(),
switchMap(search => this.fetchWidgetBundle(search))
);
this.widgets$ = combineLatest([this.search$.asObservable(), this.filterWidgetTypes$.asObservable()]).pipe(
this.widgets$ = combineLatest([this.search$.asObservable(), this.filterWidgetTypes$.asObservable(), this.widgetsListMode$]).pipe(
distinctUntilChanged((oldValue, newValue) => JSON.stringify(oldValue) === JSON.stringify(newValue)),
switchMap(search => this.fetchWidget(...search))
);
@ -113,6 +128,7 @@ export class DashboardWidgetSelectComponent implements OnInit {
map(widgets => {
widgets = widgets.sort((a, b) => b.createdTime - a.createdTime);
const widgetTypes = new Set<widgetType>();
const hasDeprecated = widgets.some(w => w.deprecated);
const widgetInfos = widgets.map((widgetTypeInfo) => {
widgetTypes.add(widgetTypeInfo.widgetType);
const widget: WidgetInfo = {
@ -120,13 +136,15 @@ export class DashboardWidgetSelectComponent implements OnInit {
type: widgetTypeInfo.widgetType,
title: widgetTypeInfo.name,
image: widgetTypeInfo.image,
description: widgetTypeInfo.description
description: widgetTypeInfo.description,
deprecated: widgetTypeInfo.deprecated
};
return widget;
}
);
setTimeout(() => {
this.widgetTypes = widgetTypes;
this.hasDeprecated = hasDeprecated;
this.cd.markForCheck();
});
return widgetInfos;
@ -185,8 +203,10 @@ export class DashboardWidgetSelectComponent implements OnInit {
);
}
private fetchWidget(search: string, filter: widgetType[]): Observable<Array<WidgetInfo>> {
private fetchWidget(search: string, filter: widgetType[], listMode: widgetsListMode): Observable<Array<WidgetInfo>> {
return this.getWidgets().pipe(
map(widgets => (listMode && listMode !== 'all') ?
widgets.filter((widget) => listMode === 'actual' ? !widget.deprecated : widget.deprecated) : widgets),
map(widgets => filter ? widgets.filter((widget) => filter.includes(widget.type)) : widgets),
map(widgets => search ? widgets.filter(
widget => (

View File

@ -0,0 +1,56 @@
<!--
Copyright © 2016-2023 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<form [formGroup]="moveWidgetTypeFormGroup" (ngSubmit)="move()">
<mat-toolbar color="primary">
<h2 translate>widget.move-widget-type</h2>
<span fxFlex></span>
<button mat-icon-button
(click)="cancel()"
type="button">
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
</mat-progress-bar>
<div style="height: 4px;" *ngIf="!(isLoading$ | async)"></div>
<div mat-dialog-content>
<fieldset>
<span translate>widget.move-widget-type-text</span>
<tb-widgets-bundle-select fxFlex
formControlName="widgetsBundle"
required
[excludeBundleIds]="[data.currentBundleId]"
bundlesScope="{{bundlesScope}}">
</tb-widgets-bundle-select>
</fieldset>
</div>
<div mat-dialog-actions fxLayoutAlign="end center">
<button mat-button color="primary"
type="button"
[disabled]="(isLoading$ | async)"
(click)="cancel()" cdkFocusInitial>
{{ 'action.cancel' | translate }}
</button>
<button mat-raised-button color="primary"
type="submit"
[disabled]="(isLoading$ | async) || moveWidgetTypeFormGroup.invalid
|| !moveWidgetTypeFormGroup.dirty">
{{ 'action.move' | translate }}
</button>
</div>
</form>

View File

@ -0,0 +1,82 @@
///
/// Copyright © 2016-2023 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Component, Inject, OnInit } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { DialogComponent } from '@shared/components/dialog.component';
import { Router } from '@angular/router';
import { WidgetsBundle } from '@shared/models/widgets-bundle.model';
import { getCurrentAuthUser } from '@core/auth/auth.selectors';
import { Authority } from '@shared/models/authority.enum';
export interface MoveWidgetTypeDialogResult {
bundleId: string;
bundleAlias: string;
}
export interface MoveWidgetTypeDialogData {
currentBundleId: string;
}
@Component({
selector: 'tb-move-widget-type-dialog',
templateUrl: './move-widget-type-dialog.component.html',
styleUrls: []
})
export class MoveWidgetTypeDialogComponent extends
DialogComponent<MoveWidgetTypeDialogComponent, MoveWidgetTypeDialogResult> implements OnInit {
moveWidgetTypeFormGroup: UntypedFormGroup;
bundlesScope: string;
constructor(protected store: Store<AppState>,
protected router: Router,
@Inject(MAT_DIALOG_DATA) public data: MoveWidgetTypeDialogData,
public dialogRef: MatDialogRef<MoveWidgetTypeDialogComponent, MoveWidgetTypeDialogResult>,
public fb: UntypedFormBuilder) {
super(store, router, dialogRef);
const authUser = getCurrentAuthUser(store);
if (authUser.authority === Authority.TENANT_ADMIN) {
this.bundlesScope = 'tenant';
} else {
this.bundlesScope = 'system';
}
}
ngOnInit(): void {
this.moveWidgetTypeFormGroup = this.fb.group({
widgetsBundle: [null, [Validators.required]]
});
}
cancel(): void {
this.dialogRef.close(null);
}
move(): void {
const widgetsBundle: WidgetsBundle = this.moveWidgetTypeFormGroup.get('widgetsBundle').value;
const result: MoveWidgetTypeDialogResult = {
bundleId: widgetsBundle.id.id,
bundleAlias: widgetsBundle.alias
};
this.dialogRef.close(result);
}
}

View File

@ -63,9 +63,18 @@
[tb-circular-progress]="saveWidgetAsPending"
matTooltip="{{ 'widget.saveAs' | translate }} (Shift + CTRL + S)"
matTooltipPosition="below">
<mat-icon>save</mat-icon>
<mat-icon>save_as</mat-icon>
<span translate>action.saveAs</span>
</button>
<button mat-raised-button
fxHide.lt-md [disabled]="(isLoading$ | async) || moveDisabled()"
(click)="moveWidget()"
[tb-circular-progress]="moveWidgetPending"
matTooltip="{{ 'widget.move' | translate }} (Shift + CTRL + M)"
matTooltipPosition="below">
<tb-icon matButtonIcon>mdi:content-save-move</tb-icon>
<span translate>action.move</span>
</button>
<button mat-button
fxHide.lt-lg
(click)="fullscreen = !fullscreen"
@ -106,9 +115,15 @@
<button mat-menu-item
[disabled]="(isLoading$ | async) || saveAsDisabled()"
(click)="saveWidgetAs()">
<mat-icon>save</mat-icon>
<mat-icon>save_as</mat-icon>
<span translate>action.saveAs</span>
</button>
<button mat-menu-item
[disabled]="(isLoading$ | async) || moveDisabled()"
(click)="moveWidget()">
<tb-icon matMenuItemIcon>mdi:content-save-move</tb-icon>
<span translate>action.move</span>
</button>
</mat-menu>
</mat-toolbar>
<div fxFlex style="position: relative;">
@ -253,6 +268,11 @@
rows="2" maxlength="255"></textarea>
<mat-hint align="end">{{descriptionInput.value?.length || 0}}/255</mat-hint>
</mat-form-field>
<mat-slide-toggle class="mat-block" style="padding-bottom: 16px;"
[(ngModel)]="widget.deprecated"
(ngModelChange)="isDirty = true">
{{ 'widget.deprecated' | translate }}
</mat-slide-toggle>
<mat-form-field class="mat-block">
<mat-label translate>widget.settings-form-selector</mat-label>
<input matInput

View File

@ -15,20 +15,22 @@
///
import { PageComponent } from '@shared/components/page.component';
import { Component, ElementRef, Inject, OnDestroy, OnInit, ViewChild, ViewEncapsulation } from '@angular/core';
import {
Component,
ElementRef,
EventEmitter,
Inject,
OnDestroy,
OnInit,
ViewChild,
ViewEncapsulation
} from '@angular/core';
import { WidgetsBundle } from '@shared/models/widgets-bundle.model';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { WidgetService } from '@core/http/widget.service';
import { detailsToWidgetInfo, toWidgetInfo, WidgetInfo } from '@home/models/widget-component.models';
import {
Widget,
WidgetConfig,
WidgetType,
widgetType,
WidgetTypeDetails,
widgetTypesData
} from '@shared/models/widget.models';
import { detailsToWidgetInfo, WidgetInfo } from '@home/models/widget-component.models';
import { Widget, WidgetConfig, widgetType, WidgetTypeDetails, widgetTypesData } from '@shared/models/widget.models';
import { ActivatedRoute, Router } from '@angular/router';
import { deepClone } from '@core/utils';
import { HasDirtyFlag } from '@core/guards/confirm-on-exit.guard';
@ -51,13 +53,18 @@ import {
SaveWidgetTypeAsDialogComponent,
SaveWidgetTypeAsDialogResult
} from '@home/pages/widget/save-widget-type-as-dialog.component';
import { forkJoin, from, Subscription } from 'rxjs';
import { forkJoin, Subscription } from 'rxjs';
import { ResizeObserver } from '@juggle/resize-observer';
import Timeout = NodeJS.Timeout;
import { widgetEditorCompleter } from '@home/pages/widget/widget-editor.models';
import { Observable } from 'rxjs/internal/Observable';
import { map, tap } from 'rxjs/operators';
import { beautifyCss, beautifyHtml, beautifyJs } from '@shared/models/beautify.models';
import {
MoveWidgetTypeDialogComponent,
MoveWidgetTypeDialogData,
MoveWidgetTypeDialogResult
} from '@home/pages/widget/move-widget-type-dialog.component';
import Timeout = NodeJS.Timeout;
// @dynamic
@Component({
@ -148,6 +155,7 @@ export class WidgetEditorComponent extends PageComponent implements OnInit, OnDe
iframeWidgetEditModeInited = false;
saveWidgetPending = false;
saveWidgetAsPending = false;
moveWidgetPending = false;
gotError = false;
errorMarkers: number[] = [];
@ -157,6 +165,8 @@ export class WidgetEditorComponent extends PageComponent implements OnInit, OnDe
hotKeys: Hotkey[] = [];
updateBreadcrumbs = new EventEmitter();
private rxSubscriptions = new Array<Subscription>();
constructor(protected store: Store<AppState>,
@ -249,6 +259,16 @@ export class WidgetEditorComponent extends PageComponent implements OnInit, OnDe
}, ['INPUT', 'SELECT', 'TEXTAREA'],
this.translate.instant('widget.saveAs'))
);
this.hotKeys.push(
new Hotkey('shift+ctrl+m', (event: KeyboardEvent) => {
if (!getCurrentIsLoading(this.store) && !this.moveDisabled()) {
event.preventDefault();
this.moveWidget();
}
return false;
}, ['INPUT', 'SELECT', 'TEXTAREA'],
this.translate.instant('widget.move'))
);
this.hotKeys.push(
new Hotkey('shift+ctrl+f', (event: KeyboardEvent) => {
event.preventDefault();
@ -542,17 +562,22 @@ export class WidgetEditorComponent extends PageComponent implements OnInit, OnDe
private commitSaveWidget() {
const id = (this.widgetTypeDetails && this.widgetTypeDetails.id) ? this.widgetTypeDetails.id : undefined;
const createdTime = (this.widgetTypeDetails && this.widgetTypeDetails.createdTime) ? this.widgetTypeDetails.createdTime : undefined;
this.widgetService.saveWidgetTypeDetails(this.widget, id, this.widgetsBundle.alias, createdTime).subscribe(
(widgetTypeDetails) => {
this.setWidgetTypeDetails(widgetTypeDetails);
this.widgetService.saveWidgetTypeDetails(this.widget, id, this.widgetsBundle.alias, createdTime).subscribe({
next: (widgetTypeDetails) => {
this.saveWidgetPending = false;
if (!this.widgetTypeDetails?.id) {
this.isDirty = false;
this.router.navigate(['..', widgetTypeDetails.id.id], {relativeTo: this.route});
} else {
this.setWidgetTypeDetails(widgetTypeDetails);
}
this.store.dispatch(new ActionNotificationShow(
{message: this.translate.instant('widget.widget-saved'), type: 'success', duration: 500}));
},
() => {
error: () => {
this.saveWidgetPending = false;
}
);
});
}
private commitSaveWidgetAs() {
@ -570,12 +595,18 @@ export class WidgetEditorComponent extends PageComponent implements OnInit, OnDe
this.widget.defaultConfig = JSON.stringify(config);
this.isDirty = false;
this.widgetService.saveWidgetTypeDetails(this.widget, undefined, saveWidgetAsData.bundleAlias, undefined).subscribe(
(widgetTypeDetails) => {
this.router.navigateByUrl(`/widgets-bundles/${saveWidgetAsData.bundleId}/widgetTypes/${widgetTypeDetails.id.id}`);
}
);
{
next: (widgetTypeDetails) => {
this.saveWidgetAsPending = false;
this.router.navigateByUrl(`/widgets-bundles/${saveWidgetAsData.bundleId}/widgetTypes/${widgetTypeDetails.id.id}`);
},
error: () => {
this.saveWidgetAsPending = false;
}
});
} else {
this.saveWidgetAsPending = false;
}
this.saveWidgetAsPending = false;
}
);
}
@ -587,6 +618,7 @@ export class WidgetEditorComponent extends PageComponent implements OnInit, OnDe
this.widget.defaultConfig = JSON.stringify(config);
this.origWidget = deepClone(this.widget);
this.isDirty = false;
this.updateBreadcrumbs.emit();
}
applyWidgetScript(): void {
@ -623,6 +655,34 @@ export class WidgetEditorComponent extends PageComponent implements OnInit, OnDe
this.applyWidgetScript();
}
moveWidget() {
this.moveWidgetPending = true;
this.dialog.open<MoveWidgetTypeDialogComponent, MoveWidgetTypeDialogData,
MoveWidgetTypeDialogResult>(MoveWidgetTypeDialogComponent, {
disableClose: true,
data: {
currentBundleId: this.widgetsBundle.id.id
},
panelClass: ['tb-dialog', 'tb-fullscreen-dialog']
}).afterClosed().subscribe(
(moveWidgetTypeData) => {
if (moveWidgetTypeData) {
this.widgetService.moveWidgetType(this.widgetTypeDetails.id.id, moveWidgetTypeData.bundleAlias).subscribe({
next: (widgetTypeDetails) => {
this.moveWidgetPending = false;
this.router.navigateByUrl(`/widgets-bundles/${moveWidgetTypeData.bundleId}/widgetTypes/${widgetTypeDetails.id.id}`);
},
error: () => {
this.moveWidgetPending = false;
}
});
} else {
this.moveWidgetPending = false;
}
}
);
}
undoDisabled(): boolean {
return !this.isDirty
|| !this.iframeWidgetEditModeInited
@ -644,6 +704,15 @@ export class WidgetEditorComponent extends PageComponent implements OnInit, OnDe
|| this.saveWidgetAsPending;
}
moveDisabled(): boolean {
return this.isReadOnly
|| !this.widgetTypeDetails?.id
|| !this.iframeWidgetEditModeInited
|| this.saveWidgetPending
|| this.saveWidgetAsPending
|| this.moveWidgetPending;
}
beautifyCss(): void {
beautifyCss(this.widget.templateCss, {indent_size: 4}).subscribe(
(res) => {

View File

@ -78,35 +78,28 @@ export class WidgetEditorDataResolver implements Resolve<WidgetEditorData> {
resolve(route: ActivatedRouteSnapshot): Observable<WidgetEditorData> {
const widgetTypeId = route.params.widgetTypeId;
return this.widgetsService.getWidgetTypeById(widgetTypeId).pipe(
map((result) => ({
if (!widgetTypeId || Object.keys(widgetType).includes(widgetTypeId)) {
let widgetTypeParam = widgetTypeId as widgetType;
if (!widgetTypeParam) {
widgetTypeParam = widgetType.timeseries;
}
return this.widgetsService.getWidgetTemplate(widgetTypeParam).pipe(
map((widget) => {
widget.widgetName = null;
return {
widgetTypeDetails: null,
widget
};
})
);
} else {
return this.widgetsService.getWidgetTypeById(widgetTypeId).pipe(
map((result) => ({
widgetTypeDetails: result,
widget: detailsToWidgetInfo(result)
}))
);
}
}
@Injectable()
export class WidgetEditorAddDataResolver implements Resolve<WidgetEditorData> {
constructor(private widgetsService: WidgetService) {
}
resolve(route: ActivatedRouteSnapshot): Observable<WidgetEditorData> {
let widgetTypeParam = route.params.widgetType as widgetType;
if (!widgetTypeParam) {
widgetTypeParam = widgetType.timeseries;
);
}
return this.widgetsService.getWidgetTemplate(widgetTypeParam).pipe(
map((widget) => {
widget.widgetName = null;
return {
widgetTypeDetails: null,
widget
};
})
);
}
}
@ -114,7 +107,9 @@ export const widgetTypesBreadcumbLabelFunction: BreadCrumbLabelFunction<any> = (
route.data.widgetsBundle.title);
export const widgetEditorBreadcumbLabelFunction: BreadCrumbLabelFunction<WidgetEditorComponent> =
((route, translate, component) => component ? component.widget.widgetName : '');
((route, translate, component) =>
component?.widget?.widgetName ?
(component.widget.widgetName + (component.widget.deprecated ? ` (${translate.instant('widget.deprecated')})` : '')) : '');
export const widgetsBundlesRoutes: Routes = [
{
@ -175,22 +170,6 @@ export const widgetsBundlesRoutes: Routes = [
resolve: {
widgetEditorData: WidgetEditorDataResolver
}
},
{
path: 'add/:widgetType',
component: WidgetEditorComponent,
canDeactivate: [ConfirmOnExitGuard],
data: {
auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN],
title: 'widget.editor',
breadcrumb: {
labelFunction: widgetEditorBreadcumbLabelFunction,
icon: 'insert_chart'
} as BreadCrumbConfig<WidgetEditorComponent>
},
resolve: {
widgetEditorData: WidgetEditorAddDataResolver
}
}
]
}
@ -216,7 +195,11 @@ const routes: Routes = [
},
{
path: 'widgets-bundles/:widgetsBundleId/widgetTypes/add/:widgetType',
redirectTo: '/resources/widgets-bundles/:widgetsBundleId/widgetTypes/add/:widgetType',
redirectTo: '/resources/widgets-bundles/:widgetsBundleId/widgetTypes/:widgetType',
},
{
path: 'resources/widgets-bundles/:widgetsBundleId/widgetTypes/add/:widgetType',
redirectTo: '/resources/widgets-bundles/:widgetsBundleId/widgetTypes/:widgetType',
}
];
@ -228,8 +211,7 @@ const routes: Routes = [
WidgetsBundlesTableConfigResolver,
WidgetsBundleResolver,
WidgetsTypesDataResolver,
WidgetEditorDataResolver,
WidgetEditorAddDataResolver
WidgetEditorDataResolver
]
})
export class WidgetLibraryRoutingModule { }

View File

@ -160,7 +160,7 @@ export class WidgetLibraryComponent extends PageComponent implements OnInit {
}).afterClosed().subscribe(
(type) => {
if (type) {
this.router.navigate(['add', type], {relativeTo: this.route});
this.router.navigate([type], {relativeTo: this.route});
}
}
);

View File

@ -25,6 +25,7 @@ import { WidgetEditorComponent } from '@home/pages/widget/widget-editor.componen
import { SelectWidgetTypeDialogComponent } from '@home/pages/widget/select-widget-type-dialog.component';
import { SaveWidgetTypeAsDialogComponent } from './save-widget-type-as-dialog.component';
import { WidgetsBundleTabsComponent } from '@home/pages/widget/widgets-bundle-tabs.component';
import { MoveWidgetTypeDialogComponent } from '@home/pages/widget/move-widget-type-dialog.component';
@NgModule({
declarations: [
@ -33,6 +34,7 @@ import { WidgetsBundleTabsComponent } from '@home/pages/widget/widgets-bundle-ta
WidgetEditorComponent,
SelectWidgetTypeDialogComponent,
SaveWidgetTypeAsDialogComponent,
MoveWidgetTypeDialogComponent,
WidgetsBundleTabsComponent
],
imports: [

View File

@ -61,6 +61,9 @@ export class WidgetsBundleSelectComponent implements ControlValueAccessor, OnIni
@Input()
disabled: boolean;
@Input()
excludeBundleIds: Array<string>;
widgetsBundles$: Observable<Array<WidgetsBundle>>;
widgetsBundles: Array<WidgetsBundle>;
@ -161,6 +164,12 @@ export class WidgetsBundleSelectComponent implements ControlValueAccessor, OnIni
} else {
widgetsBundlesObservable = this.widgetService.getAllWidgetsBundles();
}
if (this.excludeBundleIds && this.excludeBundleIds.length) {
widgetsBundlesObservable = widgetsBundlesObservable.pipe(
map((widgetBundles) =>
widgetBundles.filter(w => !this.excludeBundleIds.includes(w.id.id)))
);
}
return widgetsBundlesObservable;
}

View File

@ -698,6 +698,7 @@ export interface WidgetInfo {
title: string;
image?: string;
description?: string;
deprecated?: boolean;
}
export interface GroupInfo {

View File

@ -19,6 +19,7 @@
"suspend": "Suspend",
"save": "Save",
"saveAs": "Save as",
"move": "Move",
"cancel": "Cancel",
"ok": "OK",
"delete": "Delete",
@ -4548,8 +4549,11 @@
"unable-to-save-widget-error": "Unable to save widget! Widget has errors!",
"save": "Save widget",
"saveAs": "Save widget as",
"move": "Move widget",
"save-widget-type-as": "Save widget type as",
"save-widget-type-as-text": "Please enter new widget title and/or select target widgets bundle",
"move-widget-type": "Move widget type",
"move-widget-type-text": "Please select target widgets bundle",
"toggle-fullscreen": "Toggle fullscreen",
"run": "Run widget",
"title": "Widget title",
@ -4572,6 +4576,9 @@
"settings-form-selector": "Settings form selector",
"data-key-settings-form-selector": "Data key settings form selector",
"latest-data-key-settings-form-selector": "Latest data key settings form selector",
"all": "All",
"actual": "Actual",
"deprecated": "Deprecated",
"has-basic-mode": "Has basic mode",
"basic-mode-form-selector": "Basic mode form selector",
"basic-mode": "Basic",