diff --git a/angular.json b/angular.json index 064a161573..88a087f7e6 100644 --- a/angular.json +++ b/angular.json @@ -61,7 +61,7 @@ { "type": "anyComponentStyle", "maximumWarning": "8kb", - "maximumError": "10kb" + "maximumError": "20kb" } ], "fileReplacements": [ diff --git a/src/app/admin/schema/frontend.config.jsonforms.json b/src/app/admin/schema/frontend.config.jsonforms.json index 7a4cde39b1..50e4942288 100644 --- a/src/app/admin/schema/frontend.config.jsonforms.json +++ b/src/app/admin/schema/frontend.config.jsonforms.json @@ -24,6 +24,7 @@ "accessTokenPrefix": { "type": "string" }, "addDatasetEnabled": { "type": "boolean" }, "archiveWorkflowEnabled": { "type": "boolean" }, + "realTimeUpdatesEnabled": { "type": "boolean" }, "datasetReduceEnabled": { "type": "boolean" }, "datasetJsonScientificMetadata": { "type": "boolean" }, "datasetPageSizeOptions": { @@ -480,7 +481,8 @@ { "type": "Control", "scope": "#/properties/logbookEnabled" }, { "type": "Control", "scope": "#/properties/loginFormEnabled" }, { "type": "Control", "scope": "#/properties/metadataPreviewEnabled" }, - { "type": "Control", "scope": "#/properties/autoApplyFilters" } + { "type": "Control", "scope": "#/properties/autoApplyFilters" }, + { "type": "Control", "scope": "#/properties/realTimeUpdatesEnabled" } ] }, { diff --git a/src/app/app-config.service.ts b/src/app/app-config.service.ts index 6b42dc9177..a54e79fbcf 100644 --- a/src/app/app-config.service.ts +++ b/src/app/app-config.service.ts @@ -176,6 +176,7 @@ export interface AppConfigInterface { autoApplyFilters?: boolean; batchActionsEnabled?: boolean; batchActions?: ActionConfig[]; + realTimeUpdatesEnabled?: boolean; } function isMainPageConfiguration(obj: any): obj is MainPageConfiguration { diff --git a/src/app/datasets/dashboard/dashboard.component.ts b/src/app/datasets/dashboard/dashboard.component.ts index 3097b717ce..4f09aab907 100644 --- a/src/app/datasets/dashboard/dashboard.component.ts +++ b/src/app/datasets/dashboard/dashboard.component.ts @@ -30,7 +30,6 @@ import { import { distinctUntilChanged, filter, map, take } from "rxjs/operators"; import { MatDialog } from "@angular/material/dialog"; import { MatSidenav } from "@angular/material/sidenav"; -import { AddDatasetDialogComponent } from "datasets/add-dataset-dialog/add-dataset-dialog.component"; import { combineLatest, Subscription, lastValueFrom } from "rxjs"; import { selectProfile, @@ -58,6 +57,7 @@ export class DashboardComponent implements OnInit, OnDestroy { private readyToFetch$ = this.store .select(selectHasPrefilledFilters) .pipe(filter((has) => has)); + loggedIn$ = this.store.select(selectIsLoggedIn); selectedSets$ = this.store.select(selectSelectedDatasets); selectColumns$ = this.store.select(selectColumns); diff --git a/src/app/proposals/proposal-datasets/proposal-datasets.component.html b/src/app/proposals/proposal-datasets/proposal-datasets.component.html index 9cadcb767a..3b0c7c084d 100644 --- a/src/app/proposals/proposal-datasets/proposal-datasets.component.html +++ b/src/app/proposals/proposal-datasets/proposal-datasets.component.html @@ -22,5 +22,9 @@ class="mat-elevation-z2 proposal-dataset-table" [emptyMessage]="'No datasets available'" [emptyIcon]="'folder'" + [showRealTimeToggle]="isLoggedIn$ | async" + [latestUpdatedId]="latestUpdatedId$ | async" + [realTimeEnabled]="realTimeEnabled" + (realTimeEnabledChange)="onRealTimeToggle($event)" > diff --git a/src/app/proposals/proposal-datasets/proposal-datasets.component.ts b/src/app/proposals/proposal-datasets/proposal-datasets.component.ts index 7de3452a81..7860b650de 100644 --- a/src/app/proposals/proposal-datasets/proposal-datasets.component.ts +++ b/src/app/proposals/proposal-datasets/proposal-datasets.component.ts @@ -6,7 +6,13 @@ import { ActivatedRoute, Router } from "@angular/router"; import { Store } from "@ngrx/store"; import { OutputDatasetObsoleteDto } from "@scicatproject/scicat-sdk-ts-angular"; import { AppConfigService } from "app-config.service"; -import { BehaviorSubject, lastValueFrom, Subscription, take } from "rxjs"; +import { + BehaviorSubject, + filter, + lastValueFrom, + Subscription, + take, +} from "rxjs"; import { PrintConfig } from "shared/modules/dynamic-material-table/models/print-config.model"; import { TableField } from "shared/modules/dynamic-material-table/models/table-field.model"; import { @@ -28,7 +34,11 @@ import { DatasetsListService } from "shared/services/datasets-list.service"; import { TableConfigService } from "shared/services/table-config.service"; import { fetchProposalDatasetsAction } from "state-management/actions/proposals.actions"; import { selectViewProposalPageViewModel } from "state-management/selectors/proposals.selectors"; -import { selectColumnsWithHasFetchedSettings } from "state-management/selectors/user.selectors"; +import { + selectColumnsWithHasFetchedSettings, + selectIsLoggedIn, +} from "state-management/selectors/user.selectors"; +import { EventsService } from "shared/events.service"; export interface TableData { pid: string; @@ -47,9 +57,11 @@ export interface TableData { standalone: false, }) export class ProposalDatasetsComponent implements OnInit, OnDestroy { + isLoggedIn$ = this.store.select(selectIsLoggedIn); proposalDatasets$ = this.store.select(selectViewProposalPageViewModel); + latestUpdatedId$ = this.eventsService.latestUpdatedId$; - subscription: Subscription; + subscriptions: Subscription[] = []; @Input() proposalId: string; appConfig = this.appConfigService.getConfig(); @@ -57,6 +69,8 @@ export class ProposalDatasetsComponent implements OnInit, OnDestroy { selectColumnsWithHasFetchedSettings, ); + realTimeEnabled = false; + tableName = "proposalDatasetsTable"; columns: TableField[]; @@ -75,9 +89,6 @@ export class ProposalDatasetsComponent implements OnInit, OnDestroy { showNoData = true; - //dataSource: BehaviorSubject = new BehaviorSubject( - // [], - //); dataSource: BehaviorSubject = new BehaviorSubject< OutputDatasetObsoleteDto[] >([]); @@ -127,9 +138,31 @@ export class ProposalDatasetsComponent implements OnInit, OnDestroy { private store: Store, private tableConfigService: TableConfigService, private datasetsListService: DatasetsListService, + private eventsService: EventsService, ) {} ngOnInit(): void { + this.subscriptions.push( + this.eventsService.message$ + .pipe( + filter((payload) => { + return payload.type === "Dataset.created"; + }), + ) + .subscribe((payload: Record) => { + if (!payload.data.proposalIds.includes(this.proposalId)) return; + this.store.dispatch( + fetchProposalDatasetsAction({ + proposalId: this.proposalId, + skip: 0, + limit: this.defaultPageSize, + sortColumn: "creationTime", + sortDirection: "desc", + }), + ); + }), + ); + this.store.dispatch( fetchProposalDatasetsAction({ proposalId: this.proposalId, @@ -138,44 +171,46 @@ export class ProposalDatasetsComponent implements OnInit, OnDestroy { }), ); - this.subscription = this.proposalDatasets$.subscribe(async (data) => { - this.dataSource.next(data.datasets); - this.pending = false; + this.subscriptions.push( + this.proposalDatasets$.subscribe(async (data) => { + this.dataSource.next(data.datasets); + this.pending = false; - const defaultTableColumns = await lastValueFrom( - this.selectColumnsWithFetchedSettings$.pipe(take(1)), - ); - - const defaultConfigColumns = - this.appConfig?.defaultDatasetsListSettings?.columns; - - const userTableConfigColumns = - this.datasetsListService.convertSavedDatasetColumns( - defaultTableColumns.columns, + const defaultTableColumns = await lastValueFrom( + this.selectColumnsWithFetchedSettings$.pipe(take(1)), ); - this.tableDefaultSettingsConfig.settingList[0].columnSetting = - this.datasetsListService.convertSavedDatasetColumns( - defaultConfigColumns as TableColumn[], - ); - - const tableSettingsConfig = - this.tableConfigService.getTableSettingsConfig( - this.tableName, - this.tableDefaultSettingsConfig, - userTableConfigColumns, - ); - const paginationConfig = { - pageSizeOptions: [5, 10, 25, 100], - pageIndex: data.currentPage || 0, - pageSize: data.datasetsPerPage || this.defaultPageSize, - length: data.datasetCount, - }; - - if (tableSettingsConfig?.settingList.length) { - this.initTable(tableSettingsConfig, paginationConfig); - } - }); + const defaultConfigColumns = + this.appConfig?.defaultDatasetsListSettings?.columns; + + const userTableConfigColumns = + this.datasetsListService.convertSavedDatasetColumns( + defaultTableColumns.columns, + ); + + this.tableDefaultSettingsConfig.settingList[0].columnSetting = + this.datasetsListService.convertSavedDatasetColumns( + defaultConfigColumns as TableColumn[], + ); + + const tableSettingsConfig = + this.tableConfigService.getTableSettingsConfig( + this.tableName, + this.tableDefaultSettingsConfig, + userTableConfigColumns, + ); + const paginationConfig = { + pageSizeOptions: [5, 10, 25, 100], + pageIndex: data.currentPage || 0, + pageSize: data.datasetsPerPage || this.defaultPageSize, + length: data.datasetCount, + }; + + if (tableSettingsConfig?.settingList.length) { + this.initTable(tableSettingsConfig, paginationConfig); + } + }), + ); } initTable( @@ -191,6 +226,24 @@ export class ProposalDatasetsComponent implements OnInit, OnDestroy { this.pagination = paginationConfig; } + onRealTimeToggle(enabled: boolean) { + this.realTimeEnabled = enabled; + if (enabled) { + this.eventsService.connect(); + this.store.dispatch( + fetchProposalDatasetsAction({ + proposalId: this.proposalId, + skip: 0, + limit: this.defaultPageSize, + sortColumn: "creationTime", + sortDirection: "desc", + }), + ); + } else { + this.eventsService.disconnect(); + } + } + formatTableData(datasets: OutputDatasetObsoleteDto[]): TableData[] { let tableData: TableData[] = []; if (datasets) { @@ -269,6 +322,7 @@ export class ProposalDatasetsComponent implements OnInit, OnDestroy { } ngOnDestroy() { - this.subscription.unsubscribe(); + this.eventsService.disconnect(); + this.subscriptions.forEach((subscription) => subscription.unsubscribe()); } } diff --git a/src/app/shared/events.service.ts b/src/app/shared/events.service.ts new file mode 100644 index 0000000000..6ef3b2a507 --- /dev/null +++ b/src/app/shared/events.service.ts @@ -0,0 +1,77 @@ +import { Injectable, NgZone } from "@angular/core"; +import { Store } from "@ngrx/store"; +import { fetchScicatTokenAction } from "state-management/actions/user.actions"; +import { selectScicatToken } from "state-management/selectors/user.selectors"; +import { + BehaviorSubject, + catchError, + distinctUntilChanged, + EMPTY, + map, + Observable, + Subject, + Subscription, + switchMap, +} from "rxjs"; + +@Injectable({ providedIn: "root" }) +export class EventsService { + private connectionSub: Subscription | null = null; + private messageSubject = new Subject>(); + private connectionErrorSubject = new BehaviorSubject(false); + + connectionError$ = this.connectionErrorSubject.asObservable(); + + message$ = this.messageSubject.asObservable(); + + latestUpdatedId$ = this.messageSubject.pipe( + map((m) => (m["data"] as { _id: string })._id), + ); + + constructor( + private ngZone: NgZone, + private store: Store, + ) {} + + private createEventStream( + token: string, + ): Observable> { + return new Observable>((observer) => { + const es = new EventSource(`/api/v3/events/stream?token=${token}`); + + es.onopen = () => + this.ngZone.run(() => this.connectionErrorSubject.next(false)); + + es.onmessage = (event) => { + this.ngZone.run(() => observer.next(JSON.parse(event.data))); + }; + + es.onerror = () => { + es.close(); + this.ngZone.run(() => this.connectionErrorSubject.next(true)); + }; + + return () => es.close(); + }); + } + connect() { + if (this.connectionSub) return; + + this.store.dispatch(fetchScicatTokenAction()); + + this.connectionSub = this.store + .select(selectScicatToken) + .pipe( + distinctUntilChanged(), + switchMap((token) => (token ? this.createEventStream(token) : EMPTY)), + ) + .subscribe((msg) => { + return this.messageSubject.next(msg); + }); + } + + disconnect() { + this.connectionSub?.unsubscribe(); + this.connectionSub = null; + } +} diff --git a/src/app/shared/modules/dynamic-material-table/cores/table.core.directive.ts b/src/app/shared/modules/dynamic-material-table/cores/table.core.directive.ts index dbea2a29c5..94a0fd52ba 100644 --- a/src/app/shared/modules/dynamic-material-table/cores/table.core.directive.ts +++ b/src/app/shared/modules/dynamic-material-table/cores/table.core.directive.ts @@ -75,6 +75,9 @@ export class TableCoreDirective { @Input() globalTextSearchPlaceholder = "Search..."; @Input() selectionIds = []; @Input() disableBorder: boolean; + @Input() showRealTimeToggle = false; + @Input() realTimeEnabled = false; + @Output() realTimeEnabledChange = new EventEmitter(); // eslint-disable-next-line @angular-eslint/no-output-on-prefix @Output() onTableEvent: EventEmitter = new EventEmitter(); // eslint-disable-next-line @angular-eslint/no-output-on-prefix diff --git a/src/app/shared/modules/dynamic-material-table/table/dynamic-mat-table.component.html b/src/app/shared/modules/dynamic-material-table/table/dynamic-mat-table.component.html index 341065ad27..3a14a13695 100644 --- a/src/app/shared/modules/dynamic-material-table/table/dynamic-mat-table.component.html +++ b/src/app/shared/modules/dynamic-material-table/table/dynamic-mat-table.component.html @@ -2,6 +2,29 @@ class="table-header-controls" [class.with-side-filter]="sideFilterCollapsed" > +
+ + Live updates + + + + cloud_off + +
diff --git a/src/app/shared/modules/dynamic-material-table/table/dynamic-mat-table.component.scss b/src/app/shared/modules/dynamic-material-table/table/dynamic-mat-table.component.scss index 7743112ff3..400e871ed1 100644 --- a/src/app/shared/modules/dynamic-material-table/table/dynamic-mat-table.component.scss +++ b/src/app/shared/modules/dynamic-material-table/table/dynamic-mat-table.component.scss @@ -17,6 +17,37 @@ box-shadow: none; border: none; } + + &.live-border { + position: relative; + border-radius: 4px; + + &::before { + content: ""; + position: absolute; + inset: 0; + padding: 2px; + z-index: 10; + border-radius: inherit; + pointer-events: none; + // A bright segment over a faint base; rotating the angle makes the + // segment travel from one corner all the way around and repeat. + background: conic-gradient( + from var(--live-angle), + rgba(64, 174, 44, 0.3) 0deg 200deg, + rgba(64, 174, 44, 0.9) 330deg 340deg, + rgba(64, 174, 44, 0.3) 360deg + ); + // The mask keeps only the border ring, so the gradient never covers + // the table content underneath. + -webkit-mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + animation: live-border-travel 10s linear infinite; + } + } } ::ng-deep .mat-mdc-header-cell .mat-sort-header-content { @@ -377,6 +408,18 @@ cdk-virtual-scroll-viewport { margin-left: 3rem; } + .real-time-toggle-wrapper { + margin-left: 1rem; + .connection-error-icon { + color: var(--theme-warn-darker); + font-size: 18px; + width: 18px; + height: 18px; + vertical-align: middle; + margin-left: 6px; + } + } + .global-search-wrapper { padding: 0 20px; flex: 1 1 auto; @@ -430,6 +473,16 @@ cdk-virtual-scroll-viewport { flex: 1; } } + +.connection-error-icon { + color: var(--warn-color, #d32f2f); + font-size: 18px; + width: 18px; + height: 18px; + cursor: default; + vertical-align: middle; +} + .paginator-container.is-loading { ::ng-deep .mat-mdc-paginator-range-label { position: relative; @@ -450,6 +503,33 @@ cdk-virtual-scroll-viewport { } } +.row-highlight, +.row-highlight .mat-mdc-cell { + animation: row-flash 5s ease-out forwards; +} + +// Animating a conic-gradient angle needs a typed custom property, +@property --live-angle { + syntax: ""; + initial-value: 0deg; + inherits: false; +} + +@keyframes row-flash { + from { + box-shadow: inset 0 0 0 9999px rgba(66, 184, 70, 0.95); + } + to { + box-shadow: inset 0 0 0 9999px rgba(76, 175, 80, 0); + } +} + +@keyframes live-border-travel { + to { + --live-angle: 360deg; + } +} + @keyframes paginator-range-spin { from { transform: translate(-50%, -50%) rotate(0deg); diff --git a/src/app/shared/modules/dynamic-material-table/table/dynamic-mat-table.component.ts b/src/app/shared/modules/dynamic-material-table/table/dynamic-mat-table.component.ts index 28647d0d9a..c21a4f001b 100644 --- a/src/app/shared/modules/dynamic-material-table/table/dynamic-mat-table.component.ts +++ b/src/app/shared/modules/dynamic-material-table/table/dynamic-mat-table.component.ts @@ -2,7 +2,6 @@ import { Component, OnInit, AfterViewInit, - QueryList, ElementRef, ViewChild, TemplateRef, @@ -10,7 +9,6 @@ import { ChangeDetectorRef, Input, OnDestroy, - ContentChildren, Injector, ComponentRef, HostBinding, @@ -24,7 +22,6 @@ import { import { TableCoreDirective } from "../cores/table.core.directive"; import { TableService } from "./dynamic-mat-table.service"; import { TableField } from "../models/table-field.model"; -import { AbstractFilter } from "./extensions/filter/compare/abstract-filter"; import { MatDialog } from "@angular/material/dialog"; import { trigger, @@ -50,7 +47,7 @@ import { distinctUntilChanged, filter, } from "rxjs/operators"; -import { Subject, Subscription } from "rxjs"; +import { Subject, Subscription, timer } from "rxjs"; import { MatMenuTrigger } from "@angular/material/menu"; import { ContextMenuItem } from "../models/context-menu.model"; import { @@ -78,6 +75,7 @@ import { import { TableDataSource } from "../cores/table-data-source"; import { DatePipe } from "@angular/common"; import { AppConfigService } from "app-config.service"; +import { EventsService } from "shared/events.service"; export interface IDynamicCell { row: TableRow; @@ -190,6 +188,7 @@ export const expandAnimation = trigger("detailExpand", [ standalone: false, host: { "[class.disable-border]": "disableBorder", + "[class.live-border]": "realTimeEnabled && liveBorder", }, }) export class DynamicMatTableComponent @@ -199,12 +198,15 @@ export class DynamicMatTableComponent // Private fields private dragDropData = { dragColumnIndex: -1, dropColumnIndex: -1 }; private eventsSubscription: Subscription; + private liveConnectionErrSub?: Subscription; // Public fields globalSearchUpdate = new Subject(); init = false; hoverKey: string | null = null; currentContextMenuSender: any = {}; + highlighted = new Set(); + liveBorder = false; @HostBinding("style.height.px") height = null; @@ -319,6 +321,13 @@ export class DynamicMatTableComponent @Input() emptyMessage = "No data available"; @Input() emptyIcon = "info"; @Input() sideFilterCollapsed = false; + @Input() set latestUpdatedId(id: string) { + if (!id || this.highlighted.has(id)) return; + this.highlighted.add(id); + timer(10000).subscribe(() => { + this.highlighted.delete(id); + }); + } appConfig = this.appConfigService.getConfig(); @@ -333,6 +342,7 @@ export class DynamicMatTableComponent public readonly config: TableSetting, private datePipe: DatePipe, public appConfigService: AppConfigService, + public eventsService: EventsService, ) { super(tableService, cdr, config); @@ -403,7 +413,6 @@ export class DynamicMatTableComponent this.dataSource.subscribe((x) => { x = x || []; this.rowSelectionModel.clear(); - this.standardDataSource.data = []; this.initSystemField(x); this.standardDataSource.data = x; this.refreshUI(); @@ -495,8 +504,8 @@ export class DynamicMatTableComponent return {}; } - indexTrackFn = (index: number) => { - return index; + indexTrackFn = (index: number, row: any) => { + return row?._id ?? index; }; trackColumn(index: number, item: TableField): string { @@ -507,6 +516,9 @@ export class DynamicMatTableComponent if (this.eventsSubscription) { this.eventsSubscription.unsubscribe(); } + if (this.liveConnectionErrSub) { + this.liveConnectionErrSub.unsubscribe(); + } } public refreshUI() { @@ -537,6 +549,12 @@ export class DynamicMatTableComponent } }); } + + this.liveConnectionErrSub = this.eventsService.connectionError$.subscribe( + (hasError) => { + this.liveBorder = !hasError; + }, + ); } public get inverseOfTranslation(): number { diff --git a/src/app/shared/modules/dynamic-material-table/table/dynamic-mat-table.module.ts b/src/app/shared/modules/dynamic-material-table/table/dynamic-mat-table.module.ts index 9ae1197ce5..1af2f61b4a 100644 --- a/src/app/shared/modules/dynamic-material-table/table/dynamic-mat-table.module.ts +++ b/src/app/shared/modules/dynamic-material-table/table/dynamic-mat-table.module.ts @@ -37,6 +37,7 @@ import { ITableSetting, TableSetting } from "../models/table-setting.model"; import { PipesModule } from "shared/pipes/pipes.module"; import { EmptyContentModule } from "shared/modules/generic-empty-content/empty-content.module"; import { MatCardModule } from "@angular/material/card"; +import { MatSlideToggleModule } from "@angular/material/slide-toggle"; // eslint-disable-next-line @typescript-eslint/naming-convention const ExtensionsModule = [RowMenuModule]; @@ -62,6 +63,7 @@ const ExtensionsModule = [RowMenuModule]; MatButtonModule, MatCardModule, MatMenuModule, + MatSlideToggleModule, ExtensionsModule, PipesModule, EmptyContentModule,