UI: Implement entitiy versions table

This commit is contained in:
Igor Kulikov 2022-05-25 12:26:23 +03:00
parent a5ff23a0a4
commit ca3c95afca
38 changed files with 948 additions and 87 deletions

View File

@ -294,11 +294,14 @@ public class EntitiesVersionControlController extends BaseController {
String defaultBranch = versionControlService.getVersionControlSettings(tenantId).getDefaultBranch(); String defaultBranch = versionControlService.getVersionControlSettings(tenantId).getDefaultBranch();
if (StringUtils.isNotEmpty(defaultBranch)) { if (StringUtils.isNotEmpty(defaultBranch)) {
remoteBranches.remove(defaultBranch);
infos.add(new BranchInfo(defaultBranch, true)); infos.add(new BranchInfo(defaultBranch, true));
} }
remoteBranches.forEach(branch -> infos.add(new BranchInfo(branch, false))); remoteBranches.forEach(branch -> {
if (!branch.equals(defaultBranch)) {
infos.add(new BranchInfo(branch, false));
}
});
return infos; return infos;
}, MoreExecutors.directExecutor())); }, MoreExecutors.directExecutor()));
} catch (Exception e) { } catch (Exception e) {

View File

@ -411,7 +411,7 @@ public class DefaultGitVersionControlQueueService implements GitVersionControlQu
} }
if (vcSettings != null) { if (vcSettings != null) {
builder.setVcSettings(ByteString.copyFrom(encodingService.encode(vcSettings))); builder.setVcSettings(ByteString.copyFrom(encodingService.encode(vcSettings)));
} else { } else if (request.requiresSettings()) {
throw new RuntimeException("No entity version control settings provisioned!"); throw new RuntimeException("No entity version control settings provisioned!");
} }
return builder; return builder;

View File

@ -170,7 +170,7 @@ public class GitRepository {
public PageData<Commit> listCommits(String branch, String path, PageLink pageLink) throws IOException, GitAPIException { public PageData<Commit> listCommits(String branch, String path, PageLink pageLink) throws IOException, GitAPIException {
ObjectId branchId = resolve("origin/" + branch); ObjectId branchId = resolve("origin/" + branch);
if (branchId == null) { if (branchId == null) {
throw new IllegalArgumentException("Branch not found"); return new PageData<>();
} }
LogCommand command = git.log() LogCommand command = git.log()
.add(branchId) .add(branchId)
@ -313,6 +313,7 @@ public class GitRepository {
Function<? super T, ? extends R> mapper, Function<? super T, ? extends R> mapper,
PageLink pageLink, PageLink pageLink,
Function<PageLink, Comparator<T>> comparatorFunction) { Function<PageLink, Comparator<T>> comparatorFunction) {
iterable = Streams.stream(iterable).collect(Collectors.toList());
int totalElements = Iterables.size(iterable); int totalElements = Iterables.size(iterable);
int totalPages = pageLink.getPageSize() > 0 ? (int) Math.ceil((float) totalElements / pageLink.getPageSize()) : 1; int totalPages = pageLink.getPageSize() > 0 ? (int) Math.ceil((float) totalElements / pageLink.getPageSize()) : 1;
int startIndex = pageLink.getPageSize() * pageLink.getPage(); int startIndex = pageLink.getPageSize() * pageLink.getPage();

View File

@ -23,7 +23,8 @@ export enum AuthActionTypes {
UNAUTHENTICATED = '[Auth] Unauthenticated', UNAUTHENTICATED = '[Auth] Unauthenticated',
LOAD_USER = '[Auth] Load User', LOAD_USER = '[Auth] Load User',
UPDATE_USER_DETAILS = '[Auth] Update User Details', UPDATE_USER_DETAILS = '[Auth] Update User Details',
UPDATE_LAST_PUBLIC_DASHBOARD_ID = '[Auth] Update Last Public Dashboard Id' UPDATE_LAST_PUBLIC_DASHBOARD_ID = '[Auth] Update Last Public Dashboard Id',
UPDATE_HAS_VERSION_CONTROL = '[Auth] Change Has Version Control'
} }
export class ActionAuthAuthenticated implements Action { export class ActionAuthAuthenticated implements Action {
@ -54,5 +55,11 @@ export class ActionAuthUpdateLastPublicDashboardId implements Action {
constructor(readonly payload: { lastPublicDashboardId: string }) {} constructor(readonly payload: { lastPublicDashboardId: string }) {}
} }
export class ActionAuthUpdateHasVersionControl implements Action {
readonly type = AuthActionTypes.UPDATE_HAS_VERSION_CONTROL;
constructor(readonly payload: { hasVersionControl: boolean }) {}
}
export type AuthActions = ActionAuthAuthenticated | ActionAuthUnauthenticated | export type AuthActions = ActionAuthAuthenticated | ActionAuthUnauthenticated |
ActionAuthLoadUser | ActionAuthUpdateUserDetails | ActionAuthUpdateLastPublicDashboardId; ActionAuthLoadUser | ActionAuthUpdateUserDetails | ActionAuthUpdateLastPublicDashboardId | ActionAuthUpdateHasVersionControl;

View File

@ -20,6 +20,7 @@ export interface SysParamsState {
userTokenAccessEnabled: boolean; userTokenAccessEnabled: boolean;
allowedDashboardIds: string[]; allowedDashboardIds: string[];
edgesSupportEnabled: boolean; edgesSupportEnabled: boolean;
hasVersionControl: boolean;
} }
export interface AuthPayload extends SysParamsState { export interface AuthPayload extends SysParamsState {

View File

@ -23,7 +23,8 @@ const emptyUserAuthState: AuthPayload = {
userTokenAccessEnabled: false, userTokenAccessEnabled: false,
forceFullscreen: false, forceFullscreen: false,
allowedDashboardIds: [], allowedDashboardIds: [],
edgesSupportEnabled: false edgesSupportEnabled: false,
hasVersionControl: false
}; };
export const initialState: AuthState = { export const initialState: AuthState = {
@ -54,6 +55,9 @@ export function authReducer(
case AuthActionTypes.UPDATE_LAST_PUBLIC_DASHBOARD_ID: case AuthActionTypes.UPDATE_LAST_PUBLIC_DASHBOARD_ID:
return { ...state, ...action.payload}; return { ...state, ...action.payload};
case AuthActionTypes.UPDATE_HAS_VERSION_CONTROL:
return { ...state, ...action.payload};
default: default:
return state; return state;
} }

View File

@ -55,6 +55,11 @@ export const selectUserTokenAccessEnabled = createSelector(
(state: AuthState) => state.userTokenAccessEnabled (state: AuthState) => state.userTokenAccessEnabled
); );
export const selectHasVersionControl = createSelector(
selectAuthState,
(state: AuthState) => state.hasVersionControl
);
export function getCurrentAuthState(store: Store<AppState>): AuthState { export function getCurrentAuthState(store: Store<AppState>): AuthState {
let state: AuthState; let state: AuthState;
store.pipe(select(selectAuth), take(1)).subscribe( store.pipe(select(selectAuth), take(1)).subscribe(

View File

@ -437,17 +437,27 @@ export class AuthService {
return this.http.get<boolean>('/api/edges/enabled', defaultHttpOptions()); return this.http.get<boolean>('/api/edges/enabled', defaultHttpOptions());
} }
private loadHasVersionControl(authUser: AuthUser): Observable<boolean> {
if (authUser.authority === Authority.TENANT_ADMIN) {
return this.http.get<boolean>('/api/admin/vcSettings/exists', defaultHttpOptions());
} else {
return of(false);
}
}
private loadSystemParams(authPayload: AuthPayload): Observable<SysParamsState> { private loadSystemParams(authPayload: AuthPayload): Observable<SysParamsState> {
const sources = [this.loadIsUserTokenAccessEnabled(authPayload.authUser), const sources = [this.loadIsUserTokenAccessEnabled(authPayload.authUser),
this.fetchAllowedDashboardIds(authPayload), this.fetchAllowedDashboardIds(authPayload),
this.loadIsEdgesSupportEnabled(), this.loadIsEdgesSupportEnabled(),
this.loadHasVersionControl(authPayload.authUser),
this.timeService.loadMaxDatapointsLimit()]; this.timeService.loadMaxDatapointsLimit()];
return forkJoin(sources) return forkJoin(sources)
.pipe(map((data) => { .pipe(map((data) => {
const userTokenAccessEnabled: boolean = data[0] as boolean; const userTokenAccessEnabled: boolean = data[0] as boolean;
const allowedDashboardIds: string[] = data[1] as string[]; const allowedDashboardIds: string[] = data[1] as string[];
const edgesSupportEnabled: boolean = data[2] as boolean; const edgesSupportEnabled: boolean = data[2] as boolean;
return {userTokenAccessEnabled, allowedDashboardIds, edgesSupportEnabled}; const hasVersionControl: boolean = data[3] as boolean;
return {userTokenAccessEnabled, allowedDashboardIds, edgesSupportEnabled, hasVersionControl};
}, catchError((err) => { }, catchError((err) => {
return of({}); return of({});
}))); })));

View File

@ -18,7 +18,12 @@ import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { defaultHttpOptionsFromConfig, RequestConfig } from '@core/http/http-utils'; import { defaultHttpOptionsFromConfig, RequestConfig } from '@core/http/http-utils';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { BranchInfo, VersionCreateRequest, VersionCreationResult } from '@shared/models/vc.models'; import { BranchInfo, EntityVersion, VersionCreateRequest, VersionCreationResult } from '@shared/models/vc.models';
import { PageLink } from '@shared/models/page/page-link';
import { PageData } from '@shared/models/page/page-data';
import { DeviceInfo } from '@shared/models/device.models';
import { EntityId } from '@shared/models/id/entity-id';
import { EntityType } from '@shared/models/entity-type.models';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -37,4 +42,24 @@ export class EntitiesVersionControlService {
public saveEntitiesVersion(request: VersionCreateRequest, config?: RequestConfig): Observable<VersionCreationResult> { public saveEntitiesVersion(request: VersionCreateRequest, config?: RequestConfig): Observable<VersionCreationResult> {
return this.http.post<VersionCreationResult>('/api/entities/vc/version', request, defaultHttpOptionsFromConfig(config)); return this.http.post<VersionCreationResult>('/api/entities/vc/version', request, defaultHttpOptionsFromConfig(config));
} }
public listEntityVersions(pageLink: PageLink, branch: string,
externalEntityId: EntityId,
config?: RequestConfig): Observable<PageData<EntityVersion>> {
return this.http.get<PageData<EntityVersion>>(`/api/entities/vc/version/${branch}/${externalEntityId.entityType}/${externalEntityId.id}${pageLink.toQuery()}`,
defaultHttpOptionsFromConfig(config));
}
public listEntityTypeVersions(pageLink: PageLink, branch: string,
entityType: EntityType,
config?: RequestConfig): Observable<PageData<EntityVersion>> {
return this.http.get<PageData<EntityVersion>>(`/api/entities/vc/version/${branch}/${entityType}${pageLink.toQuery()}`,
defaultHttpOptionsFromConfig(config));
}
public listVersions(pageLink: PageLink, branch: string,
config?: RequestConfig): Observable<PageData<EntityVersion>> {
return this.http.get<PageData<EntityVersion>>(`/api/entities/vc/version/${branch}${pageLink.toQuery()}`,
defaultHttpOptionsFromConfig(config));
}
} }

View File

@ -343,6 +343,13 @@ export class MenuService {
path: '/dashboards', path: '/dashboards',
icon: 'dashboards' icon: 'dashboards'
}, },
{
id: guid(),
name: 'version-control.version-control',
type: 'link',
path: '/vc',
icon: 'history'
},
{ {
id: guid(), id: guid(),
name: 'audit-log.audit-logs', name: 'audit-log.audit-logs',
@ -492,6 +499,16 @@ export class MenuService {
} }
] ]
}, },
{
name: 'version-control.management',
places: [
{
name: 'version-control.version-control',
icon: 'history',
path: '/vc'
}
]
},
{ {
name: 'audit-log.audit', name: 'audit-log.audit',
places: [ places: [

View File

@ -154,6 +154,9 @@ import { QueueFormComponent } from '@home/components/queue/queue-form.component'
import { WidgetSettingsModule } from '@home/components/widget/lib/settings/widget-settings.module'; import { WidgetSettingsModule } from '@home/components/widget/lib/settings/widget-settings.module';
import { WidgetSettingsComponent } from '@home/components/widget/widget-settings.component'; import { WidgetSettingsComponent } from '@home/components/widget/widget-settings.component';
import { VcEntityExportDialogComponent } from '@home/components/vc/vc-entity-export-dialog.component'; import { VcEntityExportDialogComponent } from '@home/components/vc/vc-entity-export-dialog.component';
import { VersionControlSettingsComponent } from '@home/components/vc/version-control-settings.component';
import { VersionControlComponent } from '@home/components/vc/version-control.component';
import { EntityVersionsTableComponent } from '@home/components/vc/entity-versions-table.component';
@NgModule({ @NgModule({
declarations: declarations:
@ -278,7 +281,10 @@ import { VcEntityExportDialogComponent } from '@home/components/vc/vc-entity-exp
DisplayWidgetTypesPanelComponent, DisplayWidgetTypesPanelComponent,
TenantProfileQueuesComponent, TenantProfileQueuesComponent,
QueueFormComponent, QueueFormComponent,
VcEntityExportDialogComponent VcEntityExportDialogComponent,
VersionControlSettingsComponent,
VersionControlComponent,
EntityVersionsTableComponent
], ],
imports: [ imports: [
CommonModule, CommonModule,
@ -397,7 +403,10 @@ import { VcEntityExportDialogComponent } from '@home/components/vc/vc-entity-exp
DisplayWidgetTypesPanelComponent, DisplayWidgetTypesPanelComponent,
TenantProfileQueuesComponent, TenantProfileQueuesComponent,
QueueFormComponent, QueueFormComponent,
VcEntityExportDialogComponent VcEntityExportDialogComponent,
VersionControlSettingsComponent,
VersionControlComponent,
EntityVersionsTableComponent
], ],
providers: [ providers: [
WidgetComponentService, WidgetComponentService,

View File

@ -0,0 +1,81 @@
<!--
Copyright © 2016-2022 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<div class="mat-padding tb-entity-table tb-absolute-fill">
<div fxFlex fxLayout="column" class="mat-elevation-z1 tb-entity-table-content">
<mat-toolbar class="mat-table-toolbar">
<div class="mat-toolbar-tools">
<div fxLayout="row" fxLayoutAlign="start center" fxLayout.xs="column" fxLayoutAlign.xs="center start" class="title-container">
<span class="tb-entity-table-title">{{(singleEntityMode ? 'version-control.entity-versions' : 'version-control.versions') | translate}}</span>
<tb-branch-autocomplete
#branchAutocompleteComponent
[selectionMode]="true"
[selectDefaultBranch]="false"
[disabled]="isLoading$ | async"
[ngModel]="branch"
(ngModelChange)="branchChanged($event)">
</tb-branch-autocomplete>
</div>
<span fxFlex></span>
<button *ngIf="singleEntityMode" mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="vcExport($event)">
{{'version-control.export-to-git' | translate }}
</button>
</div>
</mat-toolbar>
<div fxFlex class="table-container">
<table mat-table [dataSource]="dataSource"
matSort [matSortActive]="pageLink.sortOrder.property" [matSortDirection]="pageLink.sortDirection()" matSortDisableClear>
<ng-container matColumnDef="timestamp">
<mat-header-cell *matHeaderCellDef mat-sort-header style="min-width: 150px; max-width: 150px; width: 150px;"> {{ 'version-control.created-time' | translate }} </mat-header-cell>
<mat-cell *matCellDef="let entityVersion">
{{ entityVersion.timestamp | date:'yyyy-MM-dd HH:mm:ss' }}
</mat-cell>
</ng-container>
<ng-container matColumnDef="id">
<mat-header-cell *matHeaderCellDef style="width: 40%"> {{ 'version-control.version-id' | translate }} </mat-header-cell>
<mat-cell *matCellDef="let entityVersion">
{{ entityVersion.id }}
</mat-cell>
</ng-container>
<ng-container matColumnDef="name">
<mat-header-cell *matHeaderCellDef style="width: 60%"> {{ 'version-control.version-name' | translate }} </mat-header-cell>
<mat-cell *matCellDef="let entityVersion">
{{ entityVersion.name }}
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="displayedColumns; sticky: true"></mat-header-row>
<mat-row *matRowDef="let entityVersion; columns: displayedColumns;"></mat-row>
</table>
<span [fxShow]="dataSource.isEmpty() | async"
fxLayoutAlign="center center"
class="no-data-found" translate>{{
singleEntityMode
? 'version-control.no-entity-versions-text'
: 'version-control.no-versions-text'
}}</span>
</div>
<mat-divider></mat-divider>
<mat-paginator [length]="dataSource.total() | async"
[pageIndex]="pageLink.page"
[pageSize]="pageLink.pageSize"
[pageSizeOptions]="[10, 20, 30]"
[hidePageSize]="hidePageSize"
showFirstLastButtons></mat-paginator>
</div>
</div>

View File

@ -0,0 +1,100 @@
/**
* Copyright © 2016-2022 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';
:host {
width: 100%;
height: 100%;
display: block;
.tb-entity-table {
.tb-entity-table-content {
width: 100%;
height: 100%;
background: #fff;
.mat-toolbar-tools{
min-height: auto;
}
.title-container{
overflow: hidden;
}
.tb-entity-table-title {
padding-right: 20px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.table-container {
overflow: auto;
}
.tb-entity-table-info{
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.button-widget-action{
margin-left: auto;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
@media #{$mat-xs} {
.mat-toolbar {
height: auto;
min-height: 100px;
.tb-entity-table-title{
padding-bottom: 5px;
width: 100%;
}
}
}
}
:host ::ng-deep {
.mat-sort-header-sorted .mat-sort-header-arrow {
opacity: 1 !important;
}
tb-branch-autocomplete {
mat-form-field {
font-size: 16px;
width: 200px;
.mat-form-field-wrapper {
padding-bottom: 0;
}
.mat-form-field-underline {
bottom: 0;
}
@media #{$mat-xs} {
width: 100%;
.mat-form-field-infix {
width: auto !important;
}
}
}
}
}

View File

@ -0,0 +1,244 @@
///
/// Copyright © 2016-2022 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import {
AfterViewInit,
ChangeDetectorRef,
Component,
ElementRef,
Input,
OnDestroy,
OnInit,
ViewChild
} from '@angular/core';
import { PageComponent } from '@shared/components/page.component';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { EntityId } from '@shared/models/id/entity-id';
import { CollectionViewer, DataSource } from '@angular/cdk/collections';
import { BehaviorSubject, merge, Observable, of, ReplaySubject } from 'rxjs';
import { emptyPageData, PageData } from '@shared/models/page/page-data';
import { PageLink } from '@shared/models/page/page-link';
import { catchError, map, tap } from 'rxjs/operators';
import { EntityVersion } from '@shared/models/vc.models';
import { EntitiesVersionControlService } from '@core/http/entities-version-control.service';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort';
import { ResizeObserver } from '@juggle/resize-observer';
import { hidePageSizePixelValue } from '@shared/models/constants';
import { Direction, SortOrder } from '@shared/models/page/sort-order';
import { BranchAutocompleteComponent } from '@shared/components/vc/branch-autocomplete.component';
@Component({
selector: 'tb-entity-versions-table',
templateUrl: './entity-versions-table.component.html',
styleUrls: ['./entity-versions-table.component.scss']
})
export class EntityVersionsTableComponent extends PageComponent implements OnInit, AfterViewInit, OnDestroy {
@ViewChild('branchAutocompleteComponent') branchAutocompleteComponent: BranchAutocompleteComponent;
@Input()
singleEntityMode = false;
displayedColumns = ['timestamp', 'id', 'name'];
pageLink: PageLink;
dataSource: EntityVersionsDatasource;
hidePageSize = false;
branch: string = null;
activeValue = false;
dirtyValue = false;
externalEntityIdValue: EntityId;
viewsInited = false;
private componentResize$: ResizeObserver;
@Input()
set active(active: boolean) {
if (this.activeValue !== active) {
this.activeValue = active;
if (this.activeValue && this.dirtyValue) {
this.dirtyValue = false;
if (this.viewsInited) {
this.initFromDefaultBranch();
}
}
}
}
@Input()
set externalEntityId(externalEntityId: EntityId) {
if (this.externalEntityIdValue !== externalEntityId) {
this.externalEntityIdValue = externalEntityId;
this.resetSortAndFilter(this.activeValue);
if (!this.activeValue) {
this.dirtyValue = true;
}
}
}
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
constructor(protected store: Store<AppState>,
private entitiesVersionControlService: EntitiesVersionControlService,
private cd: ChangeDetectorRef,
private elementRef: ElementRef) {
super(store);
this.dirtyValue = !this.activeValue;
const sortOrder: SortOrder = { property: 'timestamp', direction: Direction.DESC };
this.pageLink = new PageLink(10, 0, null, sortOrder);
this.dataSource = new EntityVersionsDatasource(this.entitiesVersionControlService);
}
ngOnInit() {
this.componentResize$ = new ResizeObserver(() => {
const showHidePageSize = this.elementRef.nativeElement.offsetWidth < hidePageSizePixelValue;
if (showHidePageSize !== this.hidePageSize) {
this.hidePageSize = showHidePageSize;
this.cd.markForCheck();
}
});
this.componentResize$.observe(this.elementRef.nativeElement);
}
ngOnDestroy() {
if (this.componentResize$) {
this.componentResize$.disconnect();
}
}
branchChanged(newBranch: string) {
this.branch = newBranch;
this.paginator.pageIndex = 0;
if (this.activeValue) {
this.updateData();
}
}
ngAfterViewInit() {
this.sort.sortChange.subscribe(() => this.paginator.pageIndex = 0);
merge(this.sort.sortChange, this.paginator.page)
.pipe(
tap(() => this.updateData())
)
.subscribe();
this.viewsInited = true;
if (!this.singleEntityMode) {
this.initFromDefaultBranch();
}
}
vcExport($event: Event) {
if ($event) {
$event.stopPropagation();
}
}
private initFromDefaultBranch() {
this.branchAutocompleteComponent.selectDefaultBranchIfNeeded(false, true);
}
private updateData() {
this.pageLink.page = this.paginator.pageIndex;
this.pageLink.pageSize = this.paginator.pageSize;
this.pageLink.sortOrder.property = this.sort.active;
this.pageLink.sortOrder.direction = Direction[this.sort.direction.toUpperCase()];
this.dataSource.loadEntityVersions(this.singleEntityMode, this.branch, this.externalEntityIdValue, this.pageLink);
}
private resetSortAndFilter(update: boolean) {
this.branch = null;
this.pageLink.textSearch = null;
if (this.viewsInited) {
this.paginator.pageIndex = 0;
const sortable = this.sort.sortables.get('timestamp');
this.sort.active = sortable.id;
this.sort.direction = 'desc';
if (update) {
this.initFromDefaultBranch();
}
}
}
}
class EntityVersionsDatasource implements DataSource<EntityVersion> {
private entityVersionsSubject = new BehaviorSubject<EntityVersion[]>([]);
private pageDataSubject = new BehaviorSubject<PageData<EntityVersion>>(emptyPageData<EntityVersion>());
public pageData$ = this.pageDataSubject.asObservable();
constructor(private entitiesVersionControlService: EntitiesVersionControlService) {}
connect(collectionViewer: CollectionViewer): Observable<EntityVersion[] | ReadonlyArray<EntityVersion>> {
return this.entityVersionsSubject.asObservable();
}
disconnect(collectionViewer: CollectionViewer): void {
this.entityVersionsSubject.complete();
this.pageDataSubject.complete();
}
loadEntityVersions(singleEntityMode: boolean,
branch: string, externalEntityId: EntityId,
pageLink: PageLink): Observable<PageData<EntityVersion>> {
const result = new ReplaySubject<PageData<EntityVersion>>();
this.fetchEntityVersions(singleEntityMode, branch, externalEntityId, pageLink).pipe(
catchError(() => of(emptyPageData<EntityVersion>())),
).subscribe(
(pageData) => {
this.entityVersionsSubject.next(pageData.data);
this.pageDataSubject.next(pageData);
result.next(pageData);
}
);
return result;
}
fetchEntityVersions(singleEntityMode: boolean,
branch: string, externalEntityId: EntityId,
pageLink: PageLink): Observable<PageData<EntityVersion>> {
if (!branch) {
return of(emptyPageData<EntityVersion>());
} else {
if (singleEntityMode) {
if (externalEntityId) {
return this.entitiesVersionControlService.listEntityVersions(pageLink, branch, externalEntityId, {ignoreErrors: true});
} else {
return of(emptyPageData<EntityVersion>());
}
} else {
return this.entitiesVersionControlService.listVersions(pageLink, branch, {ignoreErrors: true});
}
}
}
isEmpty(): Observable<boolean> {
return this.entityVersionsSubject.pipe(
map((entityVersions) => !entityVersions.length)
);
}
total(): Observable<number> {
return this.pageDataSubject.pipe(
map((pageData) => pageData.totalElements)
);
}
}

View File

@ -16,7 +16,7 @@
--> -->
<div> <div>
<mat-card class="settings-card"> <mat-card class="vc-settings" [ngClass]="{'settings-card': !detailsMode}">
<mat-card-title> <mat-card-title>
<div fxLayout="row"> <div fxLayout="row">
<span class="mat-headline" translate>admin.git-repository-settings</span> <span class="mat-headline" translate>admin.git-repository-settings</span>

View File

@ -14,6 +14,9 @@
* limitations under the License. * limitations under the License.
*/ */
:host { :host {
mat-card.vc-settings {
margin: 8px;
}
.fields-group { .fields-group {
padding: 0 16px 8px; padding: 0 16px 8px;
margin-bottom: 10px; margin-bottom: 10px;

View File

@ -14,11 +14,11 @@
/// limitations under the License. /// limitations under the License.
/// ///
import { Component, OnInit } from '@angular/core'; import { Component, Input, OnInit } from '@angular/core';
import { PageComponent } from '@shared/components/page.component'; import { PageComponent } from '@shared/components/page.component';
import { HasConfirmForm } from '@core/guards/confirm-on-exit.guard'; import { HasConfirmForm } from '@core/guards/confirm-on-exit.guard';
import { FormBuilder, FormGroup, FormGroupDirective, Validators } from '@angular/forms'; import { FormBuilder, FormGroup, FormGroupDirective, Validators } from '@angular/forms';
import { Store } from '@ngrx/store'; import { select, Store } from '@ngrx/store';
import { AppState } from '@core/core.state'; import { AppState } from '@core/core.state';
import { AdminService } from '@core/http/admin.service'; import { AdminService } from '@core/http/admin.service';
import { import {
@ -30,13 +30,21 @@ import { ActionNotificationShow } from '@core/notification/notification.actions'
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { isNotEmptyStr } from '@core/utils'; import { isNotEmptyStr } from '@core/utils';
import { DialogService } from '@core/services/dialog.service'; import { DialogService } from '@core/services/dialog.service';
import { ActionSettingsChangeLanguage } from '@core/settings/settings.actions';
import { ActionAuthUpdateHasVersionControl } from '@core/auth/auth.actions';
import { selectHasVersionControl } from '@core/auth/auth.selectors';
import { catchError, mergeMap } from 'rxjs/operators';
import { of } from 'rxjs';
@Component({ @Component({
selector: 'tb-version-control-settings', selector: 'tb-version-control-settings',
templateUrl: './version-control-settings.component.html', templateUrl: './version-control-settings.component.html',
styleUrls: ['./version-control-settings.component.scss', './settings-card.scss'] styleUrls: ['./version-control-settings.component.scss', './../../pages/admin/settings-card.scss']
}) })
export class VersionControlSettingsComponent extends PageComponent implements OnInit, HasConfirmForm { export class VersionControlSettingsComponent extends PageComponent implements OnInit {
@Input()
detailsMode = false;
versionControlSettingsForm: FormGroup; versionControlSettingsForm: FormGroup;
settings: EntitiesVersionControlSettings = null; settings: EntitiesVersionControlSettings = null;
@ -62,7 +70,7 @@ export class VersionControlSettingsComponent extends PageComponent implements On
ngOnInit() { ngOnInit() {
this.versionControlSettingsForm = this.fb.group({ this.versionControlSettingsForm = this.fb.group({
repositoryUri: [null, [Validators.required]], repositoryUri: [null, [Validators.required]],
defaultBranch: [null, []], defaultBranch: ['main', []],
authMethod: [VersionControlAuthMethod.USERNAME_PASSWORD, [Validators.required]], authMethod: [VersionControlAuthMethod.USERNAME_PASSWORD, [Validators.required]],
username: [null, []], username: [null, []],
password: [null, []], password: [null, []],
@ -77,16 +85,29 @@ export class VersionControlSettingsComponent extends PageComponent implements On
this.versionControlSettingsForm.get('privateKeyFileName').valueChanges.subscribe(() => { this.versionControlSettingsForm.get('privateKeyFileName').valueChanges.subscribe(() => {
this.updateValidators(false); this.updateValidators(false);
}); });
this.adminService.getEntitiesVersionControlSettings({ignoreErrors: true}).subscribe( this.store.pipe(
select(selectHasVersionControl),
mergeMap((hasVersionControl) => {
if (hasVersionControl) {
return this.adminService.getEntitiesVersionControlSettings({ignoreErrors: true}).pipe(
catchError(() => of(null))
);
} else {
return of(null);
}
})
).subscribe(
(settings) => { (settings) => {
this.settings = settings; this.settings = settings;
if (this.settings.authMethod === VersionControlAuthMethod.USERNAME_PASSWORD) { if (this.settings != null) {
this.showChangePassword = true; if (this.settings.authMethod === VersionControlAuthMethod.USERNAME_PASSWORD) {
} else { this.showChangePassword = true;
this.showChangePrivateKeyPassword = true; } else {
this.showChangePrivateKeyPassword = true;
}
this.versionControlSettingsForm.reset(this.settings);
this.updateValidators(false);
} }
this.versionControlSettingsForm.reset(this.settings);
this.updateValidators(false);
}); });
} }
@ -112,6 +133,7 @@ export class VersionControlSettingsComponent extends PageComponent implements On
} }
this.versionControlSettingsForm.reset(this.settings); this.versionControlSettingsForm.reset(this.settings);
this.updateValidators(false); this.updateValidators(false);
this.store.dispatch(new ActionAuthUpdateHasVersionControl({ hasVersionControl: true }));
} }
); );
} }
@ -131,18 +153,15 @@ export class VersionControlSettingsComponent extends PageComponent implements On
this.showChangePrivateKeyPassword = false; this.showChangePrivateKeyPassword = false;
this.changePrivateKeyPassword = false; this.changePrivateKeyPassword = false;
formDirective.resetForm(); formDirective.resetForm();
this.versionControlSettingsForm.reset({ authMethod: VersionControlAuthMethod.USERNAME_PASSWORD }); this.versionControlSettingsForm.reset({ defaultBranch: 'main', authMethod: VersionControlAuthMethod.USERNAME_PASSWORD });
this.updateValidators(false); this.updateValidators(false);
this.store.dispatch(new ActionAuthUpdateHasVersionControl({ hasVersionControl: false }));
} }
); );
} }
}); });
} }
confirmForm(): FormGroup {
return this.versionControlSettingsForm;
}
changePasswordChanged() { changePasswordChanged() {
if (this.changePassword) { if (this.changePassword) {
this.versionControlSettingsForm.get('password').patchValue(''); this.versionControlSettingsForm.get('password').patchValue('');

View File

@ -0,0 +1,25 @@
<!--
Copyright © 2016-2022 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-version-control-settings #versionControlSettingsComponent [detailsMode]="detailsMode"
*ngIf="!(hasVersionControl$ | async); else versionsTable">
</tb-version-control-settings>
<ng-template #versionsTable>
<tb-entity-versions-table [singleEntityMode]="singleEntityMode"
[active]="active"
[externalEntityId]="externalEntityId"></tb-entity-versions-table>
</ng-template>

View File

@ -0,0 +1,18 @@
/**
* Copyright © 2016-2022 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
:host {
}

View File

@ -0,0 +1,61 @@
///
/// Copyright © 2016-2022 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, Input, OnInit, ViewChild } from '@angular/core';
import { select, Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { selectHasVersionControl } from '@core/auth/auth.selectors';
import { HasConfirmForm } from '@core/guards/confirm-on-exit.guard';
import { VersionControlSettingsComponent } from '@home/components/vc/version-control-settings.component';
import { FormGroup } from '@angular/forms';
import { EntityId } from '@shared/models/id/entity-id';
@Component({
selector: 'tb-version-control',
templateUrl: './version-control.component.html',
styleUrls: ['./version-control.component.scss']
})
export class VersionControlComponent implements OnInit, HasConfirmForm {
@ViewChild('versionControlSettingsComponent', {static: false}) versionControlSettingsComponent: VersionControlSettingsComponent;
@Input()
detailsMode = false;
@Input()
active = true;
@Input()
singleEntityMode = false;
@Input()
externalEntityId: EntityId;
hasVersionControl$ = this.store.pipe(select(selectHasVersionControl));
constructor(private store: Store<AppState>) {
}
ngOnInit() {
}
confirmForm(): FormGroup {
return this.versionControlSettingsComponent?.versionControlSettingsForm;
}
}

View File

@ -33,7 +33,7 @@ import { EntityDetailsPageComponent } from '@home/components/entity/entity-detai
import { entityDetailsPageBreadcrumbLabelFunction } from '@home/pages/home-pages.models'; import { entityDetailsPageBreadcrumbLabelFunction } from '@home/pages/home-pages.models';
import { BreadCrumbConfig } from '@shared/components/breadcrumb'; import { BreadCrumbConfig } from '@shared/components/breadcrumb';
import { QueuesTableConfigResolver } from '@home/pages/admin/queue/queues-table-config.resolver'; import { QueuesTableConfigResolver } from '@home/pages/admin/queue/queues-table-config.resolver';
import { VersionControlSettingsComponent } from '@home/pages/admin/version-control-settings.component'; import { VersionControlAdminSettingsComponent } from '@home/pages/admin/version-control-admin-settings.component';
@Injectable() @Injectable()
export class OAuth2LoginProcessingUrlResolver implements Resolve<string> { export class OAuth2LoginProcessingUrlResolver implements Resolve<string> {
@ -226,7 +226,7 @@ const routes: Routes = [
}, },
{ {
path: 'vc', path: 'vc',
component: VersionControlSettingsComponent, component: VersionControlAdminSettingsComponent,
canDeactivate: [ConfirmOnExitGuard], canDeactivate: [ConfirmOnExitGuard],
data: { data: {
auth: [Authority.TENANT_ADMIN], auth: [Authority.TENANT_ADMIN],

View File

@ -29,7 +29,7 @@ import { SendTestSmsDialogComponent } from '@home/pages/admin/send-test-sms-dial
import { HomeSettingsComponent } from '@home/pages/admin/home-settings.component'; import { HomeSettingsComponent } from '@home/pages/admin/home-settings.component';
import { ResourcesLibraryComponent } from '@home/pages/admin/resource/resources-library.component'; import { ResourcesLibraryComponent } from '@home/pages/admin/resource/resources-library.component';
import { QueueComponent} from '@home/pages/admin/queue/queue.component'; import { QueueComponent} from '@home/pages/admin/queue/queue.component';
import { VersionControlSettingsComponent } from '@home/pages/admin/version-control-settings.component'; import { VersionControlAdminSettingsComponent } from '@home/pages/admin/version-control-admin-settings.component';
@NgModule({ @NgModule({
declarations: declarations:
@ -43,7 +43,7 @@ import { VersionControlSettingsComponent } from '@home/pages/admin/version-contr
HomeSettingsComponent, HomeSettingsComponent,
ResourcesLibraryComponent, ResourcesLibraryComponent,
QueueComponent, QueueComponent,
VersionControlSettingsComponent VersionControlAdminSettingsComponent
], ],
imports: [ imports: [
CommonModule, CommonModule,

View File

@ -0,0 +1,18 @@
<!--
Copyright © 2016-2022 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-version-control-settings #versionControlSettingsComponent></tb-version-control-settings>

View File

@ -0,0 +1,44 @@
///
/// Copyright © 2016-2022 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, OnInit, ViewChild } from '@angular/core';
import { PageComponent } from '@shared/components/page.component';
import { HasConfirmForm } from '@core/guards/confirm-on-exit.guard';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { FormGroup } from '@angular/forms';
import { VersionControlSettingsComponent } from '@home/components/vc/version-control-settings.component';
@Component({
selector: 'tb-version-control-admin-settings',
templateUrl: './version-control-admin-settings.component.html',
styleUrls: []
})
export class VersionControlAdminSettingsComponent extends PageComponent implements OnInit, HasConfirmForm {
@ViewChild('versionControlSettingsComponent') versionControlSettingsComponent: VersionControlSettingsComponent;
constructor(protected store: Store<AppState>) {
super(store);
}
ngOnInit() {
}
confirmForm(): FormGroup {
return this.versionControlSettingsComponent?.versionControlSettingsForm;
}
}

View File

@ -49,3 +49,8 @@
label="{{ 'audit-log.audit-logs' | translate }}" #auditLogsTab="matTab"> label="{{ 'audit-log.audit-logs' | translate }}" #auditLogsTab="matTab">
<tb-audit-log-table detailsMode="true" [active]="auditLogsTab.isActive" [auditLogMode]="auditLogModes.ENTITY" [entityId]="entity.id"></tb-audit-log-table> <tb-audit-log-table detailsMode="true" [active]="auditLogsTab.isActive" [auditLogMode]="auditLogModes.ENTITY" [entityId]="entity.id"></tb-audit-log-table>
</mat-tab> </mat-tab>
<mat-tab *ngIf="entity && authUser.authority === authorities.TENANT_ADMIN"
label="{{ 'version-control.version-control' | translate }}" #versionControlTab="matTab">
<tb-version-control detailsMode="true" singleEntityMode="true"
[active]="versionControlTab.isActive" [externalEntityId]="entity.externalId || entity.id"></tb-version-control>
</mat-tab>

View File

@ -36,6 +36,7 @@ import { DeviceProfileModule } from './device-profile/device-profile.module';
import { ApiUsageModule } from '@home/pages/api-usage/api-usage.module'; import { ApiUsageModule } from '@home/pages/api-usage/api-usage.module';
import { EdgeModule } from '@home/pages/edge/edge.module'; import { EdgeModule } from '@home/pages/edge/edge.module';
import { OtaUpdateModule } from '@home/pages/ota-update/ota-update.module'; import { OtaUpdateModule } from '@home/pages/ota-update/ota-update.module';
import { VcModule } from '@home/pages/vc/vc.module';
@NgModule({ @NgModule({
exports: [ exports: [
@ -56,7 +57,8 @@ import { OtaUpdateModule } from '@home/pages/ota-update/ota-update.module';
AuditLogModule, AuditLogModule,
ApiUsageModule, ApiUsageModule,
OtaUpdateModule, OtaUpdateModule,
UserModule UserModule,
VcModule
], ],
providers: [ providers: [
{ {

View File

@ -0,0 +1,44 @@
///
/// Copyright © 2016-2022 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard';
import { Authority } from '@shared/models/authority.enum';
import { VersionControlComponent } from '@home/components/vc/version-control.component';
const routes: Routes = [
{
path: 'vc',
component: VersionControlComponent,
canDeactivate: [ConfirmOnExitGuard],
data: {
auth: [Authority.TENANT_ADMIN],
title: 'version-control.version-control',
breadcrumb: {
label: 'version-control.version-control',
icon: 'history'
}
}
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
providers: []
})
export class VcRoutingModule { }

View File

@ -0,0 +1,31 @@
///
/// Copyright © 2016-2022 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SharedModule } from '@shared/shared.module';
import { VcRoutingModule } from '@home/pages/vc/vc-routing.module';
@NgModule({
declarations: [
],
imports: [
CommonModule,
SharedModule,
VcRoutingModule
]
})
export class VcModule { }

View File

@ -15,7 +15,7 @@
limitations under the License. limitations under the License.
--> -->
<mat-form-field [formGroup]="branchFormGroup" class="mat-block"> <mat-form-field [formGroup]="branchFormGroup" class="mat-block" [floatLabel]="selectionMode ? 'always' : 'auto'">
<mat-label>{{ 'version-control.branch' | translate }}</mat-label> <mat-label>{{ 'version-control.branch' | translate }}</mat-label>
<input matInput type="text" placeholder="{{ 'version-control.select-branch' | translate }}" <input matInput type="text" placeholder="{{ 'version-control.select-branch' | translate }}"
#branchInput #branchInput
@ -31,10 +31,11 @@
</button> </button>
<mat-autocomplete <mat-autocomplete
class="tb-autocomplete" class="tb-autocomplete"
(closed)="onPanelClosed()"
#subTypeAutocomplete="matAutocomplete" #subTypeAutocomplete="matAutocomplete"
[displayWith]="displayBranchFn"> [displayWith]="displayBranchFn">
<mat-option *ngFor="let branch of filteredBranches | async" [value]="branch"> <mat-option *ngFor="let branch of filteredBranches | async" [value]="branch">
<span [innerHTML]="branch | highlight:searchText"></span> <span [innerHTML]="branch.name | highlight:searchText"></span>
</mat-option> </mat-option>
</mat-autocomplete> </mat-autocomplete>
<mat-error *ngIf="branchFormGroup.get('branch').hasError('required')"> <mat-error *ngIf="branchFormGroup.get('branch').hasError('required')">

View File

@ -14,7 +14,17 @@
/// limitations under the License. /// limitations under the License.
/// ///
import { AfterViewInit, Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core'; import {
AfterViewInit,
ChangeDetectorRef,
Component,
ElementRef,
forwardRef,
Input,
NgZone,
OnInit,
ViewChild
} from '@angular/core';
import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR } from '@angular/forms'; import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR } from '@angular/forms';
import { Observable, of } from 'rxjs'; import { Observable, of } from 'rxjs';
import { import {
@ -24,6 +34,7 @@ import {
map, map,
publishReplay, publishReplay,
refCount, refCount,
share,
switchMap, switchMap,
tap tap
} from 'rxjs/operators'; } from 'rxjs/operators';
@ -32,6 +43,7 @@ import { AppState } from '@app/core/core.state';
import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { BranchInfo } from '@shared/models/vc.models'; import { BranchInfo } from '@shared/models/vc.models';
import { EntitiesVersionControlService } from '@core/http/entities-version-control.service'; import { EntitiesVersionControlService } from '@core/http/entities-version-control.service';
import { isNotEmptyStr } from '@core/utils';
@Component({ @Component({
selector: 'tb-branch-autocomplete', selector: 'tb-branch-autocomplete',
@ -60,27 +72,48 @@ export class BranchAutocompleteComponent implements ControlValueAccessor, OnInit
this.requiredValue = coerceBooleanProperty(value); this.requiredValue = coerceBooleanProperty(value);
} }
private disabledValue: boolean;
get disabled(): boolean {
return this.disabledValue;
}
@Input() @Input()
disabled: boolean; set disabled(value: boolean) {
this.disabledValue = coerceBooleanProperty(value);
if (this.disabledValue) {
this.branchFormGroup.disable({emitEvent: false});
} else {
this.branchFormGroup.enable({emitEvent: false});
}
}
@Input() @Input()
selectDefaultBranch = true; selectDefaultBranch = true;
@Input()
selectionMode = false;
@ViewChild('branchInput', {static: true}) branchInput: ElementRef; @ViewChild('branchInput', {static: true}) branchInput: ElementRef;
filteredBranches: Observable<Array<string>>; filteredBranches: Observable<Array<BranchInfo>>;
branches: Observable<Array<BranchInfo>>; branches: Observable<Array<BranchInfo>> = null;
defaultBranch: BranchInfo = null;
searchText = ''; searchText = '';
private dirty = false; private dirty = false;
private ignoreClosedPanel = false;
private propagateChange = (v: any) => { }; private propagateChange = (v: any) => { };
constructor(private store: Store<AppState>, constructor(private store: Store<AppState>,
private entitiesVersionControlService: EntitiesVersionControlService, private entitiesVersionControlService: EntitiesVersionControlService,
private fb: FormBuilder) { private fb: FormBuilder,
private zone: NgZone) {
this.branchFormGroup = this.fb.group({ this.branchFormGroup = this.fb.group({
branch: [null, []] branch: [null, []]
}); });
@ -94,17 +127,36 @@ export class BranchAutocompleteComponent implements ControlValueAccessor, OnInit
} }
ngOnInit() { ngOnInit() {
this.branches = null;
this.filteredBranches = this.branchFormGroup.get('branch').valueChanges this.filteredBranches = this.branchFormGroup.get('branch').valueChanges
.pipe( .pipe(
tap((value: BranchInfo | string) => {
let modelValue: BranchInfo | null;
if (typeof value === 'string' || !value) {
if (!this.selectionMode && typeof value === 'string' && isNotEmptyStr(value)) {
modelValue = {name: value, default: false};
} else {
modelValue = null;
}
} else {
modelValue = value;
}
this.updateView(modelValue);
}),
map(value => {
if (value) {
if (typeof value === 'string') {
return value;
} else {
return value.name;
}
} else {
return '';
}
}),
debounceTime(150), debounceTime(150),
distinctUntilChanged(), distinctUntilChanged(),
tap(value => { switchMap(name => this.fetchBranches(name)),
this.updateView(value); share()
}),
map(value => value ? value : ''),
switchMap(branch => this.fetchBranches(branch))
); );
} }
@ -113,24 +165,16 @@ export class BranchAutocompleteComponent implements ControlValueAccessor, OnInit
setDisabledState(isDisabled: boolean): void { setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled; this.disabled = isDisabled;
if (this.disabled) {
this.branchFormGroup.disable({emitEvent: false});
} else {
this.branchFormGroup.enable({emitEvent: false});
}
} }
selectDefaultBranchIfNeeded(): void { selectDefaultBranchIfNeeded(ignoreLoading = true, force = false): void {
if (this.selectDefaultBranch && !this.modelValue) { if ((this.selectDefaultBranch && !this.modelValue) || force) {
this.getBranches().subscribe( this.getBranches(ignoreLoading).subscribe(
(data) => { (data) => {
if (data && data.length) { if (this.defaultBranch || force) {
const defaultBranch = data.find(branch => branch.default); this.branchFormGroup.get('branch').patchValue(this.defaultBranch, {emitEvent: false});
if (defaultBranch) { this.modelValue = this.defaultBranch?.name;
this.modelValue = defaultBranch.name; this.propagateChange(this.modelValue);
this.branchFormGroup.get('branch').patchValue(this.modelValue, {emitEvent: false});
this.propagateChange(this.modelValue);
}
} }
} }
); );
@ -141,9 +185,9 @@ export class BranchAutocompleteComponent implements ControlValueAccessor, OnInit
this.searchText = ''; this.searchText = '';
this.modelValue = value; this.modelValue = value;
if (value != null) { if (value != null) {
this.branchFormGroup.get('branch').patchValue(value, {emitEvent: false}); this.branchFormGroup.get('branch').patchValue({name: value}, {emitEvent: false});
} else { } else {
this.branchFormGroup.get('branch').patchValue('', {emitEvent: false}); this.branchFormGroup.get('branch').patchValue(null, {emitEvent: false});
this.selectDefaultBranchIfNeeded(); this.selectDefaultBranchIfNeeded();
} }
this.dirty = true; this.dirty = true;
@ -156,31 +200,53 @@ export class BranchAutocompleteComponent implements ControlValueAccessor, OnInit
} }
} }
updateView(value: string | null) { onPanelClosed() {
if (this.modelValue !== value) { if (this.ignoreClosedPanel) {
this.modelValue = value; this.ignoreClosedPanel = false;
} else {
if (this.selectionMode && !this.branchFormGroup.get('branch').value && this.defaultBranch) {
this.zone.run(() => {
this.branchFormGroup.get('branch').patchValue(this.defaultBranch, {emitEvent: true});
}, 0);
}
}
}
updateView(value: BranchInfo | null) {
if (this.modelValue !== value?.name) {
this.modelValue = value?.name;
this.propagateChange(this.modelValue); this.propagateChange(this.modelValue);
} }
} }
displayBranchFn(branch?: string): string | undefined { displayBranchFn(branch?: BranchInfo): string | undefined {
return branch ? branch : undefined; return branch ? branch.name : undefined;
} }
fetchBranches(searchText?: string): Observable<Array<string>> { fetchBranches(searchText?: string): Observable<Array<BranchInfo>> {
this.searchText = searchText; this.searchText = searchText;
return this.getBranches().pipe( return this.getBranches().pipe(
map(branches => branches.map(branch => branch.name).filter(branchName => { map(branches => {
return searchText ? branchName.toUpperCase().startsWith(searchText.toUpperCase()) : true; let res = branches.filter(branch => {
})) return searchText ? branch.name.toUpperCase().startsWith(searchText.toUpperCase()) : true;
});
if (!this.selectionMode && isNotEmptyStr(searchText) && !res.find(b => b.name === searchText)) {
res = [{name: searchText, default: false}, ...res];
}
return res;
}
)
); );
} }
getBranches(): Observable<Array<BranchInfo>> { getBranches(ignoreLoading = true): Observable<Array<BranchInfo>> {
if (!this.branches) { if (!this.branches) {
const branchesObservable = this.entitiesVersionControlService.listBranches({ignoreLoading: true, ignoreErrors: true}); const branchesObservable = this.entitiesVersionControlService.listBranches({ignoreLoading, ignoreErrors: true});
this.branches = branchesObservable.pipe( this.branches = branchesObservable.pipe(
catchError(() => of([] as Array<BranchInfo>)), catchError(() => of([] as Array<BranchInfo>)),
tap((data) => {
this.defaultBranch = data.find(branch => branch.default);
}),
publishReplay(1), publishReplay(1),
refCount() refCount()
); );
@ -189,6 +255,7 @@ export class BranchAutocompleteComponent implements ControlValueAccessor, OnInit
} }
clear() { clear() {
this.ignoreClosedPanel = true;
this.branchFormGroup.get('branch').patchValue(null, {emitEvent: true}); this.branchFormGroup.get('branch').patchValue(null, {emitEvent: true});
setTimeout(() => { setTimeout(() => {
this.branchInput.nativeElement.blur(); this.branchInput.nativeElement.blur();

View File

@ -14,13 +14,13 @@
/// limitations under the License. /// limitations under the License.
/// ///
import { BaseData } from '@shared/models/base-data'; import { BaseData, ExportableEntity } from '@shared/models/base-data';
import { AssetId } from './id/asset-id'; import { AssetId } from './id/asset-id';
import { TenantId } from '@shared/models/id/tenant-id'; import { TenantId } from '@shared/models/id/tenant-id';
import { CustomerId } from '@shared/models/id/customer-id'; import { CustomerId } from '@shared/models/id/customer-id';
import { EntitySearchQuery } from '@shared/models/relation.models'; import { EntitySearchQuery } from '@shared/models/relation.models';
export interface Asset extends BaseData<AssetId> { export interface Asset extends BaseData<AssetId>, ExportableEntity<AssetId> {
tenantId?: TenantId; tenantId?: TenantId;
customerId?: CustomerId; customerId?: CustomerId;
name: string; name: string;

View File

@ -27,6 +27,12 @@ export interface BaseData<T extends HasId> {
label?: string; label?: string;
} }
export interface ExportableEntity<T extends EntityId> {
createdTime?: number;
id?: T;
externalId?: T;
}
export function hasIdEquals(id1: HasId, id2: HasId): boolean { export function hasIdEquals(id1: HasId, id2: HasId): boolean {
if (isDefinedAndNotNull(id1) && isDefinedAndNotNull(id2)) { if (isDefinedAndNotNull(id1) && isDefinedAndNotNull(id2)) {
return id1.id === id2.id; return id1.id === id2.id;

View File

@ -17,8 +17,9 @@
import { CustomerId } from '@shared/models/id/customer-id'; import { CustomerId } from '@shared/models/id/customer-id';
import { ContactBased } from '@shared/models/contact-based.model'; import { ContactBased } from '@shared/models/contact-based.model';
import { TenantId } from './id/tenant-id'; import { TenantId } from './id/tenant-id';
import { ExportableEntity } from '@shared/models/base-data';
export interface Customer extends ContactBased<CustomerId> { export interface Customer extends ContactBased<CustomerId>, ExportableEntity<CustomerId> {
tenantId: TenantId; tenantId: TenantId;
title: string; title: string;
additionalInfo?: any; additionalInfo?: any;

View File

@ -14,7 +14,7 @@
/// limitations under the License. /// limitations under the License.
/// ///
import { BaseData } from '@shared/models/base-data'; import { BaseData, ExportableEntity } from '@shared/models/base-data';
import { DashboardId } from '@shared/models/id/dashboard-id'; import { DashboardId } from '@shared/models/id/dashboard-id';
import { TenantId } from '@shared/models/id/tenant-id'; import { TenantId } from '@shared/models/id/tenant-id';
import { ShortCustomerInfo } from '@shared/models/customer.model'; import { ShortCustomerInfo } from '@shared/models/customer.model';
@ -23,7 +23,7 @@ import { Timewindow } from '@shared/models/time/time.models';
import { EntityAliases } from './alias.models'; import { EntityAliases } from './alias.models';
import { Filters } from '@shared/models/query/query.models'; import { Filters } from '@shared/models/query/query.models';
export interface DashboardInfo extends BaseData<DashboardId> { export interface DashboardInfo extends BaseData<DashboardId>, ExportableEntity<DashboardId> {
tenantId?: TenantId; tenantId?: TenantId;
title?: string; title?: string;
image?: string; image?: string;

View File

@ -14,7 +14,7 @@
/// limitations under the License. /// limitations under the License.
/// ///
import { BaseData } from '@shared/models/base-data'; import { BaseData, ExportableEntity } from '@shared/models/base-data';
import { DeviceId } from './id/device-id'; import { DeviceId } from './id/device-id';
import { TenantId } from '@shared/models/id/tenant-id'; import { TenantId } from '@shared/models/id/tenant-id';
import { CustomerId } from '@shared/models/id/customer-id'; import { CustomerId } from '@shared/models/id/customer-id';
@ -560,7 +560,7 @@ export interface DeviceProfileData {
provisionConfiguration?: DeviceProvisionConfiguration; provisionConfiguration?: DeviceProvisionConfiguration;
} }
export interface DeviceProfile extends BaseData<DeviceProfileId> { export interface DeviceProfile extends BaseData<DeviceProfileId>, ExportableEntity<DeviceProfileId> {
tenantId?: TenantId; tenantId?: TenantId;
name: string; name: string;
description?: string; description?: string;
@ -685,7 +685,7 @@ export interface DeviceData {
transportConfiguration: DeviceTransportConfiguration; transportConfiguration: DeviceTransportConfiguration;
} }
export interface Device extends BaseData<DeviceId> { export interface Device extends BaseData<DeviceId>, ExportableEntity<DeviceId> {
tenantId?: TenantId; tenantId?: TenantId;
customerId?: CustomerId; customerId?: CustomerId;
name: string; name: string;

View File

@ -14,14 +14,14 @@
/// limitations under the License. /// limitations under the License.
/// ///
import { BaseData } from '@shared/models/base-data'; import { BaseData, ExportableEntity } from '@shared/models/base-data';
import { TenantId } from '@shared/models/id/tenant-id'; import { TenantId } from '@shared/models/id/tenant-id';
import { RuleChainId } from '@shared/models/id/rule-chain-id'; import { RuleChainId } from '@shared/models/id/rule-chain-id';
import { RuleNodeId } from '@shared/models/id/rule-node-id'; import { RuleNodeId } from '@shared/models/id/rule-node-id';
import { RuleNode, RuleNodeComponentDescriptor, RuleNodeType } from '@shared/models/rule-node.models'; import { RuleNode, RuleNodeComponentDescriptor, RuleNodeType } from '@shared/models/rule-node.models';
import { ComponentType } from '@shared/models/component-descriptor.models'; import { ComponentType } from '@shared/models/component-descriptor.models';
export interface RuleChain extends BaseData<RuleChainId> { export interface RuleChain extends BaseData<RuleChainId>, ExportableEntity<RuleChainId> {
tenantId: TenantId; tenantId: TenantId;
name: string; name: string;
firstRuleNodeId: RuleNodeId; firstRuleNodeId: RuleNodeId;

View File

@ -43,6 +43,7 @@ export interface BranchInfo {
} }
export interface EntityVersion { export interface EntityVersion {
timestamp: number;
id: string; id: string;
name: string; name: string;
} }

View File

@ -3109,6 +3109,8 @@
"json-value-required": "JSON value is required." "json-value-required": "JSON value is required."
}, },
"version-control": { "version-control": {
"version-control": "Version control",
"management": "Version control management",
"branch": "Branch", "branch": "Branch",
"select-branch": "Select branch", "select-branch": "Select branch",
"branch-required": "Branch is required", "branch-required": "Branch is required",
@ -3118,7 +3120,13 @@
"version-name-required": "Version name is required", "version-name-required": "Version name is required",
"export-entity-relations": "Export entity relations", "export-entity-relations": "Export entity relations",
"export-entity-version-result-message": "Entity exported with version '{{name}}' and commit id '{{commitId}}'.", "export-entity-version-result-message": "Entity exported with version '{{name}}' and commit id '{{commitId}}'.",
"export-to-git": "Export to Git" "export-to-git": "Export to Git",
"entity-versions": "Entity versions",
"versions": "Versions",
"created-time": "Created time",
"version-id": "Version ID",
"no-entity-versions-text": "No entity versions found",
"no-versions-text": "No versions found"
}, },
"widget": { "widget": {
"widget-library": "Widgets Library", "widget-library": "Widgets Library",