diff --git a/src/OutputDatasetObsoleteDto_fix.d.ts b/src/OutputDatasetObsoleteDto_fix.d.ts new file mode 100644 index 000000000..f274138dd --- /dev/null +++ b/src/OutputDatasetObsoleteDto_fix.d.ts @@ -0,0 +1,11 @@ +import "@scicatproject/scicat-sdk-ts-angular"; + +// Extend the existing interface +declare module "@scicatproject/scicat-sdk-ts-angular" { + interface OutputDatasetObsoleteDto { + /** + * IDs of the instruments where the data was created. + */ + instrumentIds?: Array; + } +} diff --git a/src/app/_layout/app-header/app-header.component.html b/src/app/_layout/app-header/app-header.component.html index c19064f56..43fdc3e6a 100644 --- a/src/app/_layout/app-header/app-header.component.html +++ b/src/app/_layout/app-header/app-header.component.html @@ -68,6 +68,26 @@

{{ status }}

+ + + + + +
diff --git a/src/app/_layout/app-header/app-header.component.ts b/src/app/_layout/app-header/app-header.component.ts index 347524b81..f7fb99cb1 100644 --- a/src/app/_layout/app-header/app-header.component.ts +++ b/src/app/_layout/app-header/app-header.component.ts @@ -1,5 +1,5 @@ import { DOCUMENT } from "@angular/common"; -import { Component, OnInit, Inject } from "@angular/core"; +import { Component, OnInit, Inject, OnDestroy } from "@angular/core"; import { APP_CONFIG, AppConfig } from "app-config.module"; import { Store } from "@ngrx/store"; import { @@ -13,14 +13,19 @@ import { } from "state-management/selectors/user.selectors"; import { selectDatasetsInBatchIndicator } from "state-management/selectors/datasets.selectors"; import { AppConfigService, OAuth2Endpoint } from "app-config.service"; -import { Router } from "@angular/router"; +import { Router, NavigationEnd } from "@angular/router"; +import { NomadViewerService } from "shared/services/nomad-buttons-service"; +import { filter, Subscription } from "rxjs"; @Component({ selector: "app-app-header", templateUrl: "./app-header.component.html", styleUrls: ["./app-header.component.scss"], }) -export class AppHeaderComponent implements OnInit { +export class AppHeaderComponent implements OnInit, OnDestroy { + private subscriptions: Subscription[] = []; + isDatasetDetailPage = false; + config = this.appConfigService.getConfig(); facility = this.config.facility ?? ""; status = this.appConfig.production ? "" : "test"; @@ -38,6 +43,7 @@ export class AppHeaderComponent implements OnInit { @Inject(APP_CONFIG) public appConfig: AppConfig, private store: Store, @Inject(DOCUMENT) public document: Document, + private nomadViewerService: NomadViewerService, ) {} logout(): void { @@ -58,5 +64,31 @@ export class AppHeaderComponent implements OnInit { ngOnInit() { this.store.dispatch(fetchCurrentUserAction()); this.oAuth2Endpoints = this.config.oAuth2Endpoints; + this.isDatasetDetailPage = /\/datasets\/[^/]+(?:\/.*)?$/.test( + this.router.url, + ); + + this.subscriptions.push( + this.router.events + .pipe(filter((event) => event instanceof NavigationEnd)) + .subscribe((event: NavigationEnd) => { + this.isDatasetDetailPage = /\/datasets\/[^/]+(?:\/.*)?$/.test( + event.url, + ); + }), + ); + } + + ngOnDestroy() { + // existing cleanup code + this.subscriptions.forEach((sub) => sub.unsubscribe()); + } + + openNomadLogs(): void { + this.nomadViewerService.openNomadLogs(); + } + + openNomadCharts(): void { + this.nomadViewerService.openNomadCharts(); } } diff --git a/src/app/_layout/layout.module.ts b/src/app/_layout/layout.module.ts index d9ed0af58..3e250c08f 100644 --- a/src/app/_layout/layout.module.ts +++ b/src/app/_layout/layout.module.ts @@ -12,6 +12,7 @@ import { AppMainLayoutComponent } from "./app-main-layout/app-main-layout.compon import { BatchCardModule } from "datasets/batch-card/batch-card.module"; import { BreadcrumbModule } from "shared/modules/breadcrumb/breadcrumb.module"; import { UsersModule } from "../users/users.module"; +import { MatTooltipModule } from "@angular/material/tooltip"; @NgModule({ declarations: [ @@ -27,6 +28,7 @@ import { UsersModule } from "../users/users.module"; MatIconModule, MatMenuModule, MatToolbarModule, + MatTooltipModule, RouterModule, BreadcrumbModule, UsersModule, diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 596e13037..188c83223 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -32,6 +32,7 @@ import { TranslateLoader, TranslateModule } from "@ngx-translate/core"; import { CustomTranslateLoader } from "shared/loaders/custom-translate.loader"; import { DATE_PIPE_DEFAULT_OPTIONS } from "@angular/common"; import { RouteTrackerService } from "shared/services/route-tracker.service"; +import { SearchService } from "./datasets/services/search.service"; const appConfigInitializerFn = (appConfig: AppConfigService) => { return () => appConfig.loadAppConfig(); @@ -143,6 +144,7 @@ const apiConfigurationFn = ( deps: [AuthService, AppConfigService], multi: false, }, + SearchService, ], bootstrap: [AppComponent], }) diff --git a/src/app/datasets/dataset-detail/dataset-detail/dataset-detail.component.html b/src/app/datasets/dataset-detail/dataset-detail/dataset-detail.component.html index 5bc03f478..6642ea09e 100644 --- a/src/app/datasets/dataset-detail/dataset-detail/dataset-detail.component.html +++ b/src/app/datasets/dataset-detail/dataset-detail/dataset-detail.component.html @@ -26,116 +26,161 @@
- +
description
- {{ "General Information" | translate }} + {{ "Dataset Information" | translate }} +
+ + Public + +
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{{ "Dataset Name" | translate }}{{ dataset.datasetName || "-" }} - - {{ "Dataset Name" | translate }} - - -
{{ "Description" | translate }} - - - - {{ "Description" | translate }} - - -
{{ "Pid" | translate }} - {{ dataset.pid }} - -
{{ "Type" | translate }}{{ value }}
{{ "Creation time" | translate }}{{ value | date }}
{{ "Keywords" | translate }} - - - {{ keyword }} - - - - - - - {{ keyword }} - - - - - -
{{ "Shared With" | translate }} - - - {{ share }} - - - -
+ +

{{ "General Information" | translate }}

+
+ +
+ + + + + + + + + + + + + + +
{{ "Dataset Name" | translate }}{{ dataset.pid || "-" }}
{{ "Description" | translate }}{{ dataset.datasetName || "-" }} + + {{ "Description" | translate }} + + +
{{ "Dataset Size" | translate }}{{ value | filesize }}
+
+ + +
+ + + + + + + + + + + + + + + + + + +
{{ "Keywords" | translate }} + + + {{ keyword }} + + + + + + + {{ keyword }} + + + + + +
{{ "Type" | translate }}{{ value }}
{{ "Creation time" | translate }}{{ value | date }}
{{ "Shared With" | translate }} + + + {{ share }} + + + +
+
+
+ + +

+ {{ "Creator Information" | translate }} +

+
+ +
+ + + + + + + + + +
{{ "Owner" | translate }}{{ value }}
{{ "Orcid" | translate }}
+
+ + +
+ + + + + +
{{ "Local Contact" | translate }} + {{ value.split("@")[0] }} +
+
+
+ +
+
+ + {{ row.numor }} + + + + + +
+ {{ "Measurement Type" | translate }} +
+ filter_list +
+ +
+
+ + {{ "All" | translate }} + +
+
+ + {{ type }} + +
+
+
+
+ {{ + row.measurementType + }} +
+ + + + +
+ {{ "Sample Type" | translate }} +
+ filter_list +
+ +
+
+ + {{ "All" | translate }} + +
+
+ + {{ type }} + +
+
+
+
+ {{ + row.sampleType + }} +
+ + + + +
+ {{ "Sample Subtype" | translate }} +
+ filter_list +
+ +
+
+ + {{ "All" | translate }} + +
+
+ + {{ subtype || "-" }} + +
+
+
+
+ {{ + row.sampleSubtype || "-" + }} +
+ + + + {{ + "Duration" | translate + }} + + {{ formatNumberWithDecimals(row.time, 0) }} + {{ row.timeUnit }} + + + + + + +
+ Loading... +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ science +
+ {{ "Sample Environment" | translate }} +
+ + + + + + + + + + + + + + + + + +
{{ "Environment Type" | translate }} + + {{ sampleEnv.value }} + {{ sampleEnv.unit }} + + + {{ sampleEnv | json }} + +
{{ "Sample Holder Type" | translate }} + + {{ sampleHolder.value }} + + {{ sampleHolder.unit }} + + + {{ sampleHolder | json }} + +
{{ "Additional Environment" | translate }} + {{ + getObjectKeys( + dataset.scientificMetadata["sampleProperties"].value[ + "additional_environment" + ] + ).join(", ") + }} +
+
+
+
+ + + + +
+ science +
+ {{ "Sample Properties" | translate }} +
+ + + + + + + + + + +
{{ key }} + + {{ formatNumberWithDecimals(sampleProps[key].value, 3) }} + + {{ sampleProps[key].unit }} + + + {{ formatNumberWithDecimals(sampleProps[key], 3) }} + +
+
; + unit?: string; + }; + [key: string]: any; +} /** * Component to show details for a data set, using the @@ -65,6 +90,18 @@ import { TranslateService } from "@ngx-translate/core"; export class DatasetDetailComponent implements OnInit, OnDestroy { private subscriptions: Subscription[] = []; + private hasLoadedDatablocks = false; + private searchTermChanged = new Subject(); + private destroy$ = new Subject(); + + displayedColumns: string[] = [ + "numor", + "measurementType", + "sampleType", + "sampleSubtype", + "time", + ]; + form: FormGroup; userProfile$ = this.store.select(selectProfile); isAdmin$ = this.store.select(selectIsAdmin); @@ -78,7 +115,9 @@ export class DatasetDetailComponent implements OnInit, OnDestroy { datasetWithout$ = this.store.select(selectCurrentDatasetWithoutFileInfo); attachments$ = this.store.select(selectCurrentAttachments); loading$ = this.store.select(selectIsLoading); - instrument: Instrument | undefined; + dataset$ = this.store.select(selectCurrentDataset); + datablocks$ = this.store.select(selectCurrentOrigDatablocks); + instruments: Instrument[] = []; proposal: ProposalClass | undefined; sample: SampleClass | undefined; user: ReturnedUserDto | undefined; @@ -87,6 +126,26 @@ export class DatasetDetailComponent implements OnInit, OnDestroy { show = false; readonly separatorKeyCodes: number[] = [ENTER, COMMA, SPACE]; + sampleRows: any[] = []; + pageSize = 500; + currentPage = 0; + allDatafiles: string[] = []; + isLoadingMore = false; + hasMoreData = true; + + activeFilters: { [key: string]: string } = {}; + numorSearchTerm = ""; + isSearching = false; + searchResultCount = 0; + searchTimeout: any = null; + uniqueMeasurementTypes: string[] = []; + uniqueSampleTypes: string[] = []; + uniqueSampleSubtypes: string[] = []; + filteredSampleRows: any[] = []; + showMeasurementTypeFilter = false; + showSampleTypeFilter = false; + showSampleSubtypeFilter = false; + constructor( @Inject(DOCUMENT) private document: Document, public appConfigService: AppConfigService, @@ -96,8 +155,403 @@ export class DatasetDetailComponent implements OnInit, OnDestroy { private store: Store, private router: Router, private fb: FormBuilder, + private searchService: SearchService, ) { this.translateService.use("datasetDefault"); + + // Setup search term subscription with debounce + this.searchTermChanged + .pipe( + debounceTime(300), // Debounce by 300ms + switchMap((term: string) => { + this.isSearching = true; + + if (!term) { + // No search term, just reset and return + this.resetSearch(); + this.isSearching = false; + return []; + } + + // First apply filters to already loaded data + this.applyFilters(); + + // If there are unloaded files, search them using the service + if (this.allDatafiles.length > this.sampleRows.length) { + const loadedFilePaths = new Set( + this.sampleRows.map((row) => row.numor), + ); + const unloadedFiles = this.allDatafiles + .filter((file) => !loadedFilePaths.has(file)) + .map((filename) => { + // Type cast scientificMetadata to our interface type + const metadata = (this.dataset?.scientificMetadata || + {}) as ScientificMetadata; + return { + numor: filename || "", + measurementType: metadata.measurementType?.value || "", + sampleType: metadata.sampleType?.value || "", + sampleSubtype: metadata.sampleSubtype?.value || "", + time: metadata.duration?.value?.[filename] || "", + timeUnit: metadata.duration?.unit || "", + }; + }); + + return this.searchService.searchFiles( + unloadedFiles, + term, + this.activeFilters, + ); + } + + return []; + }), + takeUntil(this.destroy$), + ) + .subscribe( + (matchedRows) => { + if (matchedRows.length > 0) { + // Add new rows to our sampleRows if they aren't already there + const existingNumors = new Set( + this.sampleRows.map((row) => row.numor), + ); + const newRows = matchedRows.filter( + (row) => !existingNumors.has(row.numor), + ); + + if (newRows.length > 0) { + this.sampleRows = [...this.sampleRows, ...newRows]; + // Re-apply filters to update our displayed results + this.applyFilters(); + } + } + + this.isSearching = false; + }, + (error) => { + console.error("Error during search:", error); + this.isSearching = false; + }, + ); + } + + private extractSampleRows( + metadata: unknown, + datafiles?: string[], + page = 0, + pageSize = 500, + append = false, + ): unknown[] { + if (!metadata || !datafiles?.length) { + return []; + } + + // Store all datafiles first time + if (page === 0) { + this.allDatafiles = [...datafiles]; + this.currentPage = 0; + this.hasMoreData = datafiles.length > pageSize; + } + + const start = page * pageSize; + const end = Math.min(start + pageSize, datafiles.length); + + if (start >= datafiles.length) { + this.hasMoreData = false; + return append ? [...this.sampleRows] : []; + } + + const meta = metadata as Record; + const filesToProcess = datafiles.slice(start, end); + + const rows = filesToProcess.map((filename) => ({ + numor: filename || "", + measurementType: meta.measurementType?.value || "", + sampleType: meta.sampleType?.value || "", + sampleSubtype: meta.sampleSubtype?.value || "", + time: meta.duration?.value?.[filename] || "", + timeUnit: meta.duration?.unit || "", + })); + + const result = append ? [...this.sampleRows, ...rows] : rows; + + if (page === 0) { + this.filteredSampleRows = [...result]; + this.activeFilters = {}; + setTimeout(() => this.extractUniqueColumnValues(), 0); + } else if (append) { + this.applyFilters(); + } + + return result; + } + + private extractUniqueColumnValues(): void { + if (!this.sampleRows?.length) { + this.uniqueMeasurementTypes = []; + this.uniqueSampleTypes = []; + this.uniqueSampleSubtypes = []; + return; + } + + const measurementTypes = new Set(); + const sampleTypes = new Set(); + const sampleSubtypes = new Set(); + + this.sampleRows.forEach((row) => { + if (row.measurementType && row.measurementType.trim()) { + measurementTypes.add(row.measurementType); + } + if (row.sampleType && row.sampleType.trim()) { + sampleTypes.add(row.sampleType); + } + if (row.sampleSubtype && row.sampleSubtype.trim()) { + sampleSubtypes.add(row.sampleSubtype); + } + }); + + this.uniqueMeasurementTypes = Array.from(measurementTypes).sort(); + this.uniqueSampleTypes = Array.from(sampleTypes).sort(); + this.uniqueSampleSubtypes = Array.from(sampleSubtypes).sort(); + } + + private applyFilters(): void { + if (Object.keys(this.activeFilters).length === 0 && !this.numorSearchTerm) { + this.filteredSampleRows = [...this.sampleRows]; + this.searchResultCount = this.filteredSampleRows.length; + return; + } + + this.filteredSampleRows = this.sampleRows.filter((row) => { + if ( + this.numorSearchTerm && + !row.numor.toLowerCase().includes(this.numorSearchTerm.toLowerCase()) + ) { + return false; + } + + for (const [key, value] of Object.entries(this.activeFilters)) { + if (row[key] !== value) { + return false; + } + } + return true; + }); + + this.searchResultCount = this.filteredSampleRows.length; + } + + private async performDeepSearch(): Promise { + if (!this.numorSearchTerm || !this.dataset?.scientificMetadata) { + return; + } + + this.isSearching = true; + + try { + const matchingRows: any[] = []; + const searchTerm = this.numorSearchTerm.toLowerCase(); + const loadedFilePaths = new Set(this.sampleRows.map((row) => row.numor)); + const unloadedFiles = this.allDatafiles.filter( + (file) => !loadedFilePaths.has(file), + ); + + if (unloadedFiles.length === 0) { + return; + } + + for (let i = 0; i < unloadedFiles.length; i += this.pageSize) { + await new Promise((resolve) => setTimeout(resolve, 0)); + + const end = Math.min(i + this.pageSize, unloadedFiles.length); + const chunk = unloadedFiles.slice(i, end); + + const meta = this.dataset.scientificMetadata as Record< + string, + { value: string; unit: string } + >; + + const matchingFiles = chunk.filter((filename) => + filename.toLowerCase().includes(searchTerm), + ); + + const newRows = matchingFiles.map((filename) => ({ + numor: filename || "", + measurementType: meta.measurementType?.value || "", + sampleType: meta.sampleType?.value || "", + sampleSubtype: meta.sampleSubtype?.value || "", + time: meta.duration?.value?.[filename] || "", + timeUnit: meta.duration?.unit || "", + })); + + matchingRows.push(...newRows); + } + + // If we have active filters, we need to apply them to the search results + const filteredMatchingRows = matchingRows.filter((row) => { + for (const [key, value] of Object.entries(this.activeFilters)) { + if (row[key] !== value) { + return false; + } + } + return true; + }); + + // Merge with existing search results from loaded data + this.sampleRows = [...this.sampleRows, ...matchingRows]; + + // Update filtered rows with all matching rows (both from loaded data and deep search) + this.applyFilters(); + } finally { + this.isSearching = false; + } + } + + /** + * Reset the search and restore original pagination behavior + */ + private resetSearch(): void { + this.currentPage = 0; + this.hasMoreData = this.allDatafiles.length > this.pageSize; + + // Re-extract initial rows to reset the view + if (this.dataset?.scientificMetadata) { + this.sampleRows = this.extractSampleRows( + this.dataset.scientificMetadata, + this.allDatafiles, + 0, + this.pageSize, + false, + ) as any[]; + } + + this.applyFilters(); + } + + onNumorSearch(event: Event): void { + const input = event.target as HTMLInputElement; + this.numorSearchTerm = input.value; + + // Emit the new search term to trigger the search pipeline + this.searchTermChanged.next(this.numorSearchTerm); + + event.stopPropagation(); + } + + clearNumorSearch(event: Event): void { + this.numorSearchTerm = ""; + this.searchTermChanged.next(""); + event.stopPropagation(); + } + + @HostListener("document:click", ["$event"]) + onDocumentClick(event: MouseEvent): void { + this.showMeasurementTypeFilter = false; + this.showSampleTypeFilter = false; + this.showSampleSubtypeFilter = false; + } + + toggleMeasurementTypeFilter(event: MouseEvent): void { + event.stopPropagation(); + this.showMeasurementTypeFilter = !this.showMeasurementTypeFilter; + this.showSampleTypeFilter = false; + this.showSampleSubtypeFilter = false; + this.extractUniqueColumnValues(); + } + + toggleSampleTypeFilter(event: MouseEvent): void { + event.stopPropagation(); + this.showSampleTypeFilter = !this.showSampleTypeFilter; + this.showMeasurementTypeFilter = false; + this.showSampleSubtypeFilter = false; + this.extractUniqueColumnValues(); + } + + toggleSampleSubtypeFilter(event: MouseEvent): void { + event.stopPropagation(); + this.showSampleSubtypeFilter = !this.showSampleSubtypeFilter; + this.showMeasurementTypeFilter = false; + this.showSampleTypeFilter = false; + this.extractUniqueColumnValues(); + } + + applyMeasurementTypeFilter(type: string | null, event?: MouseEvent): void { + if (event) { + event.stopPropagation(); + } + if (type === null) { + delete this.activeFilters["measurementType"]; + } else { + this.activeFilters["measurementType"] = type; + } + this.applyFilters(); + this.showMeasurementTypeFilter = false; + } + + applySampleTypeFilter(type: string | null, event?: MouseEvent): void { + if (event) { + event.stopPropagation(); + } + if (type === null) { + delete this.activeFilters["sampleType"]; + } else { + this.activeFilters["sampleType"] = type; + } + this.applyFilters(); + this.showSampleTypeFilter = false; + } + + applySampleSubtypeFilter(subtype: string | null, event?: MouseEvent): void { + if (event) { + event.stopPropagation(); + } + if (subtype === null) { + delete this.activeFilters["sampleSubtype"]; + } else { + this.activeFilters["sampleSubtype"] = subtype; + } + this.applyFilters(); + this.showSampleSubtypeFilter = false; + } + + loadMoreRows(): void { + if ( + !this.hasMoreData || + this.isLoadingMore || + !this.dataset?.scientificMetadata + ) { + return; + } + + this.isLoadingMore = true; + this.currentPage++; + + // Small timeout to prevent UI freezing when loading new data + setTimeout(() => { + this.sampleRows = this.extractSampleRows( + this.dataset.scientificMetadata, + this.allDatafiles, + this.currentPage, + this.pageSize, + true, // append to existing rows + ); + this.isLoadingMore = false; + }, 100); + } + + onVirtualScroll(event: any): void { + // Load more data when user scrolls near the bottom + const scrollPosition = event.target.scrollTop + event.target.clientHeight; + const scrollHeight = event.target.scrollHeight; + + // Load more when user is within 200px of the bottom + if ( + scrollHeight - scrollPosition < 200 && + this.hasMoreData && + !this.isLoadingMore + ) { + this.loadMoreRows(); + } } ngOnInit() { @@ -106,24 +560,31 @@ export class DatasetDetailComponent implements OnInit, OnDestroy { description: new FormControl("", [Validators.required]), keywords: this.fb.array([]), }); - this.subscriptions.push( - this.store.select(selectCurrentDataset).subscribe((dataset) => { + combineLatest([ + this.dataset$, + this.accessGroups$, + this.isAdmin$, + ]).subscribe(([dataset, groups, isAdmin]) => { this.dataset = dataset; - if (this.dataset) { - combineLatest([this.accessGroups$, this.isAdmin$]).subscribe( - ([groups, isAdmin]) => { - this.editingAllowed = - groups.indexOf(this.dataset.ownerGroup) !== -1 || isAdmin; - }, - ); + if (dataset && !this.hasLoadedDatablocks) { + this.editingAllowed = + groups.indexOf(dataset.ownerGroup) !== -1 || isAdmin; + // Only fetch if we haven't already + this.store.dispatch(fetchOrigDatablocksAction({ pid: dataset.pid })); + + if (dataset.instrumentIds && dataset.instrumentIds.length > 0) { + this.store.dispatch(fetchInstrumentsAction()); + } + this.hasLoadedDatablocks = true; + this.show = true; } }), ); this.subscriptions.push( - this.store.select(selectCurrentInstrument).subscribe((instrument) => { - this.instrument = instrument; + this.store.select(selectInstruments).subscribe((instruments) => { + this.instruments = instruments || []; }), ); @@ -146,6 +607,37 @@ export class DatasetDetailComponent implements OnInit, OnDestroy { } }), ); + + this.subscriptions.push( + combineLatest([this.dataset$, this.datablocks$]).subscribe( + ([dataset, datablocks]) => { + if (dataset && datablocks) { + // Check both are available + this.dataset = dataset; + const files: string[] = []; + datablocks.forEach((block) => { + if (block.dataFileList) { + // Add null check for dataFileList + block.dataFileList.forEach((file) => { + files.push(file.path); + }); + } + }); + + if (dataset.scientificMetadata && files.length > 0) { + this.currentPage = 0; + this.sampleRows = this.extractSampleRows( + dataset.scientificMetadata, + files, + 0, + this.pageSize, + false, + ); + } + } + }, + ), + ); } onEditModeEnable() { @@ -264,6 +756,10 @@ export class DatasetDetailComponent implements OnInit, OnDestroy { this.subscriptions.forEach((subscription) => { subscription.unsubscribe(); }); + + // Clean up our observables + this.destroy$.next(); + this.destroy$.complete(); } onCopy(pid: string) { @@ -297,4 +793,101 @@ export class DatasetDetailComponent implements OnInit, OnDestroy { openAttachment(encoded: string) { this.attachmentService.openAttachment(encoded); } + + getObjectKeys(obj: unknown): string[] { + return Object.keys(obj); + } + + /** + * Filters and cleans sample properties, removing any properties that should be excluded + */ + filterSampleProperties( + sampleProps: Record, + ): Record { + if (!sampleProps) { + return {}; + } + + // Create a copy so we don't modify the original object + const filteredProps = { ...sampleProps }; + + // Remove the property to exclude + if ("additional_environment" in filteredProps) { + delete filteredProps["additional_environment"]; + } + + // Also apply the empty values cleaning + return this.clearEmptyValues(filteredProps); + } + + /** + * Removes empty string values from an object (modifies the object in-place) + */ + clearEmptyValues(obj: Record): Record { + if (!obj || typeof obj !== "object") { + return obj; + } + + const queue = [obj]; + while (queue.length > 0) { + const current = queue.pop(); + for (const key in current) { + if ( + typeof current[key] === "string" && + (current[key] as string).trim() === "" + ) { + // Remove empty/whitespace-only strings + delete current[key]; + } else if (typeof current[key] === "object" && current[key] !== null) { + // Process nested objects + queue.push(current[key] as Record); + } + } + } + + return obj; + } + + /** + * Checks if a value is an object (but not an array or null) + */ + isObject(val: unknown): boolean { + return val !== null && typeof val === "object" && !Array.isArray(val); + } + + formatNumberWithDecimals(value: unknown, number_decimals: number): string { + // Handle null/undefined/empty cases + if (value === null || value === undefined || value === "") { + return ""; + } + + const num = parseFloat(String(value)); + + // Return original value if not a valid number + if (isNaN(num) || !isFinite(num)) { + return String(value); + } + + // Use scientific notation for specific ranges + if ((num !== 0 && Math.abs(num) < 1) || Math.abs(num) > 1000) { + return num.toExponential(number_decimals); + } + + // Use fixed notation for other numbers + return num.toFixed(number_decimals); + } + + openHDF5Viewer(url: string, row: unknown = null): void { + window.open(url, "_blank", "noopener,noreferrer"); + } + + getInstrumentName(instrumentId: string): string { + if (!instrumentId) return "-"; + + // Just search directly in the instruments array + const foundInstrument = this.instruments.find( + (i) => i.pid === instrumentId, + ); + return foundInstrument?.name || instrumentId; + } } diff --git a/src/app/datasets/dataset-details-dashboard/dataset-details-dashboard.component.ts b/src/app/datasets/dataset-details-dashboard/dataset-details-dashboard.component.ts index 5b9f41941..a765fd8a4 100644 --- a/src/app/datasets/dataset-details-dashboard/dataset-details-dashboard.component.ts +++ b/src/app/datasets/dataset-details-dashboard/dataset-details-dashboard.component.ts @@ -275,12 +275,19 @@ export class DatasetDetailsDashboardComponent fetchSampleAction({ sampleId: this.dataset.sampleId }), ); } - if ("instrumentId" in this.dataset && this.dataset.instrumentId) { - this.store.dispatch( - fetchInstrumentAction({ - pid: this.dataset.instrumentId, - }), - ); + + if ( + "instrumentIds" in this.dataset && + this.dataset.instrumentIds?.length + ) { + // Fetch each instrument in the array + this.dataset.instrumentIds.forEach((instrumentId) => { + this.store.dispatch( + fetchInstrumentAction({ + pid: instrumentId, + }), + ); + }); } } } diff --git a/src/app/datasets/dataset-table/_dataset-table-theme.scss b/src/app/datasets/dataset-table/_dataset-table-theme.scss index b9cc4b06c..c2c582d36 100644 --- a/src/app/datasets/dataset-table/_dataset-table-theme.scss +++ b/src/app/datasets/dataset-table/_dataset-table-theme.scss @@ -7,7 +7,11 @@ .dataset-table { .settings-button { - color: mat.get-color-from-palette($hover, "lighter"); + color: mat.get-color-from-palette($hover, "hover"); + + &:hover { + color: mat.get-color-from-palette($hover, "hover"); + } } mat-table { diff --git a/src/app/datasets/dataset-table/dataset-table.component.html b/src/app/datasets/dataset-table/dataset-table.component.html index fe8299015..b377a67b5 100644 --- a/src/app/datasets/dataset-table/dataset-table.component.html +++ b/src/app/datasets/dataset-table/dataset-table.component.html @@ -67,7 +67,7 @@
perm_identity
-
Pid
+
Proposal Dataset
@@ -85,7 +85,7 @@
fingerprint
-
Name
+
Title
@@ -340,6 +340,54 @@ + + + +
+
+ developer_board +
+
Instrument Name
+
+
+ + + + {{ getInstrumentName(id) }}{{ !last ? ",\u00A0" : "" }} + + + + {{ getInstrumentName(dataset.instrumentId) || "-" }} + + +
+ + + + +
+
+ list_alt +
+
Number of Files
+
+
+ + {{ dataset.numberOfFiles || "-" }} + +
+