Merge pull request #3 from Trivernis/fix/angular-peformance

Fix/angular peformance
pull/4/head
Julius Riegel 3 years ago committed by GitHub
commit 5c6f479332
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -11,7 +11,8 @@
"projectType": "application", "projectType": "application",
"schematics": { "schematics": {
"@schematics/angular:component": { "@schematics/angular:component": {
"style": "scss" "style": "scss",
"changeDetection": "OnPush"
}, },
"@schematics/angular:application": { "@schematics/angular:application": {
"strict": true "strict": true
@ -67,7 +68,7 @@
"optimization": { "optimization": {
"fonts": false, "fonts": false,
"styles": false, "styles": false,
"scripts": true "scripts": false
}, },
"vendorChunk": true, "vendorChunk": true,
"extractLicenses": false, "extractLicenses": false,

@ -40,7 +40,7 @@ checksum = "84450d0b4a8bd1ba4144ce8ce718fbc5d071358b1e5384bace6536b3d1f2d5b3"
[[package]] [[package]]
name = "app" name = "app"
version = "0.12.0" version = "0.13.0"
dependencies = [ dependencies = [
"mediarepo-api", "mediarepo-api",
"serde", "serde",

@ -4,7 +4,7 @@
<mat-tab [label]="this.selectedRepository? 'RepositoryData' : 'Repositories'"> <mat-tab [label]="this.selectedRepository? 'RepositoryData' : 'Repositories'">
<app-repositories-tab></app-repositories-tab> <app-repositories-tab></app-repositories-tab>
</mat-tab> </mat-tab>
<mat-tab *ngFor="let tab of tabs"> <mat-tab *ngFor="let tab of tabs; trackBy: trackByTabId">
<ng-template mat-tab-label> <ng-template mat-tab-label>
<div (click)="this.onMouseClickTabLabel(tab, $event)" class="tab-label-div"> <div (click)="this.onMouseClickTabLabel(tab, $event)" class="tab-label-div">
{{tab.category}} {{tab.category}}

@ -27,7 +27,8 @@ export class CoreComponent {
private tabService: TabService, private tabService: TabService,
private repoService: RepositoryService, private repoService: RepositoryService,
private stateService: StateService, private stateService: StateService,
private tagService: TagService) { private tagService: TagService
) {
this.selectedRepository = this.repoService.selectedRepository.getValue(); this.selectedRepository = this.repoService.selectedRepository.getValue();
this.repoService.selectedRepository.subscribe(async (selected) => { this.repoService.selectedRepository.subscribe(async (selected) => {
@ -114,4 +115,8 @@ export class CoreComponent {
await this.closeTab(tab); await this.closeTab(tab);
} }
} }
public trackByTabId(index: number, item: TabState) {
return item.uuid;
}
} }

@ -73,7 +73,6 @@ export class RepositoriesTabComponent implements OnInit, AfterViewInit {
await this.runRepositoryStartupTasks(dialogContext); await this.runRepositoryStartupTasks(dialogContext);
dialogContext.message.next("Restoring previous tabs..."); dialogContext.message.next("Restoring previous tabs...");
await this.repoService.loadRepositories(); await this.repoService.loadRepositories();
await this.stateService.loadState();
dialogContext.dialog.close(true); dialogContext.dialog.close(true);
} catch (err: any) { } catch (err: any) {
this.errorBroker.showError(err); this.errorBroker.showError(err);

@ -14,6 +14,7 @@ import {MetadataEntryComponent} from "./metadata-entry/metadata-entry.component"
import {BusyDialogComponent} from "./busy-dialog/busy-dialog.component"; import {BusyDialogComponent} from "./busy-dialog/busy-dialog.component";
import {SelectableComponent} from "./selectable/selectable.component"; import {SelectableComponent} from "./selectable/selectable.component";
import {MatProgressBarModule} from "@angular/material/progress-bar"; import {MatProgressBarModule} from "@angular/material/progress-bar";
import {HasPropertyPipe} from "./pipes/has-property.pipe";
@NgModule({ @NgModule({
@ -26,6 +27,7 @@ import {MatProgressBarModule} from "@angular/material/progress-bar";
MetadataEntryComponent, MetadataEntryComponent,
BusyDialogComponent, BusyDialogComponent,
SelectableComponent, SelectableComponent,
HasPropertyPipe,
], ],
exports: [ exports: [
ConfirmDialogComponent, ConfirmDialogComponent,
@ -35,6 +37,7 @@ import {MatProgressBarModule} from "@angular/material/progress-bar";
InputReceiverDirective, InputReceiverDirective,
MetadataEntryComponent, MetadataEntryComponent,
SelectableComponent, SelectableComponent,
HasPropertyPipe,
], ],
imports: [ imports: [
CommonModule, CommonModule,

@ -1,5 +1,5 @@
<ng-content></ng-content> <ng-content></ng-content>
<div *ngIf="this.busy" [class.blur]="this.blurBackground" [class.darken]="this.darkenBackground" <div *ngIf="this.busy" [class.blur]="this.blurBackground" [class.darken]="this.darkenBackground"
class="busy-indicator-overlay"> class="busy-indicator-overlay">
<mat-progress-spinner [mode]="mode" [value]="value" color="primary"></mat-progress-spinner> <mat-progress-spinner *ngIf="this.busy" [mode]="mode" [value]="value" color="primary"></mat-progress-spinner>
</div> </div>

@ -22,6 +22,10 @@
background-color: rgba(0, 0, 0, 0.5); background-color: rgba(0, 0, 0, 0.5);
} }
hidden {
display: none;
}
::ng-deep app-busy-indicator { ::ng-deep app-busy-indicator {
width: 100%; width: 100%;
height: 100%; height: 100%;

@ -1,24 +1,33 @@
import {Component, Input} from "@angular/core"; import {ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, SimpleChanges} from "@angular/core";
import {ProgressSpinnerMode} from "@angular/material/progress-spinner"; import {ProgressSpinnerMode} from "@angular/material/progress-spinner";
@Component({ @Component({
selector: "app-busy-indicator", selector: "app-busy-indicator",
templateUrl: "./busy-indicator.component.html", templateUrl: "./busy-indicator.component.html",
styleUrls: ["./busy-indicator.component.scss"] styleUrls: ["./busy-indicator.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class BusyIndicatorComponent { export class BusyIndicatorComponent implements OnChanges {
@Input() busy: boolean = false; @Input() busy: boolean = false;
@Input() blurBackground: boolean = false; @Input() blurBackground: boolean = false;
@Input() darkenBackground: boolean = false; @Input() darkenBackground: boolean = false;
@Input() mode: ProgressSpinnerMode = "indeterminate"; @Input() mode: ProgressSpinnerMode = "indeterminate";
@Input() value: number | undefined; @Input() value: number | undefined;
constructor() { constructor(private changeDetector: ChangeDetectorRef) {
}
public ngOnChanges(changes: SimpleChanges): void {
if (changes["busy"]) {
this.changeDetector.markForCheck();
}
} }
public setBusy(busy: boolean) { public setBusy(busy: boolean) {
if (busy != this.busy) {
this.busy = busy; this.busy = busy;
this.changeDetector.markForCheck();
}
} }
public wrapOperation<T>(operation: Function): T | undefined { public wrapOperation<T>(operation: Function): T | undefined {
@ -36,6 +45,7 @@ export class BusyIndicatorComponent {
public async wrapAsyncOperation<T>(operation: Function): Promise<T | undefined> { public async wrapAsyncOperation<T>(operation: Function): Promise<T | undefined> {
this.setBusy(true); this.setBusy(true);
console.log("busy");
try { try {
const result = await operation(); const result = await operation();
this.setBusy(false); this.setBusy(false);
@ -44,6 +54,7 @@ export class BusyIndicatorComponent {
return undefined; return undefined;
} finally { } finally {
this.setBusy(false); this.setBusy(false);
console.log("not busy");
} }
} }
} }

@ -1,10 +1,11 @@
import {Component, ViewChild,} from "@angular/core"; import {ChangeDetectionStrategy, ChangeDetectorRef, Component, ViewChild,} from "@angular/core";
import {MatMenuTrigger} from "@angular/material/menu"; import {MatMenuTrigger} from "@angular/material/menu";
@Component({ @Component({
selector: "app-context-menu", selector: "app-context-menu",
templateUrl: "./context-menu.component.html", templateUrl: "./context-menu.component.html",
styleUrls: ["./context-menu.component.scss"] styleUrls: ["./context-menu.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class ContextMenuComponent { export class ContextMenuComponent {
@ -13,7 +14,7 @@ export class ContextMenuComponent {
@ViewChild(MatMenuTrigger) menuTrigger!: MatMenuTrigger; @ViewChild(MatMenuTrigger) menuTrigger!: MatMenuTrigger;
constructor() { constructor(private changeDetector: ChangeDetectorRef) {
} }
public onContextMenu(event: MouseEvent) { public onContextMenu(event: MouseEvent) {
@ -22,5 +23,6 @@ export class ContextMenuComponent {
this.y = event.clientY + "px"; this.y = event.clientY + "px";
this.menuTrigger.menu.focusFirstItem("mouse"); this.menuTrigger.menu.focusFirstItem("mouse");
this.menuTrigger.openMenu(); this.menuTrigger.openMenu();
this.changeDetector.markForCheck();
} }
} }

@ -0,0 +1,8 @@
import { HasPropertyPipe } from './has-property.pipe';
describe('HasPropertyPipe', () => {
it('create an instance', () => {
const pipe = new HasPropertyPipe();
expect(pipe).toBeTruthy();
});
});

@ -0,0 +1,11 @@
import {Pipe, PipeTransform} from "@angular/core";
@Pipe({
name: "hasProperty"
})
export class HasPropertyPipe implements PipeTransform {
transform(value: any, propertyName: string): unknown {
return propertyName in value;
}
}

@ -1,8 +1,8 @@
<app-image-viewer *ngIf="getContentType() === 'image' && contentUrl" [imageUrl]="contentUrl"></app-image-viewer> <app-image-viewer *ngIf="contentType === 'image' && contentUrl" [imageUrl]="contentUrl"></app-image-viewer>
<app-video-viewer *ngIf="getContentType() === 'video' && this.blobUrl" [blobUrl]="this.blobUrl!"></app-video-viewer> <app-video-viewer *ngIf="contentType === 'video' && this.blobUrl" [blobUrl]="this.blobUrl!"></app-video-viewer>
<app-audio-viewer *ngIf="getContentType() === 'audio' && this.blobUrl" [blobUrl]="this.blobUrl!"></app-audio-viewer> <app-audio-viewer *ngIf="contentType === 'audio' && this.blobUrl" [blobUrl]="this.blobUrl!"></app-audio-viewer>
<div *ngIf="getContentType() === 'other'" class="download-prompt"> <div *ngIf="contentType === 'other'" class="download-prompt">
<span>Unsupported content type <b>{{this.file.mimeType}}</b></span> <span>Unsupported content type <b>{{this.file.mimeType}}</b></span>
<button (click)="this.downloadContent()" color="primary" mat-flat-button>Download</button> <button (click)="this.downloadContent()" color="primary" mat-flat-button>Download</button>
</div> </div>

@ -1,5 +1,6 @@
import { import {
AfterViewInit, AfterViewInit,
ChangeDetectionStrategy,
Component, Component,
Input, Input,
OnChanges, OnChanges,
@ -11,25 +12,23 @@ import {SafeResourceUrl} from "@angular/platform-browser";
import {File} from "../../../../../api/models/File"; import {File} from "../../../../../api/models/File";
import {FileService} from "../../../../services/file/file.service"; import {FileService} from "../../../../services/file/file.service";
import {FileHelper} from "../../../../services/file/file.helper"; import {FileHelper} from "../../../../services/file/file.helper";
import { import {ErrorBrokerService} from "../../../../services/error-broker/error-broker.service";
ErrorBrokerService import {BusyIndicatorComponent} from "../../app-common/busy-indicator/busy-indicator.component";
} from "../../../../services/error-broker/error-broker.service";
import {
BusyIndicatorComponent
} from "../../app-common/busy-indicator/busy-indicator.component";
type ContentType = "image" | "video" | "audio" | "other"; type ContentType = "image" | "video" | "audio" | "other";
@Component({ @Component({
selector: "app-content-viewer", selector: "app-content-viewer",
templateUrl: "./content-viewer.component.html", templateUrl: "./content-viewer.component.html",
styleUrls: ["./content-viewer.component.scss"] styleUrls: ["./content-viewer.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class ContentViewerComponent implements AfterViewInit, OnChanges, OnDestroy { export class ContentViewerComponent implements AfterViewInit, OnChanges, OnDestroy {
@Input() file!: File; @Input() file!: File;
public contentUrl: SafeResourceUrl | undefined; public contentUrl: SafeResourceUrl | undefined;
public blobUrl: SafeResourceUrl | undefined; public blobUrl: SafeResourceUrl | undefined;
public contentType: ContentType = "other";
@ViewChild(BusyIndicatorComponent) busyIndicator!: BusyIndicatorComponent; @ViewChild(BusyIndicatorComponent) busyIndicator!: BusyIndicatorComponent;
@ -40,7 +39,8 @@ export class ContentViewerComponent implements AfterViewInit, OnChanges, OnDestr
} }
public async ngAfterViewInit() { public async ngAfterViewInit() {
if (["audio", "video"].includes(this.getContentType())) { this.contentType = this.getContentType();
if (["audio", "video"].includes(this.contentType)) {
await this.loadBlobUrl(); await this.loadBlobUrl();
} else { } else {
this.contentUrl = this.fileService.buildContentUrl(this.file); this.contentUrl = this.fileService.buildContentUrl(this.file);
@ -49,8 +49,9 @@ export class ContentViewerComponent implements AfterViewInit, OnChanges, OnDestr
public async ngOnChanges(changes: SimpleChanges) { public async ngOnChanges(changes: SimpleChanges) {
if (changes["file"]) { if (changes["file"]) {
if (["audio", "video"].includes( this.contentType = this.getContentType();
this.getContentType()) && this.busyIndicator) {
if (["audio", "video"].includes(this.contentType) && this.busyIndicator) {
await this.loadBlobUrl(); await this.loadBlobUrl();
} else { } else {
this.contentUrl = this.fileService.buildContentUrl(this.file); this.contentUrl = this.fileService.buildContentUrl(this.file);
@ -63,23 +64,6 @@ export class ContentViewerComponent implements AfterViewInit, OnChanges, OnDestr
this.unloadBlobUrl(); this.unloadBlobUrl();
} }
public getContentType(): ContentType {
let mimeParts = this.file.mimeType.split("/");
const type = mimeParts.shift() ?? "other";
const subtype = mimeParts.shift() ?? "*";
switch (type) {
case "image":
return "image";
case "video":
return "video";
case "audio":
return "audio";
default:
return "other";
}
}
public async downloadContent() { public async downloadContent() {
const path = await FileHelper.getFileDownloadLocation(this.file); const path = await FileHelper.getFileDownloadLocation(this.file);
@ -103,6 +87,22 @@ export class ContentViewerComponent implements AfterViewInit, OnChanges, OnDestr
}); });
} }
private getContentType(): ContentType {
let mimeParts = this.file.mimeType.split("/");
const type = mimeParts.shift() ?? "other";
switch (type) {
case "image":
return "image";
case "video":
return "video";
case "audio":
return "audio";
default:
return "other";
}
}
private unloadBlobUrl() { private unloadBlobUrl() {
if (this.blobUrl) { if (this.blobUrl) {
URL?.revokeObjectURL(this.blobUrl as string); URL?.revokeObjectURL(this.blobUrl as string);

@ -1,8 +1,11 @@
<mat-card #card (click)="clickEvent.emit(this)" (dblclick)="dblClickEvent.emit(this)" <mat-card #card (click)="clickEvent.emit(this)" (dblclick)="dblClickEvent.emit(this)"
[ngClass]="{'selected': entry.selected}"> [class.selected]="this.entry.selected | async">
<mat-card-content> <mat-card-content>
<app-busy-indicator [busy]="this.loading"> <app-busy-indicator [busy]="this.loading">
<app-file-thumbnail *ngIf="!loading" [file]="this.entry.data" class=".entry-image"></app-file-thumbnail> <app-file-thumbnail *ngIf="!loading"
[fileChanged]="this.fileChanged"
[file]="this.entry.data"
class=".entry-image"></app-file-thumbnail>
</app-busy-indicator> </app-busy-indicator>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>

@ -1,4 +1,6 @@
import { import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component, Component,
ElementRef, ElementRef,
EventEmitter, EventEmitter,
@ -12,28 +14,29 @@ import {
} from "@angular/core"; } from "@angular/core";
import {File} from "../../../../../api/models/File"; import {File} from "../../../../../api/models/File";
import {Selectable} from "../../../../models/Selectable"; import {Selectable} from "../../../../models/Selectable";
import { import {SchedulingService} from "../../../../services/scheduling/scheduling.service";
SchedulingService import {BehaviorSubject} from "rxjs";
} from "../../../../services/scheduling/scheduling.service";
const LOADING_WORK_KEY = "FILE_THUMBNAIL_LOADING"; const LOADING_WORK_KEY = "FILE_THUMBNAIL_LOADING";
@Component({ @Component({
selector: "app-file-card", selector: "app-file-card",
templateUrl: "./file-card.component.html", templateUrl: "./file-card.component.html",
styleUrls: ["./file-card.component.scss"] styleUrls: ["./file-card.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class FileCardComponent implements OnInit, OnChanges, OnDestroy { export class FileCardComponent implements OnInit, OnChanges, OnDestroy {
@ViewChild("card") card!: ElementRef; @ViewChild("card") card!: ElementRef;
@Input() public entry!: Selectable<File>; @Input() public entry!: Selectable<File>;
@Input() public fileChanged: BehaviorSubject<void> = new BehaviorSubject<void>(undefined);
@Output() clickEvent = new EventEmitter<FileCardComponent>(); @Output() clickEvent = new EventEmitter<FileCardComponent>();
@Output() dblClickEvent = new EventEmitter<FileCardComponent>(); @Output() dblClickEvent = new EventEmitter<FileCardComponent>();
public loading = false;
private cachedId: number | undefined; private cachedId: number | undefined;
private workId: number | undefined; private workId: number | undefined;
public loading = false;
constructor(private schedulingService: SchedulingService) { constructor(private changeDetector: ChangeDetectorRef, private schedulingService: SchedulingService) {
} }
async ngOnInit() { async ngOnInit() {
@ -42,10 +45,13 @@ export class FileCardComponent implements OnInit, OnChanges, OnDestroy {
} }
async ngOnChanges(changes: SimpleChanges) { async ngOnChanges(changes: SimpleChanges) {
if (changes["file"] && (this.cachedId === undefined || this.entry.data.id !== this.cachedId)) { if (changes["entry"] && (this.cachedId === undefined || this.entry.data.id !== this.cachedId)) {
this.cachedId = this.entry.data.id; this.cachedId = this.entry.data.id;
this.setImageDelayed(); this.setImageDelayed();
} }
if (changes["fileChanged"]) {
this.fileChanged.subscribe(() => this.changeDetector.markForCheck());
}
} }
public ngOnDestroy(): void { public ngOnDestroy(): void {
@ -59,10 +65,13 @@ export class FileCardComponent implements OnInit, OnChanges, OnDestroy {
this.schedulingService.cancelWork(LOADING_WORK_KEY, this.workId); this.schedulingService.cancelWork(LOADING_WORK_KEY, this.workId);
} }
this.loading = true; this.loading = true;
this.workId = this.schedulingService.addWork(LOADING_WORK_KEY, this.workId = this.schedulingService.addWork(
LOADING_WORK_KEY,
async () => { async () => {
await this.schedulingService.delay(1); await this.schedulingService.delay(1);
this.loading = false; this.loading = false;
}); this.changeDetector.markForCheck();
}
);
} }
} }

@ -1,12 +1,12 @@
<app-context-menu #contextMenu> <app-context-menu #contextMenu>
<ng-content select="[content-before]"></ng-content> <ng-content select="[content-before]"></ng-content>
<ng-container *ngIf="this.files"> <ng-container *ngIf="this.files">
<button (click)="this.updateStatus(this.files, 'Archived')" *ngIf="actionArchive" mat-menu-item>Archive <button (click)="this.changeFileStatus('Archived')" *ngIf="actionArchive" mat-menu-item>Archive
</button> </button>
<button (click)="this.updateStatus(this.files, 'Imported')" *ngIf="actionImported" mat-menu-item>Back to <button (click)="this.changeFileStatus('Imported')" *ngIf="actionImported" mat-menu-item>Back to
imported imported
</button> </button>
<button (click)="this.updateStatus(this.files, 'Deleted')" <button (click)="this.changeFileStatus('Deleted')"
*ngIf="actionDelete" *ngIf="actionDelete"
mat-menu-item>Delete mat-menu-item>Delete
</button> </button>
@ -14,7 +14,7 @@
*ngIf="actionDeletePermantently" *ngIf="actionDeletePermantently"
mat-menu-item>Delete permanently mat-menu-item>Delete permanently
</button> </button>
<button (click)="this.updateStatus(this.files, 'Archived')" *ngIf="actionRestore" mat-menu-item>Restore</button> <button (click)="this.changeFileStatus('Archived')" *ngIf="actionRestore" mat-menu-item>Restore</button>
<!-- everything that only applies to a single file --> <!-- everything that only applies to a single file -->
<ng-container> <ng-container>

@ -7,6 +7,7 @@ import {MatDialog, MatDialogRef} from "@angular/material/dialog";
import {BusyDialogComponent} from "../../app-common/busy-dialog/busy-dialog.component"; import {BusyDialogComponent} from "../../app-common/busy-dialog/busy-dialog.component";
import {BehaviorSubject} from "rxjs"; import {BehaviorSubject} from "rxjs";
import {FileActionBaseComponent} from "../../app-base/file-action-base/file-action-base.component"; import {FileActionBaseComponent} from "../../app-base/file-action-base/file-action-base.component";
import {FileStatus} from "../../../../../api/api-types/files";
type ProgressDialogContext = { type ProgressDialogContext = {
dialog: MatDialogRef<BusyDialogComponent>, dialog: MatDialogRef<BusyDialogComponent>,
@ -31,6 +32,7 @@ export class FileContextMenuComponent extends FileActionBaseComponent implements
@ViewChild("contextMenu") contextMenu!: ContextMenuComponent; @ViewChild("contextMenu") contextMenu!: ContextMenuComponent;
@Output() fileDeleted = new EventEmitter<File[]>(); @Output() fileDeleted = new EventEmitter<File[]>();
@Output() fileStatusChange = new EventEmitter<File[]>();
constructor(fileService: FileService, errorBroker: ErrorBrokerService, dialog: MatDialog) { constructor(fileService: FileService, errorBroker: ErrorBrokerService, dialog: MatDialog) {
super(dialog, errorBroker, fileService); super(dialog, errorBroker, fileService);
@ -56,6 +58,11 @@ export class FileContextMenuComponent extends FileActionBaseComponent implements
} }
} }
public async changeFileStatus(status: FileStatus) {
await this.updateStatus(this.files, status);
this.fileStatusChange.emit(this.files);
}
private applyStatus() { private applyStatus() {
this.actionDeletePermantently = true; this.actionDeletePermantently = true;
this.actionDelete = this.actionArchive = this.actionImported = this.actionRestore = false; this.actionDelete = this.actionArchive = this.actionImported = this.actionRestore = false;

@ -13,11 +13,13 @@
<div class="file-scroll-view" fxFlex="20%"> <div class="file-scroll-view" fxFlex="20%">
<cdk-virtual-scroll-viewport #virtualScroll class="file-scroll-viewport" itemSize="260" maxBufferPx="3000" <cdk-virtual-scroll-viewport #virtualScroll class="file-scroll-viewport" itemSize="260" maxBufferPx="3000"
minBufferPx="1000" orientation="horizontal"> minBufferPx="1000" orientation="horizontal">
<div *cdkVirtualFor="let entry of entries" class="file-item"> <div *cdkVirtualFor="let entry of entries; trackBy: trackByFileId" class="file-item">
<app-file-card (clickEvent)="onEntrySelect($event.entry)" <app-file-card (clickEvent)="onEntrySelect($event.entry)"
[entry]="entry"></app-file-card> [entry]="entry" [fileChanged]="this.fileChanged"></app-file-card>
</div> </div>
</cdk-virtual-scroll-viewport> </cdk-virtual-scroll-viewport>
</div> </div>
</div> </div>
<app-file-context-menu #fileContextMenu (fileDeleted)="this.fileDeleted.emit($event)"></app-file-context-menu> <app-file-context-menu #fileContextMenu
(fileDeleted)="this.fileDeleted.emit($event)"
(fileStatusChange)="this.onFileStatusChange()"></app-file-context-menu>

@ -17,6 +17,7 @@ import {Selectable} from "../../../../../models/Selectable";
import {CdkVirtualScrollViewport} from "@angular/cdk/scrolling"; import {CdkVirtualScrollViewport} from "@angular/cdk/scrolling";
import {TabService} from "../../../../../services/tab/tab.service"; import {TabService} from "../../../../../services/tab/tab.service";
import {Key} from "w3c-keys"; import {Key} from "w3c-keys";
import {BehaviorSubject} from "rxjs";
@Component({ @Component({
selector: "app-file-gallery", selector: "app-file-gallery",
@ -40,11 +41,15 @@ export class FileGalleryComponent implements OnChanges, OnInit, AfterViewInit {
public entries: Selectable<File>[] = []; public entries: Selectable<File>[] = [];
public selectedFile: Selectable<File> | undefined; public selectedFile: Selectable<File> | undefined;
public fileContentUrl: SafeResourceUrl | undefined; public fileContentUrl: SafeResourceUrl | undefined;
public fileChanged = new BehaviorSubject<void>(undefined);
private scrollTimeout: number | undefined; private scrollTimeout: number | undefined;
private escapeCount = 0; private escapeCount = 0;
constructor(private tabService: TabService, private fileService: FileService) { constructor(
private tabService: TabService,
private fileService: FileService
) {
tabService.selectedTab.subscribe(() => this.adjustElementSizes()); tabService.selectedTab.subscribe(() => this.adjustElementSizes());
} }
@ -173,6 +178,14 @@ export class FileGalleryComponent implements OnChanges, OnInit, AfterViewInit {
} }
} }
public trackByFileId(index: number, item: Selectable<File>) {
return item.data.id;
}
public onFileStatusChange(): void {
this.fileChanged.next();
}
private scrollToSelection(): void { private scrollToSelection(): void {
if (this.selectedFile) { if (this.selectedFile) {
const selectedIndex = this.entries.indexOf(this.selectedFile); const selectedIndex = this.entries.indexOf(this.selectedFile);
@ -190,7 +203,6 @@ export class FileGalleryComponent implements OnChanges, OnInit, AfterViewInit {
} }
} }
private getPreselectedEntry(): Selectable<File> | undefined { private getPreselectedEntry(): Selectable<File> | undefined {
if (this.preselectedFile) { if (this.preselectedFile) {
const entry = this.entries.find( const entry = this.entries.find(

@ -5,20 +5,20 @@
class="file-gallery-inner"> class="file-gallery-inner">
<cdk-virtual-scroll-viewport #virtualScrollGrid class="file-scroll" itemSize="260" maxBufferPx="2000" <cdk-virtual-scroll-viewport #virtualScrollGrid class="file-scroll" itemSize="260" maxBufferPx="2000"
minBufferPx="500"> minBufferPx="500">
<div *cdkVirtualFor="let rowEntry of partitionedGridEntries"> <div *cdkVirtualFor="let rowEntry of partitionedGridEntries; trackByFileRowId">
<div class="file-row"> <div class="file-row">
<app-file-card <app-file-card
(clickEvent)="setSelectedFile($event.entry)" (clickEvent)="setSelectedFile($event.entry)"
(contextmenu)="this.selectEntryWhenNotSelected(gridEntry); fileContextMenu.onContextMenu($event, this.getSelectedFiles())" (contextmenu)="this.selectEntryWhenNotSelected(gridEntry); fileContextMenu.onContextMenu($event, this.getSelectedFiles())"
(dblClickEvent)="fileOpen.emit($event.entry.data)" (dblClickEvent)="fileOpen.emit($event.entry.data)"
*ngFor="let gridEntry of rowEntry" *ngFor="let gridEntry of rowEntry; trackBy: trackByFileId"
[entry]="gridEntry"></app-file-card> [entry]="gridEntry" [fileChanged]="this.fileChanged"></app-file-card>
</div> </div>
</div> </div>
</cdk-virtual-scroll-viewport> </cdk-virtual-scroll-viewport>
</div> </div>
<app-file-context-menu #fileContextMenu (fileDeleted)="this.fileDeleted.emit($event)"> <app-file-context-menu #fileContextMenu (fileDeleted)="this.fileDeleted.emit($event)" (fileStatusChange)="this.onFileStatusChange()">
<button (click)="this.fileOpen.emit(fileContextMenu.files[0])" <button (click)="this.fileOpen.emit(fileContextMenu.files[0])"
*ngIf="fileContextMenu.files.length === 1" *ngIf="fileContextMenu.files.length === 1"
content-before="" content-before=""

@ -1,5 +1,6 @@
import { import {
AfterViewInit, AfterViewInit,
ChangeDetectionStrategy,
Component, Component,
ElementRef, ElementRef,
EventEmitter, EventEmitter,
@ -17,11 +18,13 @@ import {TabService} from "../../../../../services/tab/tab.service";
import {FileService} from "../../../../../services/file/file.service"; import {FileService} from "../../../../../services/file/file.service";
import {Selectable} from "../../../../../models/Selectable"; import {Selectable} from "../../../../../models/Selectable";
import {Key} from "w3c-keys"; import {Key} from "w3c-keys";
import {BehaviorSubject} from "rxjs";
@Component({ @Component({
selector: "app-file-grid", selector: "app-file-grid",
templateUrl: "./file-grid.component.html", templateUrl: "./file-grid.component.html",
styleUrls: ["./file-grid.component.scss"] styleUrls: ["./file-grid.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class FileGridComponent implements OnChanges, OnInit, AfterViewInit { export class FileGridComponent implements OnChanges, OnInit, AfterViewInit {
@ -36,8 +39,10 @@ export class FileGridComponent implements OnChanges, OnInit, AfterViewInit {
@ViewChild("virtualScrollGrid") virtualScroll!: CdkVirtualScrollViewport; @ViewChild("virtualScrollGrid") virtualScroll!: CdkVirtualScrollViewport;
@ViewChild("inner") inner!: ElementRef<HTMLDivElement>; @ViewChild("inner") inner!: ElementRef<HTMLDivElement>;
selectedEntries: Selectable<File>[] = []; public fileChanged = new BehaviorSubject<void>(undefined);
partitionedGridEntries: Selectable<File>[][] = []; public selectedEntries: Selectable<File>[] = [];
public partitionedGridEntries: Selectable<File>[][] = [];
private shiftClicked = false; private shiftClicked = false;
private ctrlClicked = false; private ctrlClicked = false;
private gridEntries: Selectable<File>[] = []; private gridEntries: Selectable<File>[] = [];
@ -75,14 +80,14 @@ export class FileGridComponent implements OnChanges, OnInit, AfterViewInit {
setSelectedFile(clickedEntry: Selectable<File>) { setSelectedFile(clickedEntry: Selectable<File>) {
if (!(this.shiftClicked || this.ctrlClicked) && this.selectedEntries.length > 0) { if (!(this.shiftClicked || this.ctrlClicked) && this.selectedEntries.length > 0) {
this.selectedEntries.forEach(entry => { this.selectedEntries.forEach(entry => {
if (entry !== clickedEntry) entry.selected = false; if (entry !== clickedEntry) entry.unselect();
}); });
this.selectedEntries = []; this.selectedEntries = [];
} }
if (this.shiftClicked && this.selectedEntries.length > 0) { if (this.shiftClicked && this.selectedEntries.length > 0) {
this.handleShiftSelect(clickedEntry); this.handleShiftSelect(clickedEntry);
} else { } else {
clickedEntry.selected = !clickedEntry.selected; clickedEntry.selected.next(!clickedEntry.selected.value);
if (!clickedEntry.selected) { if (!clickedEntry.selected) {
const index = this.selectedEntries.indexOf(clickedEntry); const index = this.selectedEntries.indexOf(clickedEntry);
if (index > -1) { if (index > -1) {
@ -169,6 +174,18 @@ export class FileGridComponent implements OnChanges, OnInit, AfterViewInit {
this.ctrlClicked = event.ctrlKey ? false : this.ctrlClicked; this.ctrlClicked = event.ctrlKey ? false : this.ctrlClicked;
} }
public trackByFileRowId(index: number, item: Selectable<File>[]) {
return item.map(e => e.data.id).join("-");
}
public trackByFileId(index: number, item: Selectable<File>) {
return item.data.id;
}
public onFileStatusChange(): void {
this.fileChanged.next();
}
private setPartitionedGridEntries() { private setPartitionedGridEntries() {
this.partitionedGridEntries = []; this.partitionedGridEntries = [];
let scrollToIndex = -1; let scrollToIndex = -1;
@ -196,7 +213,7 @@ export class FileGridComponent implements OnChanges, OnInit, AfterViewInit {
this.virtualScroll?.scrollToIndex(scrollToIndex); this.virtualScroll?.scrollToIndex(scrollToIndex);
if (selectedEntry) { if (selectedEntry) {
selectedEntry.selected = true; selectedEntry.select();
this.selectedEntries.push(selectedEntry); this.selectedEntries.push(selectedEntry);
} }
} }
@ -208,7 +225,7 @@ export class FileGridComponent implements OnChanges, OnInit, AfterViewInit {
const newSelection: Selectable<File>[] = this.gridEntries.filter( const newSelection: Selectable<File>[] = this.gridEntries.filter(
entry => this.selectedEntries.findIndex( entry => this.selectedEntries.findIndex(
e => e.data.id == entry.data.id) >= 0); e => e.data.id == entry.data.id) >= 0);
newSelection.forEach(entry => entry.selected = true); newSelection.forEach(entry => entry.select());
this.selectedEntries = newSelection; this.selectedEntries = newSelection;
} }
@ -223,7 +240,7 @@ export class FileGridComponent implements OnChanges, OnInit, AfterViewInit {
for (const gridEntry of this.gridEntries) { for (const gridEntry of this.gridEntries) {
if (found) { if (found) {
gridEntry.selected = true; gridEntry.select();
this.selectedEntries.push(gridEntry); this.selectedEntries.push(gridEntry);
if (gridEntry === clickedEntry || gridEntry == lastEntry) { if (gridEntry === clickedEntry || gridEntry == lastEntry) {
return; return;
@ -231,7 +248,7 @@ export class FileGridComponent implements OnChanges, OnInit, AfterViewInit {
} else if (gridEntry === lastEntry || gridEntry === clickedEntry) { } else if (gridEntry === lastEntry || gridEntry === clickedEntry) {
found = true; found = true;
if (gridEntry === clickedEntry) { if (gridEntry === clickedEntry) {
gridEntry.selected = true; gridEntry.select();
this.selectedEntries.push(gridEntry); this.selectedEntries.push(gridEntry);
} }
} }

@ -1,14 +1,14 @@
<app-content-aware-image *ngIf="this.getThumbnailSupported() && this.thumbUrl" [imageSrc]="this.thumbUrl" <app-content-aware-image *ngIf="this.thumbnailSupported && this.thumbUrl" [imageSrc]="this.thumbUrl"
borderRadius="0.25em"></app-content-aware-image> borderRadius="0.25em"></app-content-aware-image>
<div *ngIf="this.getThumbnailSupported() && this.thumbUrl" class="file-icon-overlay"> <div *ngIf="this.thumbnailSupported && this.thumbUrl" class="file-icon-overlay">
<ng-icon *ngIf="getFileType() === 'video'" name="mat-movie"></ng-icon> <ng-icon *ngIf="fileType === 'video'" name="mat-movie"></ng-icon>
<ng-icon *ngIf="this.file.mimeType === 'image/gif'" class="gif-icon" name="mat-gif"></ng-icon> <ng-icon *ngIf="this.file.mimeType === 'image/gif'" class="gif-icon" name="mat-gif"></ng-icon>
</div> </div>
<div *ngIf="!this.getThumbnailSupported() || !this.thumbUrl" class="file-type-icon"> <div *ngIf="!this.thumbnailSupported || !this.thumbUrl" class="file-type-icon">
<ng-icon *ngIf="getFileType() === 'image'" name="mat-image"></ng-icon> <ng-icon *ngIf="fileType === 'image'" name="mat-image"></ng-icon>
<ng-icon *ngIf="getFileType() === 'video'" name="mat-movie"></ng-icon> <ng-icon *ngIf="fileType === 'video'" name="mat-movie"></ng-icon>
<ng-icon *ngIf="getFileType() === 'audio'" name="mat-audiotrack"></ng-icon> <ng-icon *ngIf="fileType === 'audio'" name="mat-audiotrack"></ng-icon>
<ng-icon *ngIf="getFileType() === 'text'" name="mat-description"></ng-icon> <ng-icon *ngIf="fileType === 'text'" name="mat-description"></ng-icon>
</div> </div>
<div *ngIf="file.status !== 'Archived'" class="file-status-icon"> <div *ngIf="file.status !== 'Archived'" class="file-status-icon">
<ng-icon *ngIf="file.status === 'Deleted'" name="mat-auto-delete"></ng-icon> <ng-icon *ngIf="file.status === 'Deleted'" name="mat-auto-delete"></ng-icon>

@ -1,45 +1,74 @@
import {AfterViewInit, Component, Input, OnChanges, SimpleChanges} from "@angular/core"; import {
AfterViewChecked,
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Input,
OnChanges,
SimpleChanges
} from "@angular/core";
import {File} from "../../../../../api/models/File"; import {File} from "../../../../../api/models/File";
import {FileService} from "../../../../services/file/file.service"; import {FileService} from "../../../../services/file/file.service";
import {FileHelper} from "../../../../services/file/file.helper"; import {FileHelper} from "../../../../services/file/file.helper";
import {SafeResourceUrl} from "@angular/platform-browser"; import {SafeResourceUrl} from "@angular/platform-browser";
import {BehaviorSubject} from "rxjs";
@Component({ @Component({
selector: "app-file-thumbnail", selector: "app-file-thumbnail",
templateUrl: "./file-thumbnail.component.html", templateUrl: "./file-thumbnail.component.html",
styleUrls: ["./file-thumbnail.component.scss"] styleUrls: ["./file-thumbnail.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class FileThumbnailComponent implements OnChanges, AfterViewInit { export class FileThumbnailComponent implements OnChanges, AfterViewInit, AfterViewChecked {
@Input() file!: File; @Input() file!: File;
@Input() public fileChanged: BehaviorSubject<void> = new BehaviorSubject<void>(undefined);
public thumbUrl: SafeResourceUrl | undefined; public thumbUrl: SafeResourceUrl | undefined;
public fileType!: string;
public thumbnailSupported: boolean = false;
private supportedThumbnailTypes = ["image", "video"]; private supportedThumbnailTypes = ["image", "video"];
private previousStatus = "imported";
constructor(private fileService: FileService) { constructor(private changeDetector: ChangeDetectorRef, private fileService: FileService) {
} }
public async ngAfterViewInit() { public async ngAfterViewInit() {
if (this.thumbnailSupported) {
this.thumbUrl = this.fileService.buildThumbnailUrl(this.file, 250, 250); this.thumbUrl = this.fileService.buildThumbnailUrl(this.file, 250, 250);
} }
}
public ngAfterViewChecked(): void {
if (this.file && this.file.status != this.previousStatus) {
this.previousStatus = this.file.status;
this.changeDetector.markForCheck();
}
}
public async ngOnChanges(changes: SimpleChanges) { public async ngOnChanges(changes: SimpleChanges) {
if (changes["file"]) { if (changes["file"]) {
this.thumbUrl = this.fileService.buildThumbnailUrl(this.file, this.thumbUrl = this.fileService.buildThumbnailUrl(this.file,
250, 250 250, 250
); );
this.fileType = this.getFileType();
this.thumbnailSupported = this.getThumbnailSupported();
}
if (changes["fileChanged"]) {
this.fileChanged.subscribe(() => this.changeDetector.markForCheck());
} }
} }
public getThumbnailSupported(): boolean { private getThumbnailSupported(): boolean {
const mimeParts = FileHelper.parseMime(this.file.mimeType); const mimeParts = FileHelper.parseMime(this.file.mimeType);
return !!mimeParts && this.supportedThumbnailTypes.includes( return !!mimeParts && this.supportedThumbnailTypes.includes(
mimeParts[0]); mimeParts[0]);
} }
public getFileType(): string { private getFileType(): string {
const mimeParts = FileHelper.parseMime(this.file.mimeType); const mimeParts = FileHelper.parseMime(this.file.mimeType);
return (mimeParts && mimeParts[0]) ?? "other"; return (mimeParts && mimeParts[0]) ?? "other";
} }

@ -1,4 +1,4 @@
import {Component, EventEmitter, Input, OnChanges, Output, SimpleChanges} from "@angular/core"; import {ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output, SimpleChanges} from "@angular/core";
import {Observable} from "rxjs"; import {Observable} from "rxjs";
import {FormControl} from "@angular/forms"; import {FormControl} from "@angular/forms";
import {Tag} from "../../../../../api/models/Tag"; import {Tag} from "../../../../../api/models/Tag";
@ -15,7 +15,8 @@ type AutocompleteEntry = {
@Component({ @Component({
selector: "app-filter-input", selector: "app-filter-input",
templateUrl: "./filter-input.component.html", templateUrl: "./filter-input.component.html",
styleUrls: ["./filter-input.component.scss"] styleUrls: ["./filter-input.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class FilterInputComponent implements OnChanges { export class FilterInputComponent implements OnChanges {

@ -3,15 +3,21 @@
<h1>File Metadata</h1> <h1>File Metadata</h1>
<mat-divider></mat-divider> <mat-divider></mat-divider>
</div> </div>
<app-busy-indicator [blurBackground]="true" [busy]="this.loading" [darkenBackground]="false"> <app-busy-indicator [blurBackground]="true" [darkenBackground]="false">
<div class="file-metadata-entries-scroll-container"> <div class="file-metadata-entries-scroll-container">
<div class="file-metadata-entries"> <div class="file-metadata-entries">
<app-editable-metadata-entry *ngIf="fileMetadata" attributeName="Name" [value]="fileMetadata.name ?? ''" (valueChangeEvent)="this.saveFileName($event)"></app-editable-metadata-entry> <app-editable-metadata-entry (valueChangeEvent)="this.saveFileName($event)"
*ngIf="fileMetadata"
[value]="fileMetadata.name ?? ''"
attributeName="Name"></app-editable-metadata-entry>
<app-metadata-entry attributeName="Content Descriptor (CD)">{{file.cd}}</app-metadata-entry> <app-metadata-entry attributeName="Content Descriptor (CD)">{{file.cd}}</app-metadata-entry>
<app-metadata-entry attributeName="Mime Type">{{file.mimeType}}</app-metadata-entry> <app-metadata-entry attributeName="Mime Type">{{file.mimeType}}</app-metadata-entry>
<app-metadata-entry *ngIf="fileMetadata" attributeName="Imported at">{{fileMetadata.import_time.toLocaleString()}}</app-metadata-entry> <app-metadata-entry *ngIf="fileMetadata"
<app-metadata-entry *ngIf="fileMetadata" attributeName="Created at">{{fileMetadata.creation_time.toLocaleString()}}</app-metadata-entry> attributeName="Imported at">{{fileMetadata.import_time.toLocaleString()}}</app-metadata-entry>
<app-metadata-entry *ngIf="fileMetadata" attributeName="Changed at">{{fileMetadata.change_time.toLocaleString()}}</app-metadata-entry> <app-metadata-entry *ngIf="fileMetadata"
attributeName="Created at">{{fileMetadata.creation_time.toLocaleString()}}</app-metadata-entry>
<app-metadata-entry *ngIf="fileMetadata"
attributeName="Changed at">{{fileMetadata.change_time.toLocaleString()}}</app-metadata-entry>
</div> </div>
</div> </div>
</app-busy-indicator> </app-busy-indicator>

@ -1,48 +1,45 @@
import { import {ChangeDetectionStrategy, Component, Input, OnChanges, OnInit, SimpleChanges, ViewChild} from "@angular/core";
Component,
Input,
OnChanges,
OnInit,
SimpleChanges
} from "@angular/core";
import {File} from "../../../../../api/models/File"; import {File} from "../../../../../api/models/File";
import {FileService} from "../../../../services/file/file.service"; import {FileService} from "../../../../services/file/file.service";
import {FileMetadata} from "../../../../../api/api-types/files"; import {FileMetadata} from "../../../../../api/api-types/files";
import {BusyIndicatorComponent} from "../../app-common/busy-indicator/busy-indicator.component";
@Component({ @Component({
selector: "app-file-metadata", selector: "app-file-metadata",
templateUrl: "./file-metadata.component.html", templateUrl: "./file-metadata.component.html",
styleUrls: ["./file-metadata.component.scss"] styleUrls: ["./file-metadata.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class FileMetadataComponent implements OnInit, OnChanges { export class FileMetadataComponent implements OnInit, OnChanges {
@Input() file!: File; @Input() file!: File;
public fileMetadata: FileMetadata | undefined; public fileMetadata: FileMetadata | undefined;
public loading = false;
@ViewChild(BusyIndicatorComponent) busyIndicator!: BusyIndicatorComponent;
constructor(private fileService: FileService) { constructor(private fileService: FileService) {
} }
public async ngOnInit() { public async ngOnInit() {
this.loading = true; await this.busyIndicator.wrapAsyncOperation(async () => {
this.fileMetadata = await this.fileService.getFileMetadata(this.file.id); this.fileMetadata = await this.fileService.getFileMetadata(this.file.id);
this.loading = false; });
} }
public async ngOnChanges(changes: SimpleChanges) { public async ngOnChanges(changes: SimpleChanges) {
if (changes["file"] && (!this.fileMetadata || this.fileMetadata.file_id != this.file.id)) { if (changes["file"] && (!this.fileMetadata || this.fileMetadata.file_id != this.file.id)) {
this.loading = true; await this.busyIndicator.wrapAsyncOperation(async () => {
this.fileMetadata = await this.fileService.getFileMetadata(this.file.id); this.fileMetadata = await this.fileService.getFileMetadata(this.file.id);
this.loading = false; });
} }
} }
public async saveFileName(name: string) { public async saveFileName(name: string) {
this.loading = true; await this.busyIndicator.wrapAsyncOperation(async () => {
const newFile = await this.fileService.updateFileName(this.file.id, name); const newFile = await this.fileService.updateFileName(this.file.id, name);
if (this.fileMetadata) { if (this.fileMetadata) {
this.fileMetadata.name = newFile.name; this.fileMetadata.name = newFile.name;
} }
this.loading = false; });
} }
} }

@ -50,7 +50,7 @@
<cdk-virtual-scroll-viewport itemSize="50" maxBufferPx="4000" minBufferPx="500"> <cdk-virtual-scroll-viewport itemSize="50" maxBufferPx="4000" minBufferPx="500">
<div (click)="addTagFilter(tag.getNormalizedOutput())" <div (click)="addTagFilter(tag.getNormalizedOutput())"
(contextmenu)="contextMenuTag = tag; contextMenu.onContextMenu($event)" (contextmenu)="contextMenuTag = tag; contextMenu.onContextMenu($event)"
*cdkVirtualFor="let tag of contextTags" class="selectable-tag"> *cdkVirtualFor="let tag of contextTags; trackBy: trackByTagId" class="selectable-tag">
<app-tag-item [tag]="tag"></app-tag-item> <app-tag-item [tag]="tag"></app-tag-item>
</div> </div>
</cdk-virtual-scroll-viewport> </cdk-virtual-scroll-viewport>

@ -1,4 +1,14 @@
import {AfterViewChecked, Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild} from "@angular/core"; import {
AfterViewChecked,
ChangeDetectionStrategy,
Component,
ElementRef,
EventEmitter,
Input,
OnInit,
Output,
ViewChild
} from "@angular/core";
import {SortKey} from "../../../../models/SortKey"; import {SortKey} from "../../../../models/SortKey";
import {MatDialog} from "@angular/material/dialog"; import {MatDialog} from "@angular/material/dialog";
import {SortDialogComponent} from "./sort-dialog/sort-dialog.component"; import {SortDialogComponent} from "./sort-dialog/sort-dialog.component";
@ -18,7 +28,8 @@ import * as deepEqual from "fast-deep-equal";
@Component({ @Component({
selector: "app-file-search", selector: "app-file-search",
templateUrl: "./file-search.component.html", templateUrl: "./file-search.component.html",
styleUrls: ["./file-search.component.scss"] styleUrls: ["./file-search.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class FileSearchComponent implements AfterViewChecked, OnInit { export class FileSearchComponent implements AfterViewChecked, OnInit {
public sortExpression: SortKey[] = []; public sortExpression: SortKey[] = [];
@ -59,7 +70,6 @@ export class FileSearchComponent implements AfterViewChecked, OnInit {
}); });
this.state.sortKeys.subscribe(s => this.sortExpression = s); this.state.sortKeys.subscribe(s => this.sortExpression = s);
this.applyStatusFromFilters(); this.applyStatusFromFilters();
await this.searchForFiles();
this.needsScroll = true; this.needsScroll = true;
this.assignDisplayedFilters(); this.assignDisplayedFilters();
} }
@ -187,6 +197,10 @@ export class FileSearchComponent implements AfterViewChecked, OnInit {
return deepEqual(tagFilter, filter); return deepEqual(tagFilter, filter);
} }
public trackByTagId(index: number, item: Tag) {
return item.id;
}
private assignDisplayedFilters() { private assignDisplayedFilters() {
this.displayedFilters = this.filters.getFilters().filter(f => !this.isTagFilter(f)); this.displayedFilters = this.filters.getFilters().filter(f => !this.isTagFilter(f));
} }

@ -1,4 +1,4 @@
import {Component, Inject, OnChanges, SimpleChanges} from "@angular/core"; import {ChangeDetectionStrategy, Component, Inject, OnChanges, SimpleChanges} from "@angular/core";
import {MAT_DIALOG_DATA, MatDialogRef} from "@angular/material/dialog"; import {MAT_DIALOG_DATA, MatDialogRef} from "@angular/material/dialog";
import {SortDialogComponent} from "../sort-dialog/sort-dialog.component"; import {SortDialogComponent} from "../sort-dialog/sort-dialog.component";
import {Tag} from "../../../../../../api/models/Tag"; import {Tag} from "../../../../../../api/models/Tag";
@ -13,7 +13,8 @@ type IndexableSelection<T> = {
@Component({ @Component({
selector: "app-filter-dialog", selector: "app-filter-dialog",
templateUrl: "./filter-dialog.component.html", templateUrl: "./filter-dialog.component.html",
styleUrls: ["./filter-dialog.component.scss"] styleUrls: ["./filter-dialog.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class FilterDialogComponent implements OnChanges { export class FilterDialogComponent implements OnChanges {
public availableTags: Tag[] = []; public availableTags: Tag[] = [];

@ -7,10 +7,10 @@
<app-selectable #componentSelectable <app-selectable #componentSelectable
(appSelect)="this.entrySelect.emit(entry)" (appSelect)="this.entrySelect.emit(entry)"
(appUnselect)="this.entryUnselect.emit(entry)"> (appUnselect)="this.entryUnselect.emit(entry)">
<app-property-query-item *ngIf="this.queryIs(entry[1], 'Property')" <app-property-query-item *ngIf="entry[1] | hasProperty: 'Property'"
[propertyQuery]="this.propertyQuery(entry[1]).Property"></app-property-query-item> [propertyQuery]="entry[1] | getPropertyQuery"></app-property-query-item>
<app-tag-query-item *ngIf="this.queryIs(entry[1], 'Tag')" <app-tag-query-item *ngIf="entry[1] | hasProperty: 'Tag'"
[tagQuery]="this.tagQuery(entry[1]).Tag"></app-tag-query-item> [tagQuery]="entry[1] | getTagQuery"></app-tag-query-item>
</app-selectable> </app-selectable>
</mat-list-item> </mat-list-item>
</mat-list> </mat-list>
@ -19,9 +19,9 @@
<app-selectable #singleSelectable <app-selectable #singleSelectable
(appSelect)="this.appSelect.emit(this.query)" (appSelect)="this.appSelect.emit(this.query)"
(appUnselect)="this.appUnselect.emit(this.query)"> (appUnselect)="this.appUnselect.emit(this.query)">
<app-property-query-item *ngIf="this.queryIs(this.query, 'Property')" <app-property-query-item *ngIf="this.query | hasProperty: 'Property'"
[propertyQuery]="this.propertyQuery(this.query).Property"></app-property-query-item> [propertyQuery]="this.query | getPropertyQuery"></app-property-query-item>
<app-tag-query-item *ngIf="this.queryIs(this.query, 'Tag')" <app-tag-query-item *ngIf="this.query | hasProperty: 'Tag'"
[tagQuery]="this.tagQuery(this.query).Tag"></app-tag-query-item> [tagQuery]="this.query | getTagQuery"></app-tag-query-item>
</app-selectable> </app-selectable>
</span> </span>

@ -1,16 +1,12 @@
import {Component, EventEmitter, Input, OnChanges, Output, SimpleChanges} from "@angular/core"; import {ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output, SimpleChanges} from "@angular/core";
import { import {FilterExpression, FilterQuery} from "../../../../../../../api/api-types/files";
FilterExpression,
FilterQuery,
FilterQueryProperty,
FilterQueryTag
} from "../../../../../../../api/api-types/files";
import {enumerate} from "../../../../../../utils/list-utils"; import {enumerate} from "../../../../../../utils/list-utils";
@Component({ @Component({
selector: "app-filter-expression-list-item", selector: "app-filter-expression-list-item",
templateUrl: "./filter-expression-list-item.component.html", templateUrl: "./filter-expression-list-item.component.html",
styleUrls: ["./filter-expression-list-item.component.scss"] styleUrls: ["./filter-expression-list-item.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class FilterExpressionListItemComponent implements OnChanges { export class FilterExpressionListItemComponent implements OnChanges {
@ -35,18 +31,6 @@ export class FilterExpressionListItemComponent implements OnChanges {
} }
} }
public queryIs(query: FilterQuery, key: "Property" | "Tag"): boolean {
return key in query;
}
public propertyQuery(query: FilterQuery): FilterQueryProperty {
return query as FilterQueryProperty;
}
public tagQuery(query: FilterQuery): FilterQueryTag {
return query as FilterQueryTag;
}
private parseFilter() { private parseFilter() {
if (this.filter && "OrExpression" in this.filter) { if (this.filter && "OrExpression" in this.filter) {
this.orExpression = enumerate(this.filter.OrExpression); this.orExpression = enumerate(this.filter.OrExpression);

@ -1,15 +1,15 @@
<span *ngIf="is('OrExpression')" class="or-expression"> <span *ngIf="this.orExpression" class="or-expression">
<ng-container *ngFor="let query of this.orExpression().OrExpression"> <ng-container *ngFor="let query of this.orExpression">
<app-property-query-item *ngIf="this.queryIs(query, 'Property')" <app-property-query-item *ngIf="query | hasProperty: 'Property'"
[propertyQuery]="this.propertyQuery(query).Property"></app-property-query-item> [propertyQuery]="query | getPropertyQuery"></app-property-query-item>
<app-tag-query-item *ngIf="this.queryIs(query, 'Tag')" <app-tag-query-item *ngIf="query | hasProperty: 'Tag'"
[tagQuery]="this.tagQuery(query).Tag"></app-tag-query-item> [tagQuery]="query | getTagQuery"></app-tag-query-item>
<span class="or-combinator"> OR </span> <span class="or-combinator"> OR </span>
</ng-container> </ng-container>
</span> </span>
<span *ngIf="is('Query')" class="query"> <span *ngIf="this.query" class="query">
<app-property-query-item *ngIf="this.queryIs(this.query().Query, 'Property')" <app-property-query-item *ngIf="this.query | hasProperty: 'Property'"
[propertyQuery]="this.propertyQuery(this.query().Query).Property"></app-property-query-item> [propertyQuery]="this.query | getPropertyQuery"></app-property-query-item>
<app-tag-query-item *ngIf="this.queryIs(this.query().Query, 'Tag')" <app-tag-query-item *ngIf="this.query | hasProperty: 'Tag'"
[tagQuery]="this.tagQuery(this.query().Query).Tag"></app-tag-query-item> [tagQuery]="this.query | getTagQuery"></app-tag-query-item>
</span> </span>

@ -1,47 +1,35 @@
import {Component, Input} from "@angular/core"; import {ChangeDetectionStrategy, Component, Input, OnChanges, OnInit, SimpleChanges} from "@angular/core";
import { import {FilterExpression, FilterQuery} from "../../../../../../api/api-types/files";
FilterExpression,
FilterExpressionOrExpression,
FilterExpressionQuery,
FilterQuery,
FilterQueryProperty,
FilterQueryTag
} from "../../../../../../api/api-types/files";
@Component({ @Component({
selector: "app-filter-expression-item", selector: "app-filter-expression-item",
templateUrl: "./filter-expression-item.component.html", templateUrl: "./filter-expression-item.component.html",
styleUrls: ["./filter-expression-item.component.scss"] styleUrls: ["./filter-expression-item.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class FilterExpressionItemComponent { export class FilterExpressionItemComponent implements OnInit, OnChanges {
@Input() filter!: FilterExpression; @Input() filter!: FilterExpression;
public orExpression?: FilterQuery[];
public query?: FilterQuery;
constructor() { constructor() {
} }
public is(key: "OrExpression" | "Query"): boolean { public ngOnInit(): void {
return key in this.filter; this.parseQuery();
}
public orExpression(): FilterExpressionOrExpression {
return this.filter as FilterExpressionOrExpression;
} }
public query(): FilterExpressionQuery { public ngOnChanges(changes: SimpleChanges): void {
return this.filter as FilterExpressionQuery; if (changes["filter"]) {
this.parseQuery();
} }
public queryIs(query: FilterQuery, key: "Property" | "Tag"): boolean {
return key in query;
} }
public propertyQuery(query: FilterQuery): FilterQueryProperty { private parseQuery() {
return query as FilterQueryProperty; if ("Query" in this.filter) {
this.query = this.filter.Query;
} else {
this.orExpression = this.filter.OrExpression;
} }
public tagQuery(query: FilterQuery): FilterQueryTag {
return query as FilterQueryTag;
} }
} }

@ -0,0 +1,8 @@
import { GetPropertyQueryPipe } from './get-property-query.pipe';
describe('GetPropertyQueryPipe', () => {
it('create an instance', () => {
const pipe = new GetPropertyQueryPipe();
expect(pipe).toBeTruthy();
});
});

@ -0,0 +1,13 @@
import {Pipe, PipeTransform} from "@angular/core";
import {FilterQuery, FilterQueryProperty, PropertyQuery} from "../../../../../../api/api-types/files";
@Pipe({
name: "getPropertyQuery"
})
export class GetPropertyQueryPipe implements PipeTransform {
transform(value: FilterQuery): PropertyQuery {
return (value as FilterQueryProperty).Property;
}
}

@ -0,0 +1,8 @@
import { GetTagQueryPipe } from './get-tag-query.pipe';
describe('GetTagQueryPipe', () => {
it('create an instance', () => {
const pipe = new GetTagQueryPipe();
expect(pipe).toBeTruthy();
});
});

@ -0,0 +1,12 @@
import {Pipe, PipeTransform} from "@angular/core";
import {FilterQuery, FilterQueryTag, TagQuery} from "../../../../../../api/api-types/files";
@Pipe({
name: "getTagQuery"
})
export class GetTagQueryPipe implements PipeTransform {
transform(value: FilterQuery): TagQuery {
return (value as FilterQueryTag).Tag;
}
}

@ -1,14 +1,16 @@
import {Component, Inject} from "@angular/core"; import {ChangeDetectionStrategy, Component, Inject} from "@angular/core";
import {MAT_DIALOG_DATA, MatDialogRef} from "@angular/material/dialog"; import {MAT_DIALOG_DATA, MatDialogRef} from "@angular/material/dialog";
import {SortKey} from "../../../../../models/SortKey"; import {SortKey} from "../../../../../models/SortKey";
import {CdkDragDrop, moveItemInArray} from "@angular/cdk/drag-drop"; import {CdkDragDrop, moveItemInArray} from "@angular/cdk/drag-drop";
import {Namespace} from "../../../../../../api/models/Namespace"; import {Namespace} from "../../../../../../api/models/Namespace";
import {TagService} from "../../../../../services/tag/tag.service"; import {TagService} from "../../../../../services/tag/tag.service";
import {compareSearchResults} from "../../../../../utils/compare-utils";
@Component({ @Component({
selector: "app-sort-dialog", selector: "app-sort-dialog",
templateUrl: "./sort-dialog.component.html", templateUrl: "./sort-dialog.component.html",
styleUrls: ["./sort-dialog.component.scss"] styleUrls: ["./sort-dialog.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class SortDialogComponent { export class SortDialogComponent {
@ -24,20 +26,6 @@ export class SortDialogComponent {
namespaces => this.namespaces = namespaces); namespaces => this.namespaces = namespaces);
} }
private static compareSuggestionNamespaces(query: string, l: string, r: string): number {
if (l.startsWith(query) && !r.startsWith(query)) {
return -1;
} else if (!l.startsWith(query) && r.startsWith(query)) {
return 1;
} else if (l.length < r.length) {
return -1;
} else if (l.length > r.length) {
return 1;
} else {
return l.localeCompare(r);
}
}
addNewSortKey() { addNewSortKey() {
const sortKey = new SortKey("FileName", "Ascending", undefined); const sortKey = new SortKey("FileName", "Ascending", undefined);
this.sortEntries.push(sortKey); this.sortEntries.push(sortKey);
@ -64,7 +52,7 @@ export class SortDialogComponent {
public updateAutocompleteSuggestions(value: string): void { public updateAutocompleteSuggestions(value: string): void {
this.suggestedNamespaces = this.namespaces.sort( this.suggestedNamespaces = this.namespaces.sort(
(a, b) => SortDialogComponent.compareSuggestionNamespaces(value, a.name, b.name)) (a, b) => compareSearchResults(value, a.name, b.name))
.slice(0, 50); .slice(0, 50);
} }
} }

@ -46,6 +46,8 @@ import {
import { import {
FilterExpressionListItemComponent FilterExpressionListItemComponent
} from "./file-search/filter-dialog/filter-expression-list-item/filter-expression-list-item.component"; } from "./file-search/filter-dialog/filter-expression-list-item/filter-expression-list-item.component";
import { GetTagQueryPipe } from './file-search/filter-pipes/get-tag-query.pipe';
import { GetPropertyQueryPipe } from './file-search/filter-pipes/get-property-query.pipe';
@NgModule({ @NgModule({
@ -62,6 +64,8 @@ import {
TagQueryItemComponent, TagQueryItemComponent,
PropertyQueryItemComponent, PropertyQueryItemComponent,
FilterExpressionListItemComponent, FilterExpressionListItemComponent,
GetTagQueryPipe,
GetPropertyQueryPipe,
], ],
exports: [ exports: [
TagEditComponent, TagEditComponent,

@ -1,11 +1,11 @@
<div class="file-edit-inner" fxLayout="column"> <app-busy-indicator [blurBackground]="true" class="file-edit-inner" fxLayout="column">
<div class="file-edit-header" fxFlex="100px"> <div class="file-edit-header" fxFlex="100px">
<h1>Edit Tags</h1> <h1>Edit Tags</h1>
<mat-divider></mat-divider> <mat-divider></mat-divider>
</div> </div>
<div class="tag-edit-list" fxFlex fxFlexAlign="start" fxFlexFill> <div class="tag-edit-list" fxFlex fxFlexAlign="start" fxFlexFill>
<cdk-virtual-scroll-viewport #tagScroll itemSize="50" maxBufferPx="2000" minBufferPx="1000"> <cdk-virtual-scroll-viewport #tagScroll itemSize="50" maxBufferPx="2000" minBufferPx="1000">
<div *cdkVirtualFor="let tag of tags" class="editable-tag"> <div *cdkVirtualFor="let tag of tags; trackBy: trackByTagId" class="editable-tag">
<app-tag-item [tag]="tag"></app-tag-item> <app-tag-item [tag]="tag"></app-tag-item>
<button (click)="removeTag(tag)" class="tag-remove-button" mat-icon-button> <button (click)="removeTag(tag)" class="tag-remove-button" mat-icon-button>
<ng-icon name="mat-remove"></ng-icon> <ng-icon name="mat-remove"></ng-icon>
@ -33,5 +33,4 @@
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
<app-busy-indicator *ngIf="this.loading" [busy]="this.loading" [blurBackground]="true"></app-busy-indicator> </app-busy-indicator>
</div>

@ -12,6 +12,7 @@
.file-edit-header { .file-edit-header {
text-align: center; text-align: center;
h1 { h1 {
margin-top: 20px; margin-top: 20px;
} }
@ -75,6 +76,7 @@ mat-divider {
width: 100%; width: 100%;
} }
/*
app-busy-indicator { app-busy-indicator {
position: absolute; position: absolute;
top: 0; top: 0;
@ -83,3 +85,4 @@ app-busy-indicator {
width: 100%; width: 100%;
z-index: 99; z-index: 99;
} }
*/

@ -1,9 +1,10 @@
import { import {
AfterViewInit,
ChangeDetectionStrategy,
Component, Component,
EventEmitter, EventEmitter,
Input, Input,
OnChanges, OnChanges,
OnInit,
Output, Output,
SimpleChanges, SimpleChanges,
ViewChild ViewChild
@ -12,31 +13,35 @@ import {File} from "../../../../../api/models/File";
import {Tag} from "../../../../../api/models/Tag"; import {Tag} from "../../../../../api/models/Tag";
import {CdkVirtualScrollViewport} from "@angular/cdk/scrolling"; import {CdkVirtualScrollViewport} from "@angular/cdk/scrolling";
import {TagService} from "../../../../services/tag/tag.service"; import {TagService} from "../../../../services/tag/tag.service";
import {ErrorBrokerService} from "../../../../services/error-broker/error-broker.service";
import {BusyIndicatorComponent} from "../../app-common/busy-indicator/busy-indicator.component";
@Component({ @Component({
selector: "app-tag-edit", selector: "app-tag-edit",
templateUrl: "./tag-edit.component.html", templateUrl: "./tag-edit.component.html",
styleUrls: ["./tag-edit.component.scss"] styleUrls: ["./tag-edit.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class TagEditComponent implements OnInit, OnChanges { export class TagEditComponent implements AfterViewInit, OnChanges {
@Input() files: File[] = []; @Input() files: File[] = [];
@Output() tagEditEvent = new EventEmitter<TagEditComponent>(); @Output() tagEditEvent = new EventEmitter<TagEditComponent>();
public tags: Tag[] = [];
@ViewChild("tagScroll") tagScroll!: CdkVirtualScrollViewport;
@ViewChild(BusyIndicatorComponent) busyIndicator!: BusyIndicatorComponent;
public tags: Tag[] = [];
public allTags: Tag[] = []; public allTags: Tag[] = [];
public editMode: string = "Toggle"; public editMode: string = "Toggle";
@ViewChild("tagScroll") tagScroll!: CdkVirtualScrollViewport;
private fileTags: { [key: number]: Tag[] } = {}; private fileTags: { [key: number]: Tag[] } = {};
public loading = false;
constructor( constructor(
private errorBroker: ErrorBrokerService,
private tagService: TagService, private tagService: TagService,
) { ) {
} }
async ngOnInit() { async ngAfterViewInit() {
this.tagService.tags.subscribe(tags => this.allTags = tags); this.tagService.tags.subscribe(tags => this.allTags = tags);
await this.tagService.loadTags(); await this.tagService.loadTags();
await this.tagService.loadNamespaces(); await this.tagService.loadNamespaces();
@ -50,7 +55,6 @@ export class TagEditComponent implements OnInit, OnChanges {
} }
public async editTag(tag: string): Promise<void> { public async editTag(tag: string): Promise<void> {
this.loading = true;
if (tag.length > 0) { if (tag.length > 0) {
let tagInstance = this.allTags.find( let tagInstance = this.allTags.find(
t => t.getNormalizedOutput() === tag); t => t.getNormalizedOutput() === tag);
@ -71,10 +75,10 @@ export class TagEditComponent implements OnInit, OnChanges {
break; break;
} }
} }
this.loading = false;
} }
async toggleTag(tag: Tag) { async toggleTag(tag: Tag) {
await this.wrapAsyncOperation(async () => {
for (const file of this.files) { for (const file of this.files) {
const fileTags = this.fileTags[file.id]; const fileTags = this.fileTags[file.id];
let addedTags = []; let addedTags = [];
@ -86,7 +90,8 @@ export class TagEditComponent implements OnInit, OnChanges {
} }
this.fileTags[file.id] = await this.tagService.changeFileTags( this.fileTags[file.id] = await this.tagService.changeFileTags(
file.id, file.id,
addedTags, removedTags); addedTags, removedTags
);
if (addedTags.length > 0) { if (addedTags.length > 0) {
await this.tagService.loadTags(); await this.tagService.loadTags();
await this.tagService.loadNamespaces(); await this.tagService.loadNamespaces();
@ -97,15 +102,18 @@ export class TagEditComponent implements OnInit, OnChanges {
if (index >= 0) { if (index >= 0) {
this.tagScroll.scrollToIndex(index); this.tagScroll.scrollToIndex(index);
} }
});
this.tagEditEvent.emit(this); this.tagEditEvent.emit(this);
} }
async addTag(tag: Tag) { async addTag(tag: Tag) {
await this.wrapAsyncOperation(async () => {
for (const file of this.files) { for (const file of this.files) {
if ((this.fileTags[file.id] ?? []).findIndex(t => t.id === tag.id) < 0) { if ((this.fileTags[file.id] ?? []).findIndex(t => t.id === tag.id) < 0) {
this.fileTags[file.id] = await this.tagService.changeFileTags( this.fileTags[file.id] = await this.tagService.changeFileTags(
file.id, file.id,
[tag.id], []); [tag.id], []
);
} }
} }
this.mapFileTagsToTagList(); this.mapFileTagsToTagList();
@ -113,27 +121,33 @@ export class TagEditComponent implements OnInit, OnChanges {
if (index >= 0) { if (index >= 0) {
this.tagScroll.scrollToIndex(index); this.tagScroll.scrollToIndex(index);
} }
this.tagEditEvent.emit(this);
await this.tagService.loadTags(); await this.tagService.loadTags();
await this.tagService.loadNamespaces(); await this.tagService.loadNamespaces();
});
this.tagEditEvent.emit(this);
} }
public async removeTag(tag: Tag) { public async removeTag(tag: Tag) {
this.loading = true; await this.wrapAsyncOperation(async () => {
for (const file of this.files) { for (const file of this.files) {
if (this.fileTags[file.id].findIndex(t => t.id === tag.id) >= 0) { if (this.fileTags[file.id].findIndex(t => t.id === tag.id) >= 0) {
this.fileTags[file.id] = await this.tagService.changeFileTags( this.fileTags[file.id] = await this.tagService.changeFileTags(
file.id, file.id,
[], [tag.id]); [], [tag.id]
);
} }
} }
this.mapFileTagsToTagList(); this.mapFileTagsToTagList();
this.loading = false; });
this.tagEditEvent.emit(this); this.tagEditEvent.emit(this);
} }
public trackByTagId(index: number, item: Tag) {
return item.id;
}
private async loadFileTags() { private async loadFileTags() {
this.loading = true; await this.wrapAsyncOperation(async () => {
const promises = []; const promises = [];
const loadFn = async (file: File) => { const loadFn = async (file: File) => {
this.fileTags[file.id] = await this.tagService.getTagsForFiles( this.fileTags[file.id] = await this.tagService.getTagsForFiles(
@ -145,7 +159,7 @@ export class TagEditComponent implements OnInit, OnChanges {
await Promise.all(promises); await Promise.all(promises);
this.mapFileTagsToTagList(); this.mapFileTagsToTagList();
this.loading = false; });
} }
private mapFileTagsToTagList() { private mapFileTagsToTagList() {
@ -160,4 +174,17 @@ export class TagEditComponent implements OnInit, OnChanges {
(a, b) => a.getNormalizedOutput() (a, b) => a.getNormalizedOutput()
.localeCompare(b.getNormalizedOutput())); .localeCompare(b.getNormalizedOutput()));
} }
private async wrapAsyncOperation<T>(cb: () => Promise<T>): Promise<T | undefined> {
if (!this.busyIndicator) {
try {
return cb();
} catch (err: any) {
this.errorBroker.showError(err);
return undefined;
}
} else {
return this.busyIndicator.wrapAsyncOperation(cb);
}
}
} }

@ -1,12 +1,18 @@
import {BehaviorSubject} from "rxjs";
export class Selectable<T> { export class Selectable<T> {
constructor(public data: T, public selected: boolean) {
public selected: BehaviorSubject<boolean>;
constructor(public data: T, selected: boolean) {
this.selected = new BehaviorSubject<boolean>(selected);
} }
public select() { public select() {
this.selected = true; this.selected.next(true);
} }
public unselect() { public unselect() {
this.selected = false; this.selected.next(false);
} }
} }

Loading…
Cancel
Save