From 7f1e815a672d28b82d6ee006b574f4f0ea1cb76d Mon Sep 17 00:00:00 2001 From: Ralf Aron Date: Sun, 9 Jun 2024 21:35:18 +0200 Subject: [PATCH] fix: zoneless aas components --- package.json | 38 +- .../src/lib/aas-tree/aas-tree-search.ts | 43 +- .../src/lib/aas-tree/aas-tree.component.html | 8 +- .../src/lib/aas-tree/aas-tree.component.ts | 249 ++++++------ .../src/lib/aas-tree/aas-tree.store.ts | 144 +++---- .../src/test/aas-tree/aas-tree-search.spec.ts | 4 - .../test/aas-tree/aas-tree.component.spec.ts | 153 +++----- .../src/app/aas/aas-store.service.ts | 46 +-- .../aas-portal/src/app/aas/aas.component.html | 37 +- .../aas-portal/src/app/aas/aas.component.ts | 131 +++---- .../src/app/aas/can-activate-aas.guard.ts | 29 -- .../app/dashboard/dashboard.component.html | 12 +- .../src/app/dashboard/dashboard.component.ts | 151 ++++--- .../src/app/dashboard/dashboard.service.ts | 90 ++--- .../src/test/aas/aas.component.spec.ts | 26 +- .../src/test/aas/delete-command.spec.ts | 12 +- .../src/test/aas/new-element-command.spec.ts | 6 +- .../test/aas/update-element-command.spec.ts | 6 +- .../dashboard/dashboard.component.spec.ts | 368 ++++++------------ .../test/dashboard/dashboard.service.spec.ts | 25 +- .../start/favorites-form.component.spec.ts | 7 +- .../src/test/start/favorites.service.spec.ts | 2 +- .../src/test/start/start.component.spec.ts | 6 +- 23 files changed, 644 insertions(+), 949 deletions(-) delete mode 100644 projects/aas-portal/src/app/aas/can-activate-aas.guard.ts diff --git a/package.json b/package.json index 6fd1ca1c..091e0d70 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "bcryptjs": "^2.4.3", "bootstrap": "^5.3.3", "bootstrap-icons": "^1.11.3", - "chart.js": "^4.4.2", + "chart.js": "^4.4.3", "cors": "^2.8.5", "express": "^4.19.2", "form-data": "^4.0.0", @@ -66,24 +66,24 @@ "jwt-decode": "^4.0.0", "lodash-es": "^4.17.21", "lowdb": "^7.0.1", - "mongoose": "^8.3.1", + "mongoose": "^8.4.1", "morgan": "^1.10.0", "multer": "^1.4.5-lts.1", - "mysql2": "^3.9.4", - "node-opcua": "^2.124.0", + "mysql2": "^3.10.0", + "node-opcua": "^2.125.0", "node-opcua-client-crawler": "^2.124.0", "nodemailer": "^6.9.13", "reflect-metadata": "^0.2.2", "rxjs": "~7.8.1", - "swagger-ui-express": "^5.0.0", - "tslib": "^2.6.2", + "swagger-ui-express": "^5.0.1", + "tslib": "^2.6.3", "tsoa": "^5.1.1", "tsyringe": "^4.8.0", "uuid": "^9.0.1", - "webdav": "^5.5.0", + "webdav": "^5.6.0", "winston": "^3.13.0", "winston-daily-rotate-file": "^5.0.0", - "ws": "^8.16.0", + "ws": "^8.17.0", "xpath": "^0.0.34", "zone.js": "~0.14.4" }, @@ -96,28 +96,28 @@ "@angular-eslint/template-parser": "17.3.0", "@angular/cli": "^17.3.4", "@angular/compiler-cli": "^17.3.4", - "@babel/plugin-syntax-import-attributes": "^7.24.1", + "@babel/plugin-syntax-import-attributes": "^7.24.7", "@jest/globals": "^29.7.0", "@semantic-release/git": "^10.0.1", - "@semantic-release/github": "^10.0.3", + "@semantic-release/github": "^10.0.6", "@types/bcryptjs": "^2.4.6", "@types/bootstrap": "^5.2.10", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/jasmine": "^5.1.4", - "@types/jquery": "^3.5.29", + "@types/jquery": "^3.5.30", "@types/jsonwebtoken": "^9.0.6", "@types/lodash-es": "^4.17.12", "@types/morgan": "^1.9.9", "@types/multer": "^1.4.11", - "@types/node": "^20.11.18", - "@types/nodemailer": "^6.4.14", + "@types/node": "^20.14.2", + "@types/nodemailer": "^6.4.15", "@types/supertest": "^6.0.2", "@types/swagger-ui-express": "^4.1.6", "@types/uuid": "^9.0.8", "@types/ws": "^8.5.10", - "@typescript-eslint/eslint-plugin": "^7.7.0", - "@typescript-eslint/parser": "^7.7.0", + "@typescript-eslint/eslint-plugin": "^7.12.0", + "@typescript-eslint/parser": "^7.12.0", "babel-plugin-transform-import-meta": "^2.2.1", "esbuild": "^0.20.2", "eslint": "^8.56.0", @@ -135,12 +135,12 @@ "karma-jasmine-html-reporter": "^2.1.0", "karma-junit-reporter": "^2.0.1", "ng-packagr": "^17.3.0", - "prettier": "^3.2.5", - "rimraf": "^5.0.5", + "prettier": "^3.3.1", + "rimraf": "^5.0.7", "semantic-release": "^23.0.8", "supertest": "^6.3.4", - "ts-jest": "^29.1.2", + "ts-jest": "^29.1.4", "ts-node": "^10.9.2", - "typescript": "^5.2.2" + "typescript": "^5.4.5" } } diff --git a/projects/aas-lib/src/lib/aas-tree/aas-tree-search.ts b/projects/aas-lib/src/lib/aas-tree/aas-tree-search.ts index 87663f7a..b50411ca 100644 --- a/projects/aas-lib/src/lib/aas-tree/aas-tree-search.ts +++ b/projects/aas-lib/src/lib/aas-tree/aas-tree-search.ts @@ -9,7 +9,6 @@ import { Injectable } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; import trim from 'lodash-es/trim'; -import { Subscription } from 'rxjs'; import { aas, AASAbbreviation, @@ -27,21 +26,15 @@ import { AASTreeStore, Operator, SearchQuery, SearchTerm } from './aas-tree.stor @Injectable() export class AASTreeSearch { private readonly loop = true; - private readonly subscription = new Subscription(); + private terms: SearchTerm[] = []; public constructor( private readonly store: AASTreeStore, private readonly translate: TranslateService, - ) { - this.subscription.add(this.store.selectTerms.subscribe(() => this.findFirst())); - } - - public destroy(): void { - this.subscription.unsubscribe(); - } + ) {} public find(referable: aas.Referable): void { - const index = this.store.rows.findIndex(row => row.element === referable); + const index = this.store.rows().findIndex(row => row.element === referable); if (index >= 0) { this.store.setMatchIndex(index); } @@ -70,7 +63,7 @@ export class AASTreeSearch { } if (terms.length > 0) { - this.store.setSearchText(terms); + this.terms = terms; } else { this.store.setMatchIndex(-1); } @@ -78,21 +71,21 @@ export class AASTreeSearch { public findNext(): boolean { let completed = false; - if (this.store.rows.length > 0 && this.store.terms.length > 0) { + if (this.store.rows().length > 0 && this.terms.length > 0) { let match = false; - let i = this.store.index < 0 ? 0 : this.store.index + 1; - if (i >= this.store.rows.length) { + let i = this.store.matchIndex() < 0 ? 0 : this.store.matchIndex() + 1; + if (i >= this.store.rows().length) { i = 0; } const start = i; while (this.loop) { - if (this.match(this.store.rows[i])) { + if (this.match(this.store.rows()[i])) { match = true; break; } - if (++i >= this.store.rows.length) { + if (++i >= this.store.rows().length) { i = 0; completed = true; } @@ -110,18 +103,18 @@ export class AASTreeSearch { public findPrevious(): boolean { let completed = false; - if (this.store.rows.length > 0 && this.store.terms.length > 0) { + if (this.store.rows().length > 0 && this.terms.length > 0) { let match = false; - let i = this.store.index <= 0 ? this.store.rows.length - 1 : this.store.index - 1; + let i = this.store.matchIndex() <= 0 ? this.store.rows().length - 1 : this.store.matchIndex() - 1; const start = i; while (this.loop) { - if (this.match(this.store.rows[i])) { + if (this.match(this.store.rows()[i])) { match = true; break; } if (--i <= 0) { - i = this.store.rows.length - 1; + i = this.store.rows().length - 1; completed = true; } @@ -141,17 +134,17 @@ export class AASTreeSearch { } private findFirst(): void { - if (this.store.rows.length > 0 && this.store.terms.length > 0) { + if (this.store.rows().length > 0 && this.terms.length > 0) { let match = false; - let i = this.store.index < 0 ? 0 : this.store.index; + let i = this.store.matchIndex() < 0 ? 0 : this.store.matchIndex(); const start = i; while (this.loop) { - if (this.match(this.store.rows[i])) { + if (this.match(this.store.rows()[i])) { match = true; break; } - if (++i >= this.store.rows.length) { + if (++i >= this.store.rows().length) { i = 0; } @@ -245,7 +238,7 @@ export class AASTreeSearch { private match(row: AASTreeRow): boolean { let match = false; - for (const term of this.store.terms) { + for (const term of this.terms) { if (term.query) { if (row.element.modelType === term.query.modelType) { if (term.query.name) { diff --git a/projects/aas-lib/src/lib/aas-tree/aas-tree.component.html b/projects/aas-lib/src/lib/aas-tree/aas-tree.component.html index ea6b9c7e..087cba66 100644 --- a/projects/aas-lib/src/lib/aas-tree/aas-tree.component.html +++ b/projects/aas-lib/src/lib/aas-tree/aas-tree.component.html @@ -6,13 +6,13 @@ ! !----------------------------------------------------------------------------> -@if (document?.content) { +@if (document()?.content) { - @for (node of nodes; track node) { + @for (node of nodes(); track node) {
+ [disabled]="state() === 'online'" [indeterminate]="someSelected()" [checked]="everySelected()"> @@ -23,11 +23,11 @@
+ [checked]="node.selected" [disabled]="state() === 'online'" />
diff --git a/projects/aas-lib/src/lib/aas-tree/aas-tree.component.ts b/projects/aas-lib/src/lib/aas-tree/aas-tree.component.ts index 1b5711fa..aed4b13e 100644 --- a/projects/aas-lib/src/lib/aas-tree/aas-tree.component.ts +++ b/projects/aas-lib/src/lib/aas-tree/aas-tree.component.ts @@ -6,14 +6,26 @@ * *****************************************************************************/ -import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges } from '@angular/core'; import { NgClass, NgStyle } from '@angular/common'; -import { BehaviorSubject, Subscription, Observable } from 'rxjs'; +import { Router } from '@angular/router'; +import { BehaviorSubject, Subscription } from 'rxjs'; import { WebSocketSubject } from 'rxjs/webSocket'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; -import { Router } from '@angular/router'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import isEqual from 'lodash-es/isEqual'; +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + OnDestroy, + OnInit, + Output, + computed, + effect, + input, +} from '@angular/core'; + import { aas, LiveNode, @@ -64,13 +76,12 @@ interface PropertyValue { standalone: true, imports: [NgClass, NgStyle, TranslateModule], providers: [AASTreeSearch, AASTreeStore], + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class AASTreeComponent implements OnInit, OnChanges, OnDestroy { +export class AASTreeComponent implements OnInit, OnDestroy { private readonly liveNodes: LiveNode[] = []; private readonly map = new Map(); private readonly subscription = new Subscription(); - private searchSubscription?: Subscription; - private _selected: aas.Referable[] = []; private shiftKey = false; private altKey = false; @@ -90,26 +101,62 @@ export class AASTreeComponent implements OnInit, OnChanges, OnDestroy { private readonly webSocketFactory: WebSocketFactoryService, private readonly clipboard: ClipboardService, ) { + effect(() => { + const searchText = this.search(); + if (searchText) { + this.searching.start(searchText); + } + }); + + effect(() => { + this.store.updateRows(this.document()); + }); + + effect(() => { + if (this.state() === 'online') { + this.goOnline(); + } else { + this.goOffline(); + } + }); + + effect(() => { + this.selectedChange.emit(this.store.selectedElements()); + }); + + effect(() => { + const matchIndex = this.store.matchIndex(); + if (matchIndex >= 0) { + this.store.expandRow(matchIndex); + } + }); + + effect(() => { + const row = this.store.matchRow(); + if (!row) return; + setTimeout(() => { + const element = this.dom.getElementById(row.id); + element?.scrollIntoView({ block: 'center', behavior: 'smooth' }); + }); + }); + this.window.addEventListener('keyup', this.keyup); this.window.addEventListener('keydown', this.keydown); } - @Input() - public document: AASDocument | null = null; + public readonly document = input(null); - @Input() - public state: OnlineState | null = 'offline'; + public readonly state = input('offline'); - @Input() - public search: Observable | null = null; + public readonly search = input(''); @Input() public get selected(): aas.Referable[] { - return this._selected; + return this.store.selectedElements(); } public set selected(values: aas.Referable[]) { - if (!isEqual(values, this._selected)) { + if (!isEqual(values, this.store.selectedElements())) { this.store.setSelectedElements(values); } } @@ -117,111 +164,48 @@ export class AASTreeComponent implements OnInit, OnChanges, OnDestroy { @Output() public selectedChange = new EventEmitter(); - public get onlineReady(): boolean { - return this.document?.onlineReady ?? false; - } + public readonly onlineReady = computed(() => this.document()?.onlineReady ?? false); - public get readonly(): boolean { - return this.document?.readonly ?? true; - } + public readonly readonly = computed(() => this.document()?.readonly ?? true); - public get modified(): boolean { - return this.document?.modified ?? false; - } + public readonly modified = computed(() => this.document()?.modified ?? false); - public get someSelected(): boolean { - const rows = this.store.rows; + public readonly someSelected = computed(() => { + const rows = this.store.rows(); return rows.length > 0 && rows.some(row => row.selected) && !rows.every(row => row.selected); - } + }); - public get everySelected(): boolean { - const rows = this.store.rows; + public readonly everySelected = computed(() => { + const rows = this.store.rows(); return rows.length > 0 && rows.every(row => row.selected); - } + }); - public get nodes(): AASTreeRow[] { - return this.store.nodes; - } + public readonly nodes = this.store.nodes; - public readonly selectMatchIndex = this.store.selectMatchIndex; + public readonly matchIndex = this.store.matchIndex; - public readonly selectMatchRow = this.store.selectMatchRow; + public readonly matchRow = this.store.matchRow; - public get rows(): AASTreeRow[] { - return this.store.rows; - } + public readonly rows = this.store.rows; public ngOnInit(): void { - this.subscription.add( - this.store.selectSelectedElements.pipe().subscribe(elements => { - this._selected = elements; - this.selectedChange.emit(elements); - }), - ); - - this.subscription.add( - this.store.selectMatchIndex.pipe().subscribe(index => { - if (index >= 0) { - this.store.expandRow(index); - } - }), - ); - - this.subscription.add( - this.store.selectMatchRow.pipe().subscribe(row => { - if (row) { - setTimeout(() => { - const element = this.dom.getElementById(row.id); - element?.scrollIntoView({ block: 'center', behavior: 'smooth' }); - }); - } - }), - ); - this.subscription.add( this.translate.onLangChange.subscribe(() => { - this.store.updateRows(this.document); + this.store.updateRows(this.document()); }), ); } - public ngOnChanges(changes: SimpleChanges): void { - if (changes['document']) { - this.store.updateRows(this.document); - } - - if (changes['search']) { - if (this.searchSubscription) { - this.searchSubscription.unsubscribe(); - this.searchSubscription = undefined; - } - - if (this.search) { - this.searchSubscription = this.search.subscribe(value => this.searching.start(value)); - } - } - - const stateChange = changes['state']; - if (stateChange) { - if (stateChange.previousValue !== stateChange.currentValue) { - if (this.state === 'online') { - this.goOnline(); - } else { - this.goOffline(); - } - } - } - } - public get message(): string { - if (this.document) { - if (this.document.content) { + const document = this.document(); + if (document) { + if (document.content) { return ''; } return stringFormat( this.translate.instant('INFO_AAS_OFFLINE'), - new Date(this.document.timestamp).toLocaleString(this.translate.currentLang), + new Date(document.timestamp).toLocaleString(this.translate.currentLang), ); } @@ -230,9 +214,7 @@ export class AASTreeComponent implements OnInit, OnChanges, OnDestroy { public ngOnDestroy(): void { this.subscription.unsubscribe(); - this.searchSubscription?.unsubscribe(); this.webSocketSubject?.unsubscribe(); - this.searching?.destroy(); this.window.removeEventListener('keyup', this.keyup); this.window.removeEventListener('keydown', this.keydown); } @@ -252,7 +234,7 @@ export class AASTreeComponent implements OnInit, OnChanges, OnDestroy { } public getValue(node: AASTreeRow): string | boolean | undefined { - if (this.state === 'online' && node.element.modelType === 'Property') { + if (this.state() === 'online' && node.element.modelType === 'Property') { const property = node.element as aas.Property; let value: string | boolean; const item = property.nodeId && this.map.get(property.nodeId); @@ -293,7 +275,7 @@ export class AASTreeComponent implements OnInit, OnChanges, OnDestroy { } public async openFile(file: aas.File | undefined): Promise { - if (!file || !file.value || this.state === 'online') return; + if (!file || !file.value || this.state() === 'online') return; const { name, url } = this.resolveFile(file); if (name && url) { @@ -311,7 +293,8 @@ export class AASTreeComponent implements OnInit, OnChanges, OnDestroy { } public async openBlob(blob: aas.Blob | undefined): Promise { - if (!blob?.value || !this.document || !blob.parent || this.state === 'online') return; + const document = this.document(); + if (!blob?.value || !document || !blob.parent || this.state() === 'online') return; const extension = mimeTypeToExtension(blob.contentType); if (extension) { @@ -323,8 +306,8 @@ export class AASTreeComponent implements OnInit, OnChanges, OnDestroy { await this.showVideoAsync(name, `data:${blob.contentType};base64,${blob.value}`); } } else { - const endpoint = encodeBase64Url(this.document.endpoint); - const id = encodeBase64Url(this.document.id); + const endpoint = encodeBase64Url(document.endpoint); + const id = encodeBase64Url(document.id); const smId = encodeBase64Url(blob.parent.keys[0].value); const path = getIdShortPath(blob); const url = `/api/v1/containers/${endpoint}/documents/${id}/submodels/${smId}/blobs/${path}/value`; @@ -342,7 +325,7 @@ export class AASTreeComponent implements OnInit, OnChanges, OnDestroy { } public async openOperation(operation: aas.Operation | undefined): Promise { - if (!operation || this.state === 'online') return; + if (!operation || this.state() === 'online') return; try { if (operation) { @@ -359,7 +342,7 @@ export class AASTreeComponent implements OnInit, OnChanges, OnDestroy { } public openReference(reference: aas.Reference | string | undefined): void { - if (!reference || this.state === 'online') return; + if (!reference || this.state() === 'online') return; if (typeof reference === 'string') { this.openDocumentByAssetId(reference); @@ -377,18 +360,19 @@ export class AASTreeComponent implements OnInit, OnChanges, OnDestroy { } public openSubmodel(submodel: aas.Submodel | undefined): void { - if (!submodel || this.state === 'online') return; + if (!submodel || this.state() === 'online') return; const semanticId = resolveSemanticId(submodel); if (semanticId) { + const document = this.document(); const template = supportedSubmodelTemplates.get(semanticId); - if (template && this.document) { + if (template && document) { const descriptor: SubmodelViewDescriptor = { template, submodels: [ { - id: this.document.id, - endpoint: this.document.endpoint, + id: document.id, + endpoint: document.endpoint, idShort: submodel.idShort, }, ], @@ -460,7 +444,7 @@ export class AASTreeComponent implements OnInit, OnChanges, OnDestroy { private goOnline(): void { try { - this.prepareOnline(this.store.rows.filter(row => row.selected)); + this.prepareOnline(this.store.rows().filter(row => row.selected)); this.play(); } catch (error) { this.stop(); @@ -472,14 +456,15 @@ export class AASTreeComponent implements OnInit, OnChanges, OnDestroy { } private play(): void { - if (this.document) { + const document = this.document(); + if (document) { this.webSocketSubject = this.webSocketFactory.create(); this.webSocketSubject.subscribe({ next: this.onMessage, error: this.onError, }); - this.webSocketSubject.next(this.createMessage(this.document)); + this.webSocketSubject.next(this.createMessage(document)); } } @@ -491,21 +476,19 @@ export class AASTreeComponent implements OnInit, OnChanges, OnDestroy { } private prepareOnline(rows: AASTreeRow[]): void { - if (this.document) { - this.liveNodes.splice(0, this.liveNodes.length); - this.map.clear(); - for (const row of rows) { - if (row.selected) { - const property = row.element as aas.Property; - if (property.nodeId) { - this.liveNodes.push({ - nodeId: property.nodeId, - valueType: property.valueType ?? 'undefined', - }); - - const subject = new BehaviorSubject(this.getPropertyValue(property)); - this.map.set(property.nodeId, { property: property, value: subject }); - } + this.liveNodes.splice(0, this.liveNodes.length); + this.map.clear(); + for (const row of rows) { + if (row.selected) { + const property = row.element as aas.Property; + if (property.nodeId) { + this.liveNodes.push({ + nodeId: property.nodeId, + valueType: property.valueType ?? 'undefined', + }); + + const subject = new BehaviorSubject(this.getPropertyValue(property)); + this.map.set(property.nodeId, { property: property, value: subject }); } } } @@ -532,11 +515,12 @@ export class AASTreeComponent implements OnInit, OnChanges, OnDestroy { } private selectModelReference(reference: aas.Reference): void { - if (!this.document?.content) { + const content = this.document()?.content; + if (!content) { return; } - const referable = selectReferable(this.document.content, reference); + const referable = selectReferable(content, reference); if (referable) { this.searching.find(referable); } else if (reference.keys[0].type === 'AssetAdministrationShell') { @@ -587,14 +571,15 @@ export class AASTreeComponent implements OnInit, OnChanges, OnDestroy { private resolveFile(file: aas.File): { url?: string; name?: string } { const value: { url?: string; name?: string } = {}; - if (this.document?.content && file.value) { - const submodel = selectSubmodel(this.document.content, file); + const document = this.document(); + if (document?.content && file.value) { + const submodel = selectSubmodel(document.content, file); if (submodel) { const smId = encodeBase64Url(submodel.id); const path = getIdShortPath(file); value.name = basename(file.value); - const name = encodeBase64Url(this.document.endpoint); - const id = encodeBase64Url(this.document.id); + const name = encodeBase64Url(document.endpoint); + const id = encodeBase64Url(document.id); value.url = `/api/v1/containers/${name}/documents/${id}/submodels/${smId}/submodel-elements/${path}/value`; } } diff --git a/projects/aas-lib/src/lib/aas-tree/aas-tree.store.ts b/projects/aas-lib/src/lib/aas-tree/aas-tree.store.ts index 7af6f9c9..5bb68080 100644 --- a/projects/aas-lib/src/lib/aas-tree/aas-tree.store.ts +++ b/projects/aas-lib/src/lib/aas-tree/aas-tree.store.ts @@ -6,10 +6,9 @@ * *****************************************************************************/ -import { Injectable } from '@angular/core'; +import { Injectable, computed, signal } from '@angular/core'; import { AASTree, AASTreeRow } from './aas-tree-row'; import { AASDocument, aas } from 'common'; -import { BehaviorSubject, Subject, map } from 'rxjs'; import { TranslateService } from '@ngx-translate/core'; import { NotifyService } from '../notify/notify.service'; @@ -27,121 +26,128 @@ export interface SearchTerm { query?: SearchQuery; } +interface AASTreeState { + matchIndex: number; + rows: AASTreeRow[]; + nodes: AASTreeRow[]; +} + @Injectable() export class AASTreeStore { - private readonly terms$ = new BehaviorSubject([]); - private readonly index$ = new BehaviorSubject(-1); - private readonly selectedElements = new Subject(); - private _rows: AASTreeRow[] = []; - private _nodes: AASTreeRow[] = []; + private readonly _state = signal({ matchIndex: -1, rows: [], nodes: [] }); public constructor( private readonly notify: NotifyService, private readonly translate: TranslateService, ) {} - public get rows(): AASTreeRow[] { - return this._rows; - } + public readonly rows = computed(() => this._state().rows); - public get terms(): SearchTerm[] { - return this.terms$.getValue(); - } + public readonly matchIndex = computed(() => this._state().matchIndex); - public get index(): number { - return this.index$.getValue(); - } + public readonly nodes = computed(() => this._state().nodes); - public get nodes(): AASTreeRow[] { - return this._nodes; - } + public readonly selectedRows = computed(() => this._state().rows.filter(row => row.selected)); - public readonly selectTerms = this.terms$.asObservable(); + public readonly selectedElements = computed(() => + this._state() + .rows.filter(row => row.selected) + .map(item => item.element), + ); - public get selectSelectedRows(): AASTreeRow[] { - return this._rows.filter(row => row.selected); - } - - public readonly selectSelectedElements = this.selectedElements.asObservable(); - - public readonly selectMatchIndex = this.index$.asObservable(); - - public readonly selectMatchRow = this.index$ - .asObservable() - .pipe(map(index => (index >= 0 ? this._rows[index] : undefined))); + public readonly matchRow = computed(() => { + const state = this._state(); + return state.matchIndex >= 0 ? state.rows[state.matchIndex] : undefined; + }); public toggleSelected(row: AASTreeRow, altKey: boolean, shiftKey: boolean): void { - const tree = new AASTree(this._rows); + const tree = new AASTree(this._state().rows); tree.toggleSelected(row, altKey, shiftKey); - this._rows = tree.nodes; - this._nodes = tree.expanded; - this.selectedElements.next(this._rows.filter(row => row.selected).map(item => item.element)); + this._state.update(state => ({ + ...state, + rows: tree.nodes, + nodes: tree.expanded, + })); } public toggleSelections(): void { - const tree = new AASTree(this._rows); + const tree = new AASTree(this._state().rows); tree.toggleSelections(); - this._rows = tree.nodes; - this._nodes = tree.expanded; - this.selectedElements.next(this._rows.filter(row => row.selected).map(item => item.element)); + this._state.update(state => ({ + ...state, + rows: tree.nodes, + nodes: tree.expanded, + })); } public collapse(): void { - const tree = new AASTree(this._rows); + const tree = new AASTree(this._state().rows); tree.collapse(); - this._rows = tree.nodes; - this._nodes = tree.expanded; + this._state.update(state => ({ + ...state, + rows: tree.nodes, + nodes: tree.expanded, + })); } public collapseRow(row: AASTreeRow): void { - const tree = new AASTree(this._rows); + const tree = new AASTree(this._state().rows); tree.collapse(row); - this._rows = tree.nodes; - this._nodes = tree.expanded; + this._state.update(state => ({ + ...state, + rows: tree.nodes, + nodes: tree.expanded, + })); } public expandRow(arg: AASTreeRow | number): void { - const tree = new AASTree(this._rows); + const tree = new AASTree(this._state().rows); tree.expand(arg); - this._rows = tree.nodes; - this._nodes = tree.expanded; + this._state.update(state => ({ + ...state, + rows: tree.nodes, + nodes: tree.expanded, + })); } public updateRows(document: AASDocument | null): void { try { if (document) { const tree = AASTree.from(document, this.translate.currentLang); - this._rows = tree.nodes; - this._nodes = tree.expanded; + this._state.set({ + matchIndex: -1, + rows: tree.nodes, + nodes: tree.expanded, + }); } else { - this._rows = []; - this._nodes = []; + this._state.set({ + matchIndex: -1, + rows: [], + nodes: [], + }); } - - this.index$.next(-1); - this.selectedElements.next([]); } catch (error) { this.notify.error(error); } } public setSelectedElements(elements: aas.Referable[]): void { - const tree = new AASTree(this._rows); + const tree = new AASTree(this._state().rows); tree.selectedElements = elements; - this._rows = tree.nodes; - this._nodes = tree.expanded; - this.selectedElements.next(this._rows.filter(row => row.selected).map(item => item.element)); - } - - public setSearchText(terms: SearchTerm[]): void { - this.terms$.next(terms); + this._state.update(state => ({ + ...state, + rows: tree.nodes, + nodes: tree.expanded, + })); } - public setMatchIndex(index: number): void { - const tree = new AASTree(this._rows); - tree.highlight(index); - this._rows = tree.nodes; - this._nodes = tree.expanded; - this.index$.next(index); + public setMatchIndex(matchIndex: number): void { + const tree = new AASTree(this._state().rows); + tree.highlight(matchIndex); + this._state.set({ + matchIndex: matchIndex, + rows: tree.nodes, + nodes: tree.expanded, + }); } } diff --git a/projects/aas-lib/src/test/aas-tree/aas-tree-search.spec.ts b/projects/aas-lib/src/test/aas-tree/aas-tree-search.spec.ts index 9486227e..2115de67 100644 --- a/projects/aas-lib/src/test/aas-tree/aas-tree-search.spec.ts +++ b/projects/aas-lib/src/test/aas-tree/aas-tree-search.spec.ts @@ -42,10 +42,6 @@ describe('AASTreeSearch', function () { store.updateRows(sampleDocument); }); - afterEach(function () { - search.destroy(); - }); - it('should create', () => { expect(search).toBeTruthy(); }); diff --git a/projects/aas-lib/src/test/aas-tree/aas-tree.component.spec.ts b/projects/aas-lib/src/test/aas-tree/aas-tree.component.spec.ts index c828d730..adaaad27 100644 --- a/projects/aas-lib/src/test/aas-tree/aas-tree.component.spec.ts +++ b/projects/aas-lib/src/test/aas-tree/aas-tree.component.spec.ts @@ -7,10 +7,9 @@ *****************************************************************************/ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { SimpleChange } from '@angular/core'; import { TranslateFakeLoader, TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { AASDocument, WebSocketData } from 'common'; -import { BehaviorSubject, Subject, first, skipWhile, takeWhile } from 'rxjs'; +import { Subject } from 'rxjs'; import { AASTreeComponent } from '../../lib/aas-tree/aas-tree.component'; import { sampleDocument } from '../assets/sample-document'; import { NotifyService } from '../../lib/notify/notify.service'; @@ -66,12 +65,8 @@ describe('AASTreeComponent', () => { fixture = TestBed.createComponent(AASTreeComponent); component = fixture.componentInstance; + fixture.componentRef.setInput('document', document); fixture.detectChanges(); - - component.document = document; - component.ngOnChanges({ - document: new SimpleChange(null, document, true), - }); }); afterEach(() => { @@ -83,31 +78,31 @@ describe('AASTreeComponent', () => { }); it('gets the current document', () => { - expect(component.document).toEqual(document); + expect(component.document()).toEqual(document); }); it('indicates if document is online-ready', () => { - expect(component.onlineReady).toEqual(document.onlineReady ? document.onlineReady : false); + expect(component.onlineReady()).toEqual(document.onlineReady ? document.onlineReady : false); }); it('indicates if document is read-only', () => { - expect(component.readonly).toEqual(document.readonly); + expect(component.readonly()).toEqual(document.readonly); }); it('indicates if the document is modified', () => { - expect(component.modified).toEqual(document.modified ? document.modified : false); + expect(component.modified()).toEqual(document.modified ? document.modified : false); }); it('shows the current offline state', () => { - expect(component.state).toEqual('offline'); + expect(component.state()).toEqual('offline'); }); it('indicates if no node is selected', () => { - expect(component.someSelected).toBeFalse(); + expect(component.someSelected()).toBeFalse(); }); it('shows the first level ExampleMotor', () => { - const nodes = component.nodes; + const nodes = component.nodes(); expect(nodes).toBeTruthy(); expect(nodes.length).toEqual(5); expect(nodes[0].element.idShort).toEqual('ExampleMotor'); @@ -121,130 +116,80 @@ describe('AASTreeComponent', () => { describe('toggleSelection', () => { it('toggle selection of all rows', () => { component.toggleSelections(); - expect(component.rows.every(value => value.selected)).toBeTrue(); + expect(component.rows().every(value => value.selected)).toBeTrue(); }); }); describe('collapse', () => { it('collapse root element', () => { - component.collapse(component.nodes[0]); - expect(component.nodes.length).toEqual(1); - expect(component.nodes[0].element.idShort).toEqual('ExampleMotor'); - expect(component.nodes[0].expanded).toBeFalse(); + component.collapse(component.nodes()[0]); + expect(component.nodes().length).toEqual(1); + expect(component.nodes()[0].element.idShort).toEqual('ExampleMotor'); + expect(component.nodes()[0].expanded).toBeFalse(); }); it('collapse to initial view', () => { component.collapse(); - expect(component.nodes.length).toEqual(5); - expect(component.nodes[0].element.idShort).toEqual('ExampleMotor'); - expect(component.nodes[0].expanded).toBeTrue(); - expect(component.nodes[1].element.idShort).toEqual('Identification'); - expect(component.nodes[2].element.idShort).toEqual('TechnicalData'); - expect(component.nodes[3].element.idShort).toEqual('OperationalData'); - expect(component.nodes[4].element.idShort).toEqual('Documentation'); + expect(component.nodes().length).toEqual(5); + expect(component.nodes()[0].element.idShort).toEqual('ExampleMotor'); + expect(component.nodes()[0].expanded).toBeTrue(); + expect(component.nodes()[1].element.idShort).toEqual('Identification'); + expect(component.nodes()[2].element.idShort).toEqual('TechnicalData'); + expect(component.nodes()[3].element.idShort).toEqual('OperationalData'); + expect(component.nodes()[4].element.idShort).toEqual('Documentation'); }); }); describe('expand', () => { it('expand submodel "Identification"', () => { - component.expand(component.nodes[1]); - expect(component.nodes.length).toEqual(9); - expect(component.nodes[1].element.idShort).toEqual('Identification'); - expect(component.nodes[0].expanded).toBeTrue(); + component.expand(component.nodes()[1]); + expect(component.nodes().length).toEqual(9); + expect(component.nodes()[1].element.idShort).toEqual('Identification'); + expect(component.nodes()[0].expanded).toBeTrue(); }); }); describe('search text "max"', () => { - let search: BehaviorSubject; - - beforeEach(() => { - search = new BehaviorSubject(''); - component.search = search.asObservable(); - component.ngOnChanges({ - search: new SimpleChange(null, search, true), - }); - }); - - it('the search text must be at least three characters long', (done: DoneFn) => { - const subscription = component.selectMatchRow.pipe().subscribe(row => { - if (row) { - expect(row.name).toEqual('MaxRotationSpeed'); - subscription.unsubscribe(); - done(); - } - }); - - search.next('z'); - search.next('zy'); - search.next('max'); + it('the search text must be at least three characters long', () => { + fixture.componentRef.setInput('search', 'z'); + fixture.componentRef.setInput('search', 'zy'); + fixture.componentRef.setInput('search', 'max'); + expect(component.matchRow()?.name).toEqual('MaxRotationSpeed'); }); - it('finds the first occurrence of "max" at row 7', (done: DoneFn) => { - component.selectMatchIndex.pipe(skipWhile(index => index < 0)).subscribe(index => { - expect(index).toEqual(7); - done(); - }); - - search.next('max'); + it('finds the first occurrence of "max" at row 7', () => { + fixture.componentRef.setInput('search', 'max'); + expect(component.matchIndex()).toEqual(7); }); - it('finds the next occurrence of "max" at row 8', (done: DoneFn) => { - search.next('max'); + it('finds the next occurrence of "max" at row 8', () => { + fixture.componentRef.setInput('search', 'max'); component.findNext(); - component.selectMatchIndex.pipe(first()).subscribe(value => { - expect(value).toEqual(8); - done(); - }); + expect(component.matchIndex()).toEqual(8); }); - it('finds the previous occurrence of "max" at row 25', (done: DoneFn) => { - search.next('max'); + it('finds the previous occurrence of "max" at row 25', () => { + fixture.componentRef.setInput('search', 'max'); component.findPrevious(); - component.selectMatchIndex.pipe(first()).subscribe(value => { - expect(value).toEqual(8); - done(); - }); + expect(component.matchIndex()).toEqual(8); }); }); describe('search pattern', () => { - let search: BehaviorSubject; - - beforeEach(() => { - search = new BehaviorSubject(''); - component.search = search.asObservable(); - component.ngOnChanges({ - search: { - currentValue: search, - previousValue: null, - firstChange: true, - isFirstChange: () => true, - }, - }); - }); - - it('finds the first occurrence of "#prop:max" at row 7', (done: DoneFn) => { - search.next('#prop:max'); - component.selectMatchIndex.pipe(first()).subscribe(value => { - expect(value).toEqual(7); - done(); - }); + it('finds the first occurrence of "#prop:max" at row 7', () => { + fixture.componentRef.setInput('search', '#prop:max'); + expect(component.matchIndex()).toEqual(7); }); - it('finds the first occurrence of "#prop:MaxTorque" at row 8', (done: DoneFn) => { - search.next('#prop:MaxTorque'); - component.selectMatchIndex.pipe(first()).subscribe(value => { - expect(value).toEqual(8); - done(); - }); + it('finds the first occurrence of "#prop:MaxTorque" at row 8', () => { + fixture.componentRef.setInput('search', '#prop:MaxTorque'); + expect(component.matchIndex()).toEqual(8); }); - it('finds the first occurrence of "#prop:serialnumber=P12345678I40" at row 5', (done: DoneFn) => { - search.next('#prop:serialnumber=P12345678I40'); - component.selectMatchIndex.pipe(first()).subscribe(value => { - expect(value).toEqual(5); - done(); - }); + it('finds the first occurrence of "#prop:serialnumber=P12345678I40" at row 5', () => { + fixture.componentRef.setInput('search', '#prop:serialnumber=P12345678I40'); + fixture.detectChanges(); + expect(component.matchIndex()).toEqual(5); }); }); }); diff --git a/projects/aas-portal/src/app/aas/aas-store.service.ts b/projects/aas-portal/src/app/aas/aas-store.service.ts index 0a14d0a8..058ad62a 100644 --- a/projects/aas-portal/src/app/aas/aas-store.service.ts +++ b/projects/aas-portal/src/app/aas/aas-store.service.ts @@ -6,67 +6,47 @@ * *****************************************************************************/ -import { Injectable } from '@angular/core'; -import { NotifyService, OnlineState } from 'aas-lib'; +import { Injectable, signal } from '@angular/core'; +import { OnlineState } from 'aas-lib'; import { AASDocument } from 'common'; -import { BehaviorSubject, Observable } from 'rxjs'; import { AASApiService } from './aas-api.service'; @Injectable({ providedIn: 'root', }) export class AASStoreService { - private _document: AASDocument | null = null; - private _state: OnlineState = 'offline'; - private readonly search$ = new BehaviorSubject(''); + private readonly _document = signal(null); - public constructor( - private readonly api: AASApiService, - private readonly notify: NotifyService, - ) {} + public constructor(private readonly api: AASApiService) {} - public get document(): AASDocument | null { - return this._document; - } + public readonly document = this._document.asReadonly(); - public get state(): OnlineState { - return this._state; - } + public readonly state = signal('offline'); - public get search(): Observable { - return this.search$.asObservable(); - } + public readonly search = signal(''); public getDocumentContent(document: AASDocument): void { this.api.getContent(document.id, document.endpoint).subscribe({ - next: content => (this._document = { ...document, content }), - error: () => (this._document = document), + next: content => this._document.set({ ...document, content }), + error: () => this._document.set(document), }); } public getDocument(id: string, endpoint: string): void { this.api.getDocument(id, endpoint).subscribe({ - next: document => (this._document = document), + next: document => this._document.set(document), }); } public setDocument(document: AASDocument | null): void { - this._document = document; + this._document.set(document); } public applyDocument(document: AASDocument): void { - this._document = { ...document, modified: true }; + this._document.set({ ...document, modified: true }); } public resetModified(document: AASDocument): void { - this._document = { ...document, modified: false }; - } - - public setSearch(value: string): void { - this.search$.next(value); - } - - public setState(value: 'offline' | 'online'): void { - this._state = value; + this._document.set({ ...document, modified: false }); } } diff --git a/projects/aas-portal/src/app/aas/aas.component.html b/projects/aas-portal/src/app/aas/aas.component.html index 3cd6258b..f9367daf 100644 --- a/projects/aas-portal/src/app/aas/aas.component.html +++ b/projects/aas-portal/src/app/aas/aas.component.html @@ -7,11 +7,11 @@ !---------------------------------------------------------------------------->
- @if (document) { + @if (document()) {
- +
@@ -22,35 +22,36 @@
LABEL_ASSET_ID
-
{{address}}
-
{{idShort}}
-
{{id}}
-
{{version}}
-
{{assetId}}
+
{{address()}}
+
{{idShort()}}
+
{{id()}}
+
{{version()}}
+
{{assetId()}}
}
- +
- -
- + @for (page of dashboardPages(); track page.name) { } @@ -62,9 +63,9 @@
-
+
@@ -73,7 +74,7 @@
-
+
@@ -81,7 +82,7 @@
-
+
@@ -95,7 +96,7 @@
-