From 020464da8f538eb6d463ceaa0558a64ba1fe3489 Mon Sep 17 00:00:00 2001 From: Martin Pedersen Date: Mon, 8 Jun 2026 14:51:02 +0200 Subject: [PATCH 1/2] Adding tree view and allowing pinning hover cards Adding escape handling --- .../table/dynamic-mat-table.component.html | 116 ++++++++++-- .../table/dynamic-mat-table.component.scss | 55 +++++- .../table/dynamic-mat-table.component.spec.ts | 179 ++++++++++++++++++ .../table/dynamic-mat-table.component.ts | 122 +++++++++++- .../table/dynamic-mat-table.module.ts | 2 + .../tree-view/tree-view.component.html | 4 +- .../tree-view/tree-view.component.scss | 5 +- 7 files changed, 459 insertions(+), 24 deletions(-) create mode 100644 src/app/shared/modules/dynamic-material-table/table/dynamic-mat-table.component.spec.ts 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..626787a592 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 @@ -217,11 +217,13 @@ - + {{ column.header || column.name | translate: localization }} -
+ +
+ +
+
+ +
+
@@ -321,25 +358,68 @@ - + {{ column.header || column.name | translate: localization }} + push_pin -
- {{ getColumnValue(row, column) || "No content" }} -
+ +
+ +
+
+ +
+ {{ getColumnValue(row, column) || "No content" }} +
+
@@ -355,6 +435,10 @@
+ +
No content
+
+ { + let component: DynamicMatTableComponent; + + const hoverColumn: TableField = { + name: "scientificMetadata", + header: "Scientific metadata", + hoverContent: true, + type: "hoverContent", + }; + + beforeEach(() => { + const overlayContainer = { + getContainerElement: () => document.createElement("div"), + } as unknown as OverlayContainer; + + component = new DynamicMatTableComponent( + {} as MatDialog, + {} as Renderer2, + new TableService(), + { + detectChanges: () => undefined, + } as ChangeDetectorRef, + {} as Overlay, + overlayContainer, + {} as OverlayPositionBuilder, + {}, + new DatePipe("en-US"), + { + getConfig: () => ({}), + } as AppConfigService, + ); + }); + + it("pins the hover card and stops click propagation", () => { + const event = jasmine.createSpyObj("event", [ + "stopPropagation", + "preventDefault", + ]); + const row = { id: 1 }; + + component.togglePinnedHoverCard(event, row, hoverColumn); + + expect(event.stopPropagation).toHaveBeenCalled(); + expect(event.preventDefault).toHaveBeenCalled(); + expect(component.isHoverCardPinned(row, hoverColumn)).toBeTrue(); + expect(component.isHoverCardOpen(row, hoverColumn)).toBeTrue(); + }); + + it("closes a pinned hover card when toggled again", () => { + const row = { id: 1 }; + const event = jasmine.createSpyObj("event", [ + "stopPropagation", + "preventDefault", + ]); + + component.togglePinnedHoverCard(event, row, hoverColumn); + component.togglePinnedHoverCard(event, row, hoverColumn); + + expect(component.isHoverCardPinned(row, hoverColumn)).toBeFalse(); + expect(component.isHoverCardOpen(row, hoverColumn)).toBeFalse(); + }); + + it("does not open a second hover card while another one is pinned", () => { + const row = { id: 1 }; + const otherRow = { id: 2 }; + + component.pinnedHoverKey = component.makeKey(row, hoverColumn); + component.onHoverCardTriggerEnter(otherRow, hoverColumn); + + expect(component.hoverKey).toBeNull(); + expect(component.isHoverCardOpen(row, hoverColumn)).toBeTrue(); + expect(component.isHoverCardOpen(otherRow, hoverColumn)).toBeFalse(); + }); + + it("keeps a pinned hover card open after mouseleave until explicitly closed", () => { + const row = { id: 1 }; + + component.pinnedHoverKey = component.makeKey(row, hoverColumn); + component.hoverKey = component.makeKey(row, hoverColumn); + + component.onHoverCardTriggerLeave(row, hoverColumn); + + expect(component.isHoverCardOpen(row, hoverColumn)).toBeTrue(); + + component.closePinnedHoverCard(); + + expect(component.isHoverCardOpen(row, hoverColumn)).toBeFalse(); + }); + + it("closes a pinned hover card on document click", () => { + const row = { id: 1 }; + + component.pinnedHoverKey = component.makeKey(row, hoverColumn); + component.hoverKey = component.makeKey(row, hoverColumn); + + component.onDocumentClick(); + + expect(component.isHoverCardPinned(row, hoverColumn)).toBeFalse(); + expect(component.isHoverCardOpen(row, hoverColumn)).toBeFalse(); + }); + + it("detects scientific metadata content for tree rendering", () => { + const row = { + scientificMetadata: { + nested: { + value: 42, + }, + }, + }; + + expect(component.isScientificMetadataColumn(hoverColumn)).toBeTrue(); + expect(component.hasScientificMetadata(row)).toBeTrue(); + expect(component.getScientificMetadata(row)).toEqual( + row.scientificMetadata, + ); + }); + + it("detects nested scientific metadata hover columns and returns only that subtree", () => { + const nestedHoverColumn: TableField = { + name: "scientificMetadata.my-group", + header: "Metadata", + hoverContent: true, + type: "hoverContent", + }; + const row = { + scientificMetadata: { + "my-group": { + nested: { + value: 42, + }, + }, + other: { + value: "not shown", + }, + }, + }; + + expect(component.isScientificMetadataColumn(nestedHoverColumn)).toBeTrue(); + expect(component.hasScientificMetadata(row, nestedHoverColumn)).toBeTrue(); + expect(component.getScientificMetadata(row, nestedHoverColumn)).toEqual( + row.scientificMetadata["my-group"], + ); + }); + + it("treats missing nested scientific metadata hover content as empty", () => { + const nestedHoverColumn: TableField = { + name: "scientificMetadata.my-group", + header: "Metadata", + hoverContent: true, + type: "hoverContent", + }; + const row = { + scientificMetadata: { + other: { + value: "not shown", + }, + }, + }; + + expect(component.hasScientificMetadata(row, nestedHoverColumn)).toBeFalse(); + expect( + component.getScientificMetadata(row, nestedHoverColumn), + ).toBeUndefined(); + }); +}); 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..06e42e0a74 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 @@ -20,6 +20,7 @@ import { OnChanges, ViewContainerRef, SimpleChanges, + HostListener, } from "@angular/core"; import { TableCoreDirective } from "../cores/table.core.directive"; import { TableService } from "./dynamic-mat-table.service"; @@ -78,6 +79,7 @@ import { import { TableDataSource } from "../cores/table-data-source"; import { DatePipe } from "@angular/common"; import { AppConfigService } from "app-config.service"; +import { get as lodashGet } from "lodash-es"; export interface IDynamicCell { row: TableRow; @@ -204,6 +206,7 @@ export class DynamicMatTableComponent globalSearchUpdate = new Subject(); init = false; hoverKey: string | null = null; + pinnedHoverKey: string | null = null; currentContextMenuSender: any = {}; @HostBinding("style.height.px") height = null; @@ -259,16 +262,16 @@ export class DynamicMatTableComponent /** Overlay positions for metadata hover */ metadataOverlayPositions: ConnectedPosition[] = [ { - originX: "end", + originX: "center", originY: "center", - overlayX: "start", + overlayX: "center", overlayY: "center", offsetX: 8, }, { - originX: "start", + originX: "center", originY: "center", - overlayX: "end", + overlayX: "center", overlayY: "center", offsetX: -8, }, @@ -388,8 +391,117 @@ export class DynamicMatTableComponent }); } + private getScientificMetadataColumnName(column?: TableField) { + return (column?.name || "").trim(); + } + makeKey = (row: any, col: any) => (row?.id ?? row) + "::" + col?.name; + @HostListener("document:click") + onDocumentClick() { + if (this.pinnedHoverKey) { + this.closePinnedHoverCard(); + } + } + + @HostListener("document:keydown.escape") + onEscapeKey() { + if (this.pinnedHoverKey) { + this.closePinnedHoverCard(); + } + } + + isHoverCardOpen(row: any, column: TableField) { + const key = this.makeKey(row, column); + return this.hoverKey === key || this.pinnedHoverKey === key; + } + + isHoverCardPinned(row: any, column: TableField) { + return this.pinnedHoverKey === this.makeKey(row, column); + } + + onHoverCardTriggerEnter(row: any, column: TableField) { + const key = this.makeKey(row, column); + if (this.pinnedHoverKey && this.pinnedHoverKey !== key) { + return; + } + + this.hoverKey = key; + } + + onHoverCardTriggerLeave(row: any, column: TableField) { + const key = this.makeKey(row, column); + if (this.pinnedHoverKey === key) { + return; + } + + if (this.hoverKey === key) { + this.hoverKey = null; + } + } + + onHoverCardContentEnter(row: any, column: TableField) { + this.hoverKey = this.makeKey(row, column); + } + + onHoverCardContentLeave(row: any, column: TableField) { + this.onHoverCardTriggerLeave(row, column); + } + + togglePinnedHoverCard(event: MouseEvent, row: any, column: TableField) { + event.preventDefault(); + this.stopEventPropagation(event); + + const key = this.makeKey(row, column); + if (this.pinnedHoverKey === key) { + this.closePinnedHoverCard(); + return; + } + + this.pinnedHoverKey = key; + this.hoverKey = key; + } + + closePinnedHoverCard() { + this.pinnedHoverKey = null; + this.hoverKey = null; + } + + stopEventPropagation(event: Event) { + event.stopPropagation(); + } + + isScientificMetadataColumn(column: TableField) { + const name = this.getScientificMetadataColumnName(column); + return ( + name === "scientificMetadata" || name.startsWith("scientificMetadata.") + ); + } + + getScientificMetadata(row: Record, column?: TableField) { + const metadata = row?.scientificMetadata as Record; + const name = this.getScientificMetadataColumnName(column); + + if ( + !name || + name === "scientificMetadata" || + !name.startsWith("scientificMetadata.") + ) { + return metadata; + } + + return lodashGet(row, name); + } + + hasScientificMetadata(row: Record, column?: TableField) { + const metadata = this.getScientificMetadata(row, column); + return ( + !!metadata && + typeof metadata === "object" && + Object.keys(metadata).length > 0 + ); + } + ngAfterViewInit(): void { this.standardDataSource.paginator = this.paginator; if (this.tableSetting.tableSort) { @@ -507,6 +619,8 @@ export class DynamicMatTableComponent if (this.eventsSubscription) { this.eventsSubscription.unsubscribe(); } + this.closePinnedHoverCard(); + this.closeTooltip(); } public refreshUI() { 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..3acb4e1a93 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 { ScientificMetadataTreeModule } from "shared/modules/scientific-metadata-tree/scientific-metadata-tree.module"; // eslint-disable-next-line @typescript-eslint/naming-convention const ExtensionsModule = [RowMenuModule]; @@ -67,6 +68,7 @@ const ExtensionsModule = [RowMenuModule]; EmptyContentModule, OverlayModule, MatTooltipModule, + ScientificMetadataTreeModule, ], exports: [DynamicMatTableComponent], declarations: [ diff --git a/src/app/shared/modules/scientific-metadata-tree/tree-view/tree-view.component.html b/src/app/shared/modules/scientific-metadata-tree/tree-view/tree-view.component.html index cc38d73b17..7f9e732dab 100644 --- a/src/app/shared/modules/scientific-metadata-tree/tree-view/tree-view.component.html +++ b/src/app/shared/modules/scientific-metadata-tree/tree-view/tree-view.component.html @@ -38,7 +38,9 @@
-
{{ getValueRepresentation(node) | formatNumber }}
+
+ {{ getValueRepresentation(node) | formatNumber }} +
diff --git a/src/app/shared/modules/scientific-metadata-tree/tree-view/tree-view.component.scss b/src/app/shared/modules/scientific-metadata-tree/tree-view/tree-view.component.scss index 9bf1daf217..6b3ae9f449 100644 --- a/src/app/shared/modules/scientific-metadata-tree/tree-view/tree-view.component.scss +++ b/src/app/shared/modules/scientific-metadata-tree/tree-view/tree-view.component.scss @@ -61,8 +61,8 @@ mat-form-field { } section { display: grid; - grid-template-columns: 40% 60%; - gap: 1em; + grid-template-columns: 1fr 3fr; + gap: 1.5rem; width: 100%; margin: 0 2em; } @@ -75,4 +75,5 @@ section { .value-cell { grid-column: 2; word-wrap: break-word; + min-width: 2rem; } From 7e15913b5569aca71356124ffa75bddcf4dc6f04 Mon Sep 17 00:00:00 2001 From: Martin Pedersen Date: Wed, 17 Jun 2026 15:21:53 +0200 Subject: [PATCH 2/2] Updating file budget --- angular.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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": [