From 802e2966641b187f4e597b331d8d22853a5e76c9 Mon Sep 17 00:00:00 2001 From: Eryk Kullikowski Date: Tue, 16 Sep 2025 14:54:51 +0200 Subject: [PATCH 01/21] Reapply "Refactor and enhance metadata handling in Submit and MetadataSelector components" This reverts commit 84b8af591ebcc737373801a3b1e00b95b687f94b. --- angular.json | 18 +- eslint.config.mjs | 27 +- karma.conf.js | 2 +- src/app/app.component.spec.ts | 5 +- src/app/app.routs.ts | 7 + src/app/compare/compare.component.spec.ts | 5 +- src/app/compare/compare.component.ts | 20 +- src/app/compute/compute.component.spec.ts | 5 +- src/app/compute/compute.component.ts | 30 +- src/app/connect/connect.component.spec.ts | 5 +- src/app/connect/connect.component.ts | 156 +++--- src/app/data.service.spec.ts | 5 +- src/app/datafile/datafile.component.spec.ts | 17 +- src/app/datafile/datafile.component.ts | 8 +- src/app/doi.lookup.service.spec.ts | 5 +- src/app/download/download.component.spec.ts | 5 +- src/app/download/download.component.ts | 53 +- .../executablefile.component.spec.ts | 5 +- .../executablefile.component.ts | 4 +- .../metadata-selector.component.html | 57 ++ .../metadata-selector.component.scss | 0 .../metadata-selector.component.spec.ts | 63 +++ .../metadata-selector.component.ts | 529 ++++++++++++++++++ src/app/oauth.service.spec.ts | 5 +- src/app/plugin.service.spec.ts | 5 +- src/app/repo.lookup.service.spec.ts | 5 +- src/app/shared/constants.ts | 14 +- src/app/shared/notification.service.ts | 5 +- src/app/shared/types.ts | 2 +- src/app/submit/submit.component.html | 23 +- src/app/submit/submit.component.spec.ts | 30 +- src/app/submit/submit.component.ts | 324 +---------- .../submitted-file.component.ts | 2 +- src/app/utils.service.ts | 4 +- tests/governance/smoke.test.js | 2 +- 35 files changed, 953 insertions(+), 499 deletions(-) create mode 100644 src/app/metadata-selector/metadata-selector.component.html create mode 100644 src/app/metadata-selector/metadata-selector.component.scss create mode 100644 src/app/metadata-selector/metadata-selector.component.spec.ts create mode 100644 src/app/metadata-selector/metadata-selector.component.ts diff --git a/angular.json b/angular.json index 560f6ec..75a04f7 100644 --- a/angular.json +++ b/angular.json @@ -24,13 +24,14 @@ "base": "dist/datasync" }, "index": "src/index.html", - "polyfills": [ - "src/polyfills.ts" - ], + "polyfills": ["src/polyfills.ts"], "tsConfig": "tsconfig.app.json", "inlineStyleLanguage": "scss", "assets": ["src/favicon.ico", "src/assets"], - "styles": ["node_modules/bootstrap/dist/css/bootstrap.min.css", "src/styles.scss"], + "styles": [ + "node_modules/bootstrap/dist/css/bootstrap.min.css", + "src/styles.scss" + ], "scripts": [ "node_modules/bootstrap/dist/js/bootstrap.bundle.min.js" ], @@ -90,14 +91,15 @@ "builder": "@angular/build:karma", "options": { "main": "src/test.ts", - "polyfills": [ - "src/polyfills.ts" - ], + "polyfills": ["src/polyfills.ts"], "tsConfig": "tsconfig.spec.json", "karmaConfig": "karma.conf.js", "inlineStyleLanguage": "scss", "assets": ["src/favicon.ico", "src/assets"], - "styles": ["node_modules/bootstrap/dist/css/bootstrap.min.css", "src/styles.scss"], + "styles": [ + "node_modules/bootstrap/dist/css/bootstrap.min.css", + "src/styles.scss" + ], "scripts": [] } } diff --git a/eslint.config.mjs b/eslint.config.mjs index b485598..37dd52f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -10,31 +10,34 @@ export default [ { rules: { // TypeScript specific rules (without type information) - "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], + "@typescript-eslint/no-unused-vars": [ + "error", + { argsIgnorePattern: "^_" }, + ], "@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/prefer-for-of": "error", - + // General code quality rules "prefer-const": "error", "no-var": "error", "prefer-arrow-callback": "error", "prefer-template": "error", - "no-console": ["warn", { "allow": ["warn", "error"] }], - + "no-console": ["warn", { allow: ["warn", "error"] }], + // Angular specific patterns "no-restricted-syntax": [ "error", { - "selector": "CallExpression[callee.name='alert']", - "message": "Use proper notification system instead of alert()" - } - ] - } + selector: "CallExpression[callee.name='alert']", + message: "Use proper notification system instead of alert()", + }, + ], + }, }, { files: ["src/**/*.spec.ts"], rules: { - "@typescript-eslint/no-explicit-any": "off" - } - } + "@typescript-eslint/no-explicit-any": "off", + }, + }, ]; diff --git a/karma.conf.js b/karma.conf.js index a4fd0c6..427754e 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -4,7 +4,7 @@ module.exports = function (config) { config.set({ basePath: "", - frameworks: ["jasmine"], + frameworks: ["jasmine"], plugins: [ require("karma-jasmine"), require("karma-chrome-launcher"), diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 0c217a7..03ccb62 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -1,7 +1,10 @@ import { TestBed } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; import { AppComponent } from './app.component'; -import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { + provideHttpClient, + withInterceptorsFromDi, +} from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { PrimeNG } from 'primeng/config'; diff --git a/src/app/app.routs.ts b/src/app/app.routs.ts index ca95f5a..60c96f2 100644 --- a/src/app/app.routs.ts +++ b/src/app/app.routs.ts @@ -18,6 +18,13 @@ export const routes: Routes = [ loadComponent: () => import('./submit/submit.component').then((m) => m.SubmitComponent), }, + { + path: 'metadata-selector', + loadComponent: () => + import('./metadata-selector/metadata-selector.component').then( + (m) => m.MetadataSelectorComponent, + ), + }, { path: 'compute', loadComponent: () => diff --git a/src/app/compare/compare.component.spec.ts b/src/app/compare/compare.component.spec.ts index ade5397..651f5d3 100644 --- a/src/app/compare/compare.component.spec.ts +++ b/src/app/compare/compare.component.spec.ts @@ -1,5 +1,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { + provideHttpClient, + withInterceptorsFromDi, +} from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { CompareComponent } from './compare.component'; diff --git a/src/app/compare/compare.component.ts b/src/app/compare/compare.component.ts index fb2de99..fcd0d2c 100644 --- a/src/app/compare/compare.component.ts +++ b/src/app/compare/compare.component.ts @@ -1,5 +1,3 @@ - - // Author: Eryk Kulikowski @ KU Leuven (2023). Apache 2.0 License import { Component, OnInit, OnDestroy, inject } from '@angular/core'; @@ -39,7 +37,7 @@ import { FilterItem, SubscriptionManager } from '../shared/types'; templateUrl: './compare.component.html', styleUrls: ['./compare.component.scss'], imports: [ - CommonModule, + CommonModule, ButtonDirective, Ripple, TreeTableModule, @@ -49,12 +47,16 @@ import { FilterItem, SubscriptionManager } from '../shared/types'; DatafileComponent, ], }) -export class CompareComponent implements OnInit, OnDestroy, SubscriptionManager { +export class CompareComponent + implements OnInit, OnDestroy, SubscriptionManager +{ private readonly dataUpdatesService = inject(DataUpdatesService); private readonly dataStateService = inject(DataStateService); private readonly credentialsService = inject(CredentialsService); private readonly router = inject(Router); - private readonly folderActionUpdateService = inject(FolderActionUpdateService); + private readonly folderActionUpdateService = inject( + FolderActionUpdateService, + ); private readonly pluginService = inject(PluginService); private readonly utils = inject(UtilsService); @@ -136,7 +138,7 @@ export class CompareComponent implements OnInit, OnDestroy, SubscriptionManager ngOnDestroy(): void { // Clean up all subscriptions - this.subscriptions.forEach(sub => sub.unsubscribe()); + this.subscriptions.forEach((sub) => sub.unsubscribe()); this.subscriptions.clear(); } @@ -317,7 +319,11 @@ export class CompareComponent implements OnInit, OnDestroy, SubscriptionManager submit(): void { this.dataStateService.updateState(this.data); - this.router.navigate(['/submit']); + if (this.isNewDataset()) { + this.router.navigate(['/metadata-selector']); + } else { + this.router.navigate(['/submit']); + } } // UI helpers diff --git a/src/app/compute/compute.component.spec.ts b/src/app/compute/compute.component.spec.ts index afcb456..3b8885d 100644 --- a/src/app/compute/compute.component.spec.ts +++ b/src/app/compute/compute.component.spec.ts @@ -1,5 +1,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { + provideHttpClient, + withInterceptorsFromDi, +} from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { RouterTestingModule } from '@angular/router/testing'; diff --git a/src/app/compute/compute.component.ts b/src/app/compute/compute.component.ts index 2ba5035..cbd481d 100644 --- a/src/app/compute/compute.component.ts +++ b/src/app/compute/compute.component.ts @@ -38,13 +38,7 @@ import { ExecutablefileComponent } from '../executablefile/executablefile.compon import { AutosizeModule } from 'ngx-autosize'; // RxJS -import { - debounceTime, - firstValueFrom, - map, - Observable, - Subject, -} from 'rxjs'; +import { debounceTime, firstValueFrom, map, Observable, Subject } from 'rxjs'; // Constants and types import { APP_CONSTANTS } from '../shared/constants'; @@ -69,7 +63,9 @@ import { SubscriptionManager } from '../shared/types'; AutosizeModule, ], }) -export class ComputeComponent implements OnInit, OnDestroy, SubscriptionManager { +export class ComputeComponent + implements OnInit, OnDestroy, SubscriptionManager +{ private readonly dvObjectLookupService = inject(DvObjectLookupService); private readonly pluginService = inject(PluginService); dataService = inject(DataService); @@ -145,23 +141,23 @@ export class ComputeComponent implements OnInit, OnDestroy, SubscriptionManager (err) => (this.doiItems = [ { - label: `search failed: ${ err.message}`, + label: `search failed: ${err.message}`, value: err.message, }, ]), ), error: (err) => (this.doiItems = [ - { label: `search failed: ${ err.message}`, value: err.message }, + { label: `search failed: ${err.message}`, value: err.message }, ]), }); } ngOnDestroy(): void { // Clean up all subscriptions - this.subscriptions.forEach(sub => sub.unsubscribe()); + this.subscriptions.forEach((sub) => sub.unsubscribe()); this.subscriptions.clear(); - + // Clean up existing observable subscriptions this.datasetSearchResultsSubscription?.unsubscribe(); } @@ -234,7 +230,7 @@ export class ComputeComponent implements OnInit, OnDestroy, SubscriptionManager return; } this.doiItems = [ - { label: `searching "${ searchTerm }"...`, value: searchTerm }, + { label: `searching "${searchTerm}"...`, value: searchTerm }, ]; this.datasetSearchSubject.next(searchTerm); } @@ -267,7 +263,9 @@ export class ComputeComponent implements OnInit, OnDestroy, SubscriptionManager }, error: (err) => { subscription.unsubscribe(); - this.notificationService.showError(`Getting executable files failed: ${err.error}`); + this.notificationService.showError( + `Getting executable files failed: ${err.error}`, + ); }, }); } @@ -338,7 +336,9 @@ export class ComputeComponent implements OnInit, OnDestroy, SubscriptionManager error: (err) => { subscription.unsubscribe(); this.loading = false; - this.notificationService.showError(`Getting computation results failed: ${err.error}`); + this.notificationService.showError( + `Getting computation results failed: ${err.error}`, + ); }, }); } diff --git a/src/app/connect/connect.component.spec.ts b/src/app/connect/connect.component.spec.ts index 89dced2..4df88c3 100644 --- a/src/app/connect/connect.component.spec.ts +++ b/src/app/connect/connect.component.spec.ts @@ -1,5 +1,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { + provideHttpClient, + withInterceptorsFromDi, +} from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { RouterTestingModule } from '@angular/router/testing'; import { provideNoopAnimations } from '@angular/platform-browser/animations'; diff --git a/src/app/connect/connect.component.ts b/src/app/connect/connect.component.ts index 4ec90bd..4fcf15c 100644 --- a/src/app/connect/connect.component.ts +++ b/src/app/connect/connect.component.ts @@ -38,13 +38,7 @@ import { Skeleton } from 'primeng/skeleton'; import { Tree } from 'primeng/tree'; // RxJS -import { - debounceTime, - firstValueFrom, - map, - Observable, - Subject, -} from 'rxjs'; +import { debounceTime, firstValueFrom, map, Observable, Subject } from 'rxjs'; // Constants and types import { APP_CONSTANTS } from '../shared/constants'; @@ -71,7 +65,9 @@ const new_dataset = 'New Dataset'; Tree, ], }) -export class ConnectComponent implements OnInit, OnDestroy, SubscriptionManager { +export class ConnectComponent + implements OnInit, OnDestroy, SubscriptionManager +{ private readonly router = inject(Router); private readonly dataStateService = inject(DataStateService); private readonly datasetService = inject(DatasetService); @@ -172,14 +168,14 @@ export class ConnectComponent implements OnInit, OnDestroy, SubscriptionManager (err) => (this.repoNames = [ { - label: `search failed: ${ err.message}`, + label: `search failed: ${err.message}`, value: err.message, }, ]), ), error: (err) => (this.repoNames = [ - { label: `search failed: ${ err.message}`, value: err.message }, + { label: `search failed: ${err.message}`, value: err.message }, ]), }); this.collectionSearchResultsSubscription = @@ -191,14 +187,14 @@ export class ConnectComponent implements OnInit, OnDestroy, SubscriptionManager (err) => (this.collectionItems = [ { - label: `search failed: ${ err.message}`, + label: `search failed: ${err.message}`, value: err.message, }, ]), ), error: (err) => (this.collectionItems = [ - { label: `search failed: ${ err.message}`, value: err.message }, + { label: `search failed: ${err.message}`, value: err.message }, ]), }); this.datasetSearchResultsSubscription = @@ -210,14 +206,14 @@ export class ConnectComponent implements OnInit, OnDestroy, SubscriptionManager (err) => (this.doiItems = [ { - label: `search failed: ${ err.message}`, + label: `search failed: ${err.message}`, value: err.message, }, ]), ), error: (err) => (this.doiItems = [ - { label: `search failed: ${ err.message}`, value: err.message }, + { label: `search failed: ${err.message}`, value: err.message }, ]), }); this.route.queryParams.subscribe((params) => { @@ -383,9 +379,9 @@ export class ConnectComponent implements OnInit, OnDestroy, SubscriptionManager ngOnDestroy(): void { // Clean up all subscriptions - this.subscriptions.forEach(sub => sub.unsubscribe()); + this.subscriptions.forEach((sub) => sub.unsubscribe()); this.subscriptions.clear(); - + // Clean up existing observable subscriptions this.repoSearchResultsSubscription?.unsubscribe(); this.collectionSearchResultsSubscription?.unsubscribe(); @@ -446,14 +442,13 @@ export class ConnectComponent implements OnInit, OnDestroy, SubscriptionManager if (url.includes('?')) { clId = '&client_id='; } - url = - `${url + - clId + - encodeURIComponent(tg.oauth_client_id) - }&redirect_uri=${ - encodeURIComponent(this.pluginService.getRedirectUri()) - }&response_type=code&state=${ - encodeURIComponent(JSON.stringify(loginState))}`; + url = `${ + url + clId + encodeURIComponent(tg.oauth_client_id) + }&redirect_uri=${encodeURIComponent( + this.pluginService.getRedirectUri(), + )}&response_type=code&state=${encodeURIComponent( + JSON.stringify(loginState), + )}`; // + '&code_challenge=' + nonce + '&code_challenge_method=S256'; if (scopes) { if (url.includes('scope=')) { @@ -464,7 +459,7 @@ export class ConnectComponent implements OnInit, OnDestroy, SubscriptionManager } url = url.replace(scopeStr, `scope=${encodeURIComponent(scopes)}`); } else { - url = `${url }&scope=${ encodeURIComponent(scopes)}`; + url = `${url}&scope=${encodeURIComponent(scopes)}`; } } location.href = url; @@ -598,14 +593,14 @@ export class ConnectComponent implements OnInit, OnDestroy, SubscriptionManager const s = strings[i]; if (s === undefined || s === '' || s === 'loading') { cnt++; - res = `${res }\n- ${ names[i]}`; + res = `${res}\n- ${names[i]}`; } } const err = this.parseUrl(); if (err) { cnt++; - res = `${res }\n\n${ err}`; + res = `${res}\n\n${err}`; } if (cnt === 0) { @@ -622,16 +617,34 @@ export class ConnectComponent implements OnInit, OnDestroy, SubscriptionManager // Check basic required fields if (!this.pluginId || this.pluginId === 'loading') return false; if (!this.datasetId || this.datasetId === 'loading') return false; - + // Check Dataverse token (required for connection) - if (this.showDVToken() && (!this.dataverseToken || this.dataverseToken.trim() === '')) return false; + if ( + this.showDVToken() && + (!this.dataverseToken || this.dataverseToken.trim() === '') + ) + return false; // Check plugin-specific required fields - if (this.getSourceUrlFieldName() && (!this.sourceUrl || this.sourceUrl.trim() === '')) return false; - if (this.getTokenFieldName() && (!this.token || this.token.trim() === '')) return false; - if (this.getOptionFieldName() && (!this.option || this.option === 'loading')) return false; - if (this.getUsernameFieldName() && (!this.user || this.user.trim() === '')) return false; - if (this.getRepoNameFieldName() && (!this.getRepoName() || this.getRepoName()!.trim() === '')) return false; + if ( + this.getSourceUrlFieldName() && + (!this.sourceUrl || this.sourceUrl.trim() === '') + ) + return false; + if (this.getTokenFieldName() && (!this.token || this.token.trim() === '')) + return false; + if ( + this.getOptionFieldName() && + (!this.option || this.option === 'loading') + ) + return false; + if (this.getUsernameFieldName() && (!this.user || this.user.trim() === '')) + return false; + if ( + this.getRepoNameFieldName() && + (!this.getRepoName() || this.getRepoName()!.trim() === '') + ) + return false; // Check URL parsing if applicable if (this.pluginService.getPlugin(this.pluginId).parseSourceUrlField) { @@ -652,22 +665,22 @@ export class ConnectComponent implements OnInit, OnDestroy, SubscriptionManager */ private validateUrlParsing(): string | undefined { if (!this.sourceUrl) return 'Source URL is required'; - + let toSplit = this.sourceUrl; if (toSplit.endsWith('/')) { toSplit = toSplit.substring(0, toSplit.length - 1); } - + const splitted = toSplit.split('://'); if (splitted?.length !== 2) { return 'Malformed source url'; } - + const pathParts = splitted[1].split('/'); if (pathParts?.length <= 2) { return 'Malformed source url'; } - + return undefined; } @@ -683,7 +696,7 @@ export class ConnectComponent implements OnInit, OnDestroy, SubscriptionManager */ get connectButtonClass(): string { const baseClasses = 'p-button-sm p-button-raised'; - return this.isConnectReady + return this.isConnectReady ? `${baseClasses} p-button-primary` : `${baseClasses} p-button-secondary`; } @@ -711,7 +724,7 @@ export class ConnectComponent implements OnInit, OnDestroy, SubscriptionManager if (splitted?.length == 2) { splitted = splitted[1].split('/'); if (splitted?.length > 2) { - this.url = `https://${ splitted[0]}`; + this.url = `https://${splitted[0]}`; this.repoName = splitted.slice(1, splitted.length).join('/'); if (this.repoName.endsWith('.git')) { this.repoName = this.repoName.substring(0, this.repoName.length - 4); @@ -819,7 +832,9 @@ export class ConnectComponent implements OnInit, OnDestroy, SubscriptionManager this.getUsernameFieldName() && (this.user === undefined || this.user === '') ) { - this.notificationService.showError(`${this.getUsernameFieldName()} is missing`); + this.notificationService.showError( + `${this.getUsernameFieldName()} is missing`, + ); return; } if ( @@ -827,14 +842,18 @@ export class ConnectComponent implements OnInit, OnDestroy, SubscriptionManager (this.getRepoName() === undefined || this.getRepoName() === '') && !isSearch ) { - this.notificationService.showError(`${this.getRepoNameFieldName()} is missing`); + this.notificationService.showError( + `${this.getRepoNameFieldName()} is missing`, + ); return; } if ( this.getTokenFieldName() && (this.token === undefined || this.token === '') ) { - this.notificationService.showError(`${this.getTokenFieldName()} is missing`); + this.notificationService.showError( + `${this.getTokenFieldName()} is missing`, + ); return; } if ( @@ -895,7 +914,7 @@ export class ConnectComponent implements OnInit, OnDestroy, SubscriptionManager return; } this.repoNames = [ - { label: `searching "${ searchTerm }"...`, value: searchTerm }, + { label: `searching "${searchTerm}"...`, value: searchTerm }, ]; this.repoSearchSubject.next(searchTerm); } @@ -1004,7 +1023,9 @@ export class ConnectComponent implements OnInit, OnDestroy, SubscriptionManager ); this.getRepoToken(scopes); } else { - this.notificationService.showError(`Branch lookup failed: ${err.error}`); + this.notificationService.showError( + `Branch lookup failed: ${err.error}`, + ); this.branchItems = []; this.option = undefined; this.optionsLoading = false; @@ -1059,9 +1080,7 @@ export class ConnectComponent implements OnInit, OnDestroy, SubscriptionManager } getDataverseToken(): void { - const url = - `${this.pluginService.getExternalURL() - }/dataverseuser.xhtml?selectTab=apiTokenTab`; + const url = `${this.pluginService.getExternalURL()}/dataverseuser.xhtml?selectTab=apiTokenTab`; window.open(url, '_blank'); } @@ -1158,7 +1177,7 @@ export class ConnectComponent implements OnInit, OnDestroy, SubscriptionManager return; } this.collectionItems = [ - { label: `searching "${ searchTerm }"...`, value: searchTerm }, + { label: `searching "${searchTerm}"...`, value: searchTerm }, ]; this.collectionSearchSubject.next(searchTerm); } @@ -1184,9 +1203,9 @@ export class ConnectComponent implements OnInit, OnDestroy, SubscriptionManager // Add "Create new dataset" option to the beginning of the list const createNewOption: SelectItem = { label: '+ Create new dataset', - value: 'CREATE_NEW_DATASET' + value: 'CREATE_NEW_DATASET', }; - + comp.doiItems = [createNewOption, ...items]; comp.datasetId = undefined; } @@ -1202,23 +1221,26 @@ export class ConnectComponent implements OnInit, OnDestroy, SubscriptionManager } newDataset() { - const datasetId = - `${this.collectionId ? this.collectionId! : '' }:${ new_dataset}`; - + const datasetId = `${this.collectionId ? this.collectionId! : ''}:${new_dataset}`; + // Create the new dataset option - const newDatasetOption: SelectItem = { - label: new_dataset, - value: datasetId + const newDatasetOption: SelectItem = { + label: new_dataset, + value: datasetId, }; - + // Add it to the dropdown options if not already there - const existingIndex = this.doiItems.findIndex(item => item.value === datasetId); + const existingIndex = this.doiItems.findIndex( + (item) => item.value === datasetId, + ); if (existingIndex === -1) { // Remove the "Create new dataset" option temporarily and add the actual new dataset - const filteredItems = this.doiItems.filter(item => item.value !== 'CREATE_NEW_DATASET'); + const filteredItems = this.doiItems.filter( + (item) => item.value !== 'CREATE_NEW_DATASET', + ); this.doiItems = [newDatasetOption, ...filteredItems]; } - + this.datasetId = datasetId; } @@ -1233,7 +1255,7 @@ export class ConnectComponent implements OnInit, OnDestroy, SubscriptionManager return; } this.doiItems = [ - { label: `searching "${ searchTerm }"...`, value: searchTerm }, + { label: `searching "${searchTerm}"...`, value: searchTerm }, ]; this.datasetSearchSubject.next(searchTerm); } @@ -1247,24 +1269,24 @@ export class ConnectComponent implements OnInit, OnDestroy, SubscriptionManager this.dataverseToken, ), ); - + // Add "Create new dataset" option to the beginning of results const createNewOption: SelectItem = { label: '+ Create new dataset', - value: 'CREATE_NEW_DATASET' + value: 'CREATE_NEW_DATASET', }; - + return [createNewOption, ...items]; } onDatasetSelectionChange(event: any) { const selectedValue = event.value; - + if (selectedValue === 'CREATE_NEW_DATASET') { // Handle creation of new dataset this.newDataset(); this.showNewDatasetCreatedMessage = true; - + // Hide the message after 3 seconds setTimeout(() => { this.showNewDatasetCreatedMessage = false; diff --git a/src/app/data.service.spec.ts b/src/app/data.service.spec.ts index 381add7..0848051 100644 --- a/src/app/data.service.spec.ts +++ b/src/app/data.service.spec.ts @@ -1,5 +1,8 @@ import { TestBed } from '@angular/core/testing'; -import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { + provideHttpClient, + withInterceptorsFromDi, +} from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { DataService } from './data.service'; diff --git a/src/app/datafile/datafile.component.spec.ts b/src/app/datafile/datafile.component.spec.ts index 1154df1..577c3d6 100644 --- a/src/app/datafile/datafile.component.spec.ts +++ b/src/app/datafile/datafile.component.spec.ts @@ -1,5 +1,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { + provideHttpClient, + withInterceptorsFromDi, +} from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { DatafileComponent } from './datafile.component'; @@ -8,12 +11,12 @@ describe('DatafileComponent', () => { let fixture: ComponentFixture; beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [DatafileComponent], - providers: [ - provideHttpClient(withInterceptorsFromDi()), - provideHttpClientTesting(), - ], + await TestBed.configureTestingModule({ + imports: [DatafileComponent], + providers: [ + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + ], }) // Shallow render to avoid PrimeNG TreeTable internal provider requirements during unit tests .overrideComponent(DatafileComponent, { diff --git a/src/app/datafile/datafile.component.ts b/src/app/datafile/datafile.component.ts index 69a2707..cf85c72 100644 --- a/src/app/datafile/datafile.component.ts +++ b/src/app/datafile/datafile.component.ts @@ -57,7 +57,7 @@ export class DatafileComponent implements OnInit { return ''; } if (this.isInFilter()) { - return `${datafile.path ? `${datafile.path }/` : ''}${datafile.name}`; + return `${datafile.path ? `${datafile.path}/` : ''}${datafile.name}`; } return `${datafile.name}`; } @@ -90,7 +90,9 @@ export class DatafileComponent implements OnInit { case Filestatus.Deleted: return 'File only in dataset (deleted in repository)'; } - return this.loading() ? 'Loading status...' : 'Click refresh to re-check status'; + return this.loading() + ? 'Loading status...' + : 'Click refresh to re-check status'; } action(): string { @@ -117,7 +119,7 @@ export class DatafileComponent implements OnInit { ) { return ''; } - return `${datafile.path ? `${datafile.path }/` : ''}${datafile.name}`; + return `${datafile.path ? `${datafile.path}/` : ''}${datafile.name}`; } toggleAction(): void { diff --git a/src/app/doi.lookup.service.spec.ts b/src/app/doi.lookup.service.spec.ts index a17695d..951e99e 100644 --- a/src/app/doi.lookup.service.spec.ts +++ b/src/app/doi.lookup.service.spec.ts @@ -1,5 +1,8 @@ import { TestBed } from '@angular/core/testing'; -import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { + provideHttpClient, + withInterceptorsFromDi, +} from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { DvObjectLookupService } from './dvobject.lookup.service'; diff --git a/src/app/download/download.component.spec.ts b/src/app/download/download.component.spec.ts index 07fa71b..ef245ac 100644 --- a/src/app/download/download.component.spec.ts +++ b/src/app/download/download.component.spec.ts @@ -1,5 +1,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { + provideHttpClient, + withInterceptorsFromDi, +} from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { RouterTestingModule } from '@angular/router/testing'; diff --git a/src/app/download/download.component.ts b/src/app/download/download.component.ts index d4419bc..51dfeed 100644 --- a/src/app/download/download.component.ts +++ b/src/app/download/download.component.ts @@ -35,13 +35,7 @@ import { Tree } from 'primeng/tree'; import { DownladablefileComponent } from '../downloadablefile/downladablefile.component'; // RxJS -import { - debounceTime, - firstValueFrom, - map, - Observable, - Subject, -} from 'rxjs'; +import { debounceTime, firstValueFrom, map, Observable, Subject } from 'rxjs'; // Constants and types import { APP_CONSTANTS } from '../shared/constants'; @@ -64,7 +58,9 @@ import { SubscriptionManager } from '../shared/types'; Button, ], }) -export class DownloadComponent implements OnInit, OnDestroy, SubscriptionManager { +export class DownloadComponent + implements OnInit, OnDestroy, SubscriptionManager +{ private readonly dvObjectLookupService = inject(DvObjectLookupService); private readonly pluginService = inject(PluginService); dataService = inject(DataService); @@ -184,14 +180,14 @@ export class DownloadComponent implements OnInit, OnDestroy, SubscriptionManager (err) => (this.doiItems = [ { - label: `search failed: ${ err.message}`, + label: `search failed: ${err.message}`, value: err.message, }, ]), ), error: (err) => (this.doiItems = [ - { label: `search failed: ${ err.message}`, value: err.message }, + { label: `search failed: ${err.message}`, value: err.message }, ]), }); this.repoSearchResultsSubscription = @@ -203,14 +199,14 @@ export class DownloadComponent implements OnInit, OnDestroy, SubscriptionManager (err) => (this.repoNames = [ { - label: `search failed: ${ err.message}`, + label: `search failed: ${err.message}`, value: err.message, }, ]), ), error: (err) => (this.repoNames = [ - { label: `search failed: ${ err.message}`, value: err.message }, + { label: `search failed: ${err.message}`, value: err.message }, ]), }); } @@ -299,7 +295,7 @@ export class DownloadComponent implements OnInit, OnDestroy, SubscriptionManager return; } this.doiItems = [ - { label: `searching "${ searchTerm }"...`, value: searchTerm }, + { label: `searching "${searchTerm}"...`, value: searchTerm }, ]; this.datasetSearchSubject.next(searchTerm); } @@ -332,7 +328,9 @@ export class DownloadComponent implements OnInit, OnDestroy, SubscriptionManager }, error: (err) => { subscription.unsubscribe(); - this.notificationService.showError(`Getting downloadable files failed: ${err.error}`); + this.notificationService.showError( + `Getting downloadable files failed: ${err.error}`, + ); }, }); } @@ -406,7 +404,9 @@ export class DownloadComponent implements OnInit, OnDestroy, SubscriptionManager error: (err) => { console.error('something went wrong: '); console.error(`${err.error}`); - this.notificationService.showError(`Download request failed: ${err.error}`); + this.notificationService.showError( + `Download request failed: ${err.error}`, + ); httpSubscription.unsubscribe(); }, }); @@ -439,7 +439,9 @@ export class DownloadComponent implements OnInit, OnDestroy, SubscriptionManager (this.getRepoName() === undefined || this.getRepoName() === '') && !isSearch ) { - this.notificationService.showError(`${this.getRepoNameFieldName()} is missing`); + this.notificationService.showError( + `${this.getRepoNameFieldName()} is missing`, + ); return; } if ( @@ -480,7 +482,7 @@ export class DownloadComponent implements OnInit, OnDestroy, SubscriptionManager return; } this.repoNames = [ - { label: `searching "${ searchTerm }"...`, value: searchTerm }, + { label: `searching "${searchTerm}"...`, value: searchTerm }, ]; this.repoSearchSubject.next(searchTerm); } @@ -542,7 +544,9 @@ export class DownloadComponent implements OnInit, OnDestroy, SubscriptionManager httpSubscription.unsubscribe(); }, error: (err) => { - this.notificationService.showError(`Branch lookup failed: ${err.error}`); + this.notificationService.showError( + `Branch lookup failed: ${err.error}`, + ); this.branchItems = []; this.option = undefined; this.optionsLoading = false; @@ -591,14 +595,11 @@ export class DownloadComponent implements OnInit, OnDestroy, SubscriptionManager if (url.includes('?')) { clId = '&client_id='; } - url = - `${url + - clId + - encodeURIComponent(tg.oauth_client_id) - }&redirect_uri=${ - this.pluginService.getRedirectUri() - }&response_type=code&state=${ - encodeURIComponent(JSON.stringify(loginState))}`; + url = `${ + url + clId + encodeURIComponent(tg.oauth_client_id) + }&redirect_uri=${this.pluginService.getRedirectUri()}&response_type=code&state=${encodeURIComponent( + JSON.stringify(loginState), + )}`; location.href = url; } else { window.open(url, '_blank'); diff --git a/src/app/executablefile/executablefile.component.spec.ts b/src/app/executablefile/executablefile.component.spec.ts index f2b4869..8b8522b 100644 --- a/src/app/executablefile/executablefile.component.spec.ts +++ b/src/app/executablefile/executablefile.component.spec.ts @@ -1,6 +1,9 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ExecutablefileComponent } from './executablefile.component'; -import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { + provideHttpClient, + withInterceptorsFromDi, +} from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; describe('ExecutablefileComponent', () => { diff --git a/src/app/executablefile/executablefile.component.ts b/src/app/executablefile/executablefile.component.ts index 83115b3..a2fd5e9 100644 --- a/src/app/executablefile/executablefile.component.ts +++ b/src/app/executablefile/executablefile.component.ts @@ -85,7 +85,9 @@ export class ExecutablefileComponent implements OnInit { }, error: (err) => { subscription.unsubscribe(); - this.notificationService.showError(`Checking access to queue failed: ${err.error}`); + this.notificationService.showError( + `Checking access to queue failed: ${err.error}`, + ); this.spinning = false; this.queue = undefined; }, diff --git a/src/app/metadata-selector/metadata-selector.component.html b/src/app/metadata-selector/metadata-selector.component.html new file mode 100644 index 0000000..4f9182b --- /dev/null +++ b/src/app/metadata-selector/metadata-selector.component.html @@ -0,0 +1,57 @@ + + + +
+ +

Make sure not to openly publish sensitive information, copyrighted materials or third-party libraries.

+ +

Click on "OK" to start the update/transfer. You can close the window and the transfer will continue.

+

You will receive an email to notify you if the update/transfer has been unsuccessful.

+

+
+
+ + +
+
+ + OK + +
+ + + + + + + + + + + + + + + + + + + + + +
Metadata that will be copied
FieldValue + +
diff --git a/src/app/metadata-selector/metadata-selector.component.scss b/src/app/metadata-selector/metadata-selector.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/metadata-selector/metadata-selector.component.spec.ts b/src/app/metadata-selector/metadata-selector.component.spec.ts new file mode 100644 index 0000000..2309473 --- /dev/null +++ b/src/app/metadata-selector/metadata-selector.component.spec.ts @@ -0,0 +1,63 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { + provideHttpClient, + withInterceptorsFromDi, +} from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { of } from 'rxjs'; +import { Router } from '@angular/router'; +import { MetadataSelectorComponent } from './metadata-selector.component'; +import { DatasetService } from '../dataset.service'; + +describe('MetadataSelectorComponent', () => { + let component: MetadataSelectorComponent; + let fixture: ComponentFixture; + let routerNavigateSpy: jasmine.Spy; + + beforeEach(async () => { + const routerStub = { + navigate: jasmine.createSpy('navigate'), + } as unknown as Router; + const datasetStub = { + newDataset: () => of({ persistentId: 'doi:10.1234/created' }), + getMetadata: () => + of({ + datasetVersion: { + metadataBlocks: { + citation: { + displayName: 'Citation', + name: 'citation', + fields: [], + }, + }, + }, + }), + } as unknown as DatasetService; + await TestBed.configureTestingModule({ + imports: [MetadataSelectorComponent], + providers: [ + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + { provide: Router, useValue: routerStub }, + { provide: DatasetService, useValue: datasetStub }, + ], + }) + // Keep template as-is; component uses PrimeNG lightweightly + .compileComponents(); + + fixture = TestBed.createComponent(MetadataSelectorComponent); + component = fixture.componentInstance; + routerNavigateSpy = TestBed.inject(Router).navigate as jasmine.Spy; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should navigate to submit after continueSubmit when new dataset is created', async () => { + (component as any).pid = 'collectionId:New Dataset'; + await component.continueSubmit(); + expect(routerNavigateSpy).toHaveBeenCalledWith(['/submit']); + }); +}); diff --git a/src/app/metadata-selector/metadata-selector.component.ts b/src/app/metadata-selector/metadata-selector.component.ts new file mode 100644 index 0000000..a346cd4 --- /dev/null +++ b/src/app/metadata-selector/metadata-selector.component.ts @@ -0,0 +1,529 @@ +// Author: Eryk Kulikowski @ KU Leuven (2023). Apache 2.0 License + +import { Component, OnDestroy, OnInit, inject } from '@angular/core'; +import { Router } from '@angular/router'; +import { Location } from '@angular/common'; +import { Subscription, firstValueFrom } from 'rxjs'; + +// Services +import { DataUpdatesService } from '../data.updates.service'; +import { DataStateService } from '../data.state.service'; +import { PluginService } from '../plugin.service'; +import { UtilsService } from '../utils.service'; +import { CredentialsService } from '../credentials.service'; +import { DataService } from '../data.service'; +import { DatasetService } from '../dataset.service'; +import { NotificationService } from '../shared/notification.service'; + +// Models +import { Datafile, Fileaction, Filestatus } from '../models/datafile'; +// import { StoreResult } from '../models/store-result'; +import { CompareResult } from '../models/compare-result'; +import { MetadataRequest } from '../models/metadata-request'; +import { + Field, + Fieldaction, + FieldDictonary, + Metadata, + MetadataField, +} from '../models/field'; + +// PrimeNG +import { TreeNode, PrimeTemplate } from 'primeng/api'; +import { ButtonDirective, Button } from 'primeng/button'; +import { Ripple } from 'primeng/ripple'; +import { Dialog } from 'primeng/dialog'; +import { Checkbox } from 'primeng/checkbox'; +import { FormsModule } from '@angular/forms'; +import { TreeTableModule } from 'primeng/treetable'; + +// Components +import { MetadatafieldComponent } from '../metadatafield/metadatafield.component'; + +// Constants and types +import { APP_CONSTANTS } from '../shared/constants'; +import { SubscriptionManager } from '../shared/types'; + +@Component({ + selector: 'app-metadata-selector', + templateUrl: './metadata-selector.component.html', + styleUrls: ['./metadata-selector.component.scss'], + imports: [ + ButtonDirective, + Button, + Ripple, + Dialog, + Checkbox, + FormsModule, + PrimeTemplate, + TreeTableModule, + MetadatafieldComponent, + ], +}) +export class MetadataSelectorComponent + implements OnInit, OnDestroy, SubscriptionManager +{ + private readonly dataStateService = inject(DataStateService); + private readonly dataUpdatesService = inject(DataUpdatesService); + private readonly location = inject(Location); + private readonly pluginService = inject(PluginService); + private readonly router = inject(Router); + private readonly utils = inject(UtilsService); + dataService = inject(DataService); + private readonly credentialsService = inject(CredentialsService); + private readonly datasetService = inject(DatasetService); + private readonly notificationService = inject(NotificationService); + + // Subscriptions for cleanup + private readonly subscriptions = new Set(); + + // Icon constants + readonly icon_warning = APP_CONSTANTS.ICONS.WARNING; + readonly icon_copy = APP_CONSTANTS.ICONS.UPDATE; + readonly icon_update = 'pi pi-clone'; + readonly icon_delete = 'pi pi-trash'; + + data: Datafile[] = []; + pid = ''; + datasetUrl = ''; + + // local state derived from this.data: + created: Datafile[] = []; + updated: Datafile[] = []; + deleted: Datafile[] = []; + + disabled = false; + submitted = false; + done = false; + sendEmailOnSuccess = false; + popup = false; + hasAccessToCompute = false; + + metadata?: Metadata; + + root?: TreeNode; + rootNodeChildren: TreeNode[] = []; + rowNodeMap: Map> = new Map>(); + + id = 0; + + constructor() {} + + ngOnInit(): void { + this.loadData(); + const subscription = this.dataService + .checkAccessToQueue( + '', + this.credentialsService.credentials.dataverse_token, + '', + ) + .subscribe({ + next: (access) => { + subscription.unsubscribe(); + this.hasAccessToCompute = access.access; + }, + error: () => { + subscription.unsubscribe(); + }, + }); + } + + ngOnDestroy(): void { + // Clean up all subscriptions + this.subscriptions.forEach((sub) => sub.unsubscribe()); + this.subscriptions.clear(); + } + + getDataSubscription(): void { + const dataSubscription = this.dataUpdatesService + .updateData(this.data, this.pid) + .subscribe({ + next: async (res: CompareResult) => { + dataSubscription?.unsubscribe(); + if (res.data !== undefined) { + this.setData(res.data); + } + if (!this.hasUnfinishedDataFiles()) { + this.done = true; + } else { + await this.utils.sleep(1000); + this.getDataSubscription(); + } + }, + error: (err) => { + dataSubscription?.unsubscribe(); + this.notificationService.showError( + `Getting status of data failed: ${err.error}`, + ); + this.router.navigate(['/connect']); + }, + }); + } + + hasUnfinishedDataFiles(): boolean { + return ( + this.created.some((d) => d.status !== Filestatus.Equal) || + this.updated.some((d) => d.status !== Filestatus.Equal) || + this.deleted.some((d) => d.status !== Filestatus.New) + ); + } + + loadData(): void { + const value = this.dataStateService.getCurrentValue(); + this.pid = value && value.id ? value.id : ''; + const data = value?.data; + if (data) { + this.setData(data); + } + } + + async setData(data: Datafile[]) { + this.data = data; + this.created = this.toCreate(); + this.updated = this.toUpdate(); + this.deleted = this.toDelete(); + this.data = [...this.created, ...this.updated, ...this.deleted]; + if (this.pid.endsWith(':New Dataset')) { + const credentials = this.credentialsService.credentials; + const req: MetadataRequest = { + pluginId: credentials.pluginId, + plugin: credentials.plugin, + repoName: credentials.repo_name, + url: credentials.url, + option: credentials.option, + user: credentials.user, + token: credentials.token, + dvToken: credentials.dataverse_token, + compareResult: this.dataStateService.getCurrentValue(), + }; + this.metadata = await firstValueFrom( + this.datasetService.getMetadata(req), + ); + const rowDataMap = this.mapFields(this.metadata); + rowDataMap.forEach((v) => this.addChild(v, rowDataMap)); + this.root = rowDataMap.get(''); + this.rowNodeMap = rowDataMap; + if (this.root?.children) { + this.rootNodeChildren = this.root.children; + } + } + } + + toCreate(): Datafile[] { + const result: Datafile[] = []; + this.data.forEach((datafile) => { + if (datafile.action === Fileaction.Copy) { + result.push(datafile); + } + }); + return result; + } + + toUpdate(): Datafile[] { + const result: Datafile[] = []; + this.data.forEach((datafile) => { + if (datafile.action === Fileaction.Update) { + result.push(datafile); + } + }); + return result; + } + + toDelete(): Datafile[] { + const result: Datafile[] = []; + this.data.forEach((datafile) => { + if (datafile.action === Fileaction.Delete) { + if ( + datafile.attributes?.remoteHashType === undefined || + datafile.attributes?.remoteHashType === '' + ) { + // file from unknown origin, by setting the hash type to not empty value we can detect in comparison that the file got deleted + datafile.attributes!.remoteHashType = 'unknown'; + datafile.attributes!.remoteHash = 'unknown'; + } + result.push(datafile); + } + }); + return result; + } + + submit() { + if (this.credentialsService.credentials.plugin === 'globus') { + this.continueSubmit(); + } else { + this.popup = true; + } + } + + async continueSubmit() { + this.popup = false; + this.disabled = true; + + // If this is a new dataset flow, first create the dataset based on selected metadata + if (this.pid.endsWith(':New Dataset')) { + const ids = this.pid.split(':'); + const ok = await this.newDataset(ids[0]); + if (!ok) { + this.disabled = false; + return; + } + } + + // After metadata selection (and dataset creation when needed), proceed to the Submit page for files + this.router.navigate(['/submit']); + } + + back(): void { + this.location.back(); + } + + goToDataset() { + window.open(this.datasetUrl, '_blank'); + } + + goToCompute() { + this.router.navigate(['/compute'], { queryParams: { pid: this.pid } }); + } + + sendMails(): boolean { + return this.pluginService.sendMails(); + } + + async newDataset(collectionId: string): Promise { + const metadata = this.filteredMetadata(); + const data = await firstValueFrom( + this.datasetService.newDataset( + collectionId, + this.credentialsService.credentials.dataverse_token, + metadata, + ), + ); + if (data.persistentId !== undefined && data.persistentId !== '') { + this.pid = data.persistentId; + this.credentialsService.credentials.dataset_id = data.persistentId; + return true; + } else { + this.notificationService.showError('Creating new dataset failed'); + return false; + } + } + + action(): string { + if (this.root) { + return MetadatafieldComponent.actionIcon(this.root); + } + return MetadatafieldComponent.icon_ignore; + } + + toggleAction(): void { + if (this.root) { + MetadatafieldComponent.toggleNodeAction(this.root); + } + } + + rowClass(field: Field): string { + switch (field.action) { + case Fieldaction.Ignore: + return ''; + case Fieldaction.Copy: + return 'background-color: #c3e6cb; color: black'; + case Fieldaction.Custom: + return 'background-color: #FFFAA0; color: black'; + } + return ''; + } + + addChild(v: TreeNode, rowDataMap: Map>): void { + if (v.data?.id == '') { + return; + } + const parent = rowDataMap.get(v.data!.parent!)!; + const children = parent.children ? parent.children : []; + parent.children = children.concat(v); + } + + mapFields(metadata: Metadata): Map> { + const rootData: Field = { + id: '', + parent: '', + name: '', + action: Fieldaction.Copy, + }; + + const rowDataMap: Map> = new Map< + string, + TreeNode + >(); + rowDataMap.set('', { + data: rootData, + }); + + metadata.datasetVersion.metadataBlocks.citation.fields.forEach((d) => { + this.addToDataMap(d, '', rowDataMap); + }); + return rowDataMap; + } + + private addToDataMap( + d: MetadataField, + parent: string, + rowDataMap: Map>, + ) { + if ( + d.value && + Array.isArray(d.value) && + d.value.length > 0 && + typeof d.value[0] === 'string' + ) { + let content = d.value[0]; + for (let i = 1; i < d.value.length; i++) { + content = `${content}, ${d.value[i]}`; + } + const id = `${this.id++}`; + d.id = id; + const data: Field = { + id: id, + parent: parent, + name: d.typeName, + action: Fieldaction.Copy, + leafValue: content, + field: d, + }; + rowDataMap.set(id, { + data: data, + }); + } else if (d.value && typeof d.value !== 'string') { + (d.value as FieldDictonary[]).forEach((v) => { + const id = `${this.id++}`; + const data: Field = { + id: id, + parent: parent, + name: d.typeName, + action: Fieldaction.Copy, + field: v, + }; + rowDataMap.set(id, { + data: data, + }); + this.mapChildField(id, v, rowDataMap); + }); + } else { + const id = `${this.id++}`; + d.id = id; + const data: Field = { + id: id, + parent: parent, + name: d.typeName, + action: Fieldaction.Copy, + leafValue: d.value, + field: d, + }; + rowDataMap.set(id, { + data: data, + }); + } + } + + mapChildField( + parent: string, + fieldDictonary: FieldDictonary, + rowDataMap: Map>, + ) { + Object.values(fieldDictonary).forEach((d) => { + this.addToDataMap(d, parent, rowDataMap); + }); + } + + filteredMetadata(): Metadata | undefined { + if ( + !this.metadata || + !this.rootNodeChildren || + this.rootNodeChildren.length === 0 + ) { + return undefined; + } + let res: MetadataField[] = []; + this.metadata.datasetVersion.metadataBlocks.citation.fields.forEach((f) => { + if (this.rowNodeMap.get(f.id!)?.data?.action == Fieldaction.Copy) { + const field: MetadataField = { + expandedvalue: f.expandedvalue, + multiple: f.multiple, + typeClass: f.typeClass, + typeName: f.typeName, + value: f.value, + }; + res = res.concat(field); + } else if ( + f.value && + Array.isArray(f.value) && + f.value.length > 0 && + typeof f.value[0] !== 'string' + ) { + const dicts = this.customValue(f.value as FieldDictonary[]); + if (dicts.length > 0) { + const field: MetadataField = { + expandedvalue: f.expandedvalue, + multiple: f.multiple, + typeClass: f.typeClass, + typeName: f.typeName, + value: dicts, + }; + res = res.concat(field); + } + } + }); + return { + datasetVersion: { + metadataBlocks: { + citation: { + displayName: + this.metadata.datasetVersion.metadataBlocks.citation.displayName, + fields: res, + name: this.metadata.datasetVersion.metadataBlocks.citation.name, + }, + }, + }, + }; + } + + customValue(metadataFields: FieldDictonary[]): FieldDictonary[] { + let res: FieldDictonary[] = []; + metadataFields.forEach((d) => { + const dict: FieldDictonary = {}; + Object.keys(d).forEach((k) => { + const f = d[k]; + if (this.rowNodeMap.get(f.id!)?.data?.action == Fieldaction.Copy) { + const field: MetadataField = { + expandedvalue: f.expandedvalue, + multiple: f.multiple, + typeClass: f.typeClass, + typeName: f.typeName, + value: f.value, + }; + dict[k] = field; + } else if ( + f.value && + Array.isArray(f.value) && + f.value.length > 0 && + typeof f.value[0] !== 'string' + ) { + const dicts = this.customValue(f.value as FieldDictonary[]); + if (dicts.length > 0) { + const field: MetadataField = { + expandedvalue: f.expandedvalue, + multiple: f.multiple, + typeClass: f.typeClass, + typeName: f.typeName, + value: this.customValue(f.value as FieldDictonary[]), + }; + dict[k] = field; + } + } + }); + if (Object.keys(dict).length > 0) { + res = res.concat(dict); + } + }); + return res; + } +} diff --git a/src/app/oauth.service.spec.ts b/src/app/oauth.service.spec.ts index d1ac7d8..243cb49 100644 --- a/src/app/oauth.service.spec.ts +++ b/src/app/oauth.service.spec.ts @@ -1,5 +1,8 @@ import { TestBed } from '@angular/core/testing'; -import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { + provideHttpClient, + withInterceptorsFromDi, +} from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { OauthService } from './oauth.service'; diff --git a/src/app/plugin.service.spec.ts b/src/app/plugin.service.spec.ts index 1f6fb3e..5a9bba1 100644 --- a/src/app/plugin.service.spec.ts +++ b/src/app/plugin.service.spec.ts @@ -1,5 +1,8 @@ import { TestBed } from '@angular/core/testing'; -import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { + provideHttpClient, + withInterceptorsFromDi, +} from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { PluginService } from './plugin.service'; diff --git a/src/app/repo.lookup.service.spec.ts b/src/app/repo.lookup.service.spec.ts index 3044409..25595bd 100644 --- a/src/app/repo.lookup.service.spec.ts +++ b/src/app/repo.lookup.service.spec.ts @@ -1,5 +1,8 @@ import { TestBed } from '@angular/core/testing'; -import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { + provideHttpClient, + withInterceptorsFromDi, +} from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { RepoLookupService } from './repo.lookup.service'; diff --git a/src/app/shared/constants.ts b/src/app/shared/constants.ts index bd303bf..f567308 100644 --- a/src/app/shared/constants.ts +++ b/src/app/shared/constants.ts @@ -21,7 +21,7 @@ export const APP_CONSTANTS = { SUBMIT: 'pi pi-save', COMPARE: 'pi pi-flag', ACTION: 'pi pi-bolt', - WARNING: 'pi pi-stop', + WARNING: 'pi pi-stop', FILTER: 'pi pi-filter', PLAY: 'pi pi-play', NEW_FILE: 'pi pi-plus-circle', @@ -41,12 +41,12 @@ export const APP_CONSTANTS = { // File action styles FILE_ACTION_STYLES: { IGNORE: '', - COPY: 'background-color: #c3e6cb; color: black', - UPDATE: 'background-color: #b8daff; color: black', - DELETE: 'background-color: #f5c6cb; color: black', - // Non-uniform selection: grey/light blue tint - CUSTOM: 'background-color: #e7f1ff; color: #495057', - } + COPY: 'background-color: #c3e6cb; color: black', + UPDATE: 'background-color: #b8daff; color: black', + DELETE: 'background-color: #f5c6cb; color: black', + // Non-uniform selection: grey/light blue tint + CUSTOM: 'background-color: #e7f1ff; color: #495057', + }, } as const; /** diff --git a/src/app/shared/notification.service.ts b/src/app/shared/notification.service.ts index 8eb7cd3..43be8c0 100644 --- a/src/app/shared/notification.service.ts +++ b/src/app/shared/notification.service.ts @@ -4,10 +4,9 @@ import { Injectable } from '@angular/core'; * Service for handling errors and user notifications */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class NotificationService { - /** * Show an error message to the user * TODO: Replace with proper notification system (e.g., toast, snackbar) @@ -52,7 +51,7 @@ export class NotificationService { */ handleHttpError(error: unknown, context?: string): void { let message = 'An unexpected error occurred'; - + if (error && typeof error === 'object' && 'error' in error) { message = String(error.error); } else if (error && typeof error === 'object' && 'message' in error) { diff --git a/src/app/shared/types.ts b/src/app/shared/types.ts index 20df61d..2a8d362 100644 --- a/src/app/shared/types.ts +++ b/src/app/shared/types.ts @@ -31,7 +31,7 @@ export enum LoadingState { IDLE = 'idle', LOADING = 'loading', SUCCESS = 'success', - ERROR = 'error' + ERROR = 'error', } /** diff --git a/src/app/submit/submit.component.html b/src/app/submit/submit.component.html index e3bf96e..b26abf4 100644 --- a/src/app/submit/submit.component.html +++ b/src/app/submit/submit.component.html @@ -31,28 +31,7 @@

- - Metadata that will be copied - - - - - - Field - Value - - - - - - - - - - + Files that will be created diff --git a/src/app/submit/submit.component.spec.ts b/src/app/submit/submit.component.spec.ts index fd5107e..041df3c 100644 --- a/src/app/submit/submit.component.spec.ts +++ b/src/app/submit/submit.component.spec.ts @@ -1,33 +1,49 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { + provideHttpClient, + withInterceptorsFromDi, +} from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { Router } from '@angular/router'; import { SubmitComponent } from './submit.component'; +import { DataStateService } from '../data.state.service'; describe('SubmitComponent', () => { let component: SubmitComponent; let fixture: ComponentFixture; + let routerNavigateSpy: jasmine.Spy; + let dataStateStub: Partial; beforeEach(async () => { + const routerStub = { + navigate: jasmine.createSpy('navigate'), + } as unknown as Router; + dataStateStub = { + getCurrentValue: () => + ({ id: 'collectionId:New Dataset', data: [] }) as any, + }; await TestBed.configureTestingModule({ imports: [SubmitComponent], providers: [ provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting(), + { provide: Router, useValue: routerStub }, + { provide: DataStateService, useValue: dataStateStub }, ], - }) - // Shallow render to avoid PrimeNG TreeTable internal provider requirements during unit tests - .overrideComponent(SubmitComponent, { - set: { template: '
' }, - }) - .compileComponents(); + }).compileComponents(); fixture = TestBed.createComponent(SubmitComponent); component = fixture.componentInstance; + routerNavigateSpy = TestBed.inject(Router).navigate as jasmine.Spy; fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('should redirect to metadata-selector when pid indicates new dataset on init', () => { + expect(routerNavigateSpy).toHaveBeenCalledWith(['/metadata-selector']); + }); }); diff --git a/src/app/submit/submit.component.ts b/src/app/submit/submit.component.ts index 8b374de..d2ba967 100644 --- a/src/app/submit/submit.component.ts +++ b/src/app/submit/submit.component.ts @@ -3,7 +3,7 @@ import { Component, OnInit, OnDestroy, inject } from '@angular/core'; import { Router } from '@angular/router'; import { Location } from '@angular/common'; -import { Subscription, firstValueFrom } from 'rxjs'; +import { Subscription } from 'rxjs'; // Services import { DataUpdatesService } from '../data.updates.service'; @@ -13,33 +13,26 @@ import { PluginService } from '../plugin.service'; import { UtilsService } from '../utils.service'; import { CredentialsService } from '../credentials.service'; import { DataService } from '../data.service'; -import { DatasetService } from '../dataset.service'; +// import { DatasetService } from '../dataset.service'; import { NotificationService } from '../shared/notification.service'; // Models import { Datafile, Fileaction, Filestatus } from '../models/datafile'; import { StoreResult } from '../models/store-result'; import { CompareResult } from '../models/compare-result'; -import { MetadataRequest } from '../models/metadata-request'; -import { - Field, - Fieldaction, - FieldDictonary, - Metadata, - MetadataField, -} from '../models/field'; +// import { MetadataRequest } from '../models/metadata-request'; +// import { Field, Fieldaction, FieldDictonary, Metadata, MetadataField } from '../models/field'; // PrimeNG -import { TreeNode, PrimeTemplate } from 'primeng/api'; +// import { TreeNode, PrimeTemplate } from 'primeng/api'; import { ButtonDirective, Button } from 'primeng/button'; import { Ripple } from 'primeng/ripple'; import { Dialog } from 'primeng/dialog'; import { Checkbox } from 'primeng/checkbox'; import { FormsModule } from '@angular/forms'; -import { TreeTableModule } from 'primeng/treetable'; +// import { TreeTableModule } from 'primeng/treetable'; // Components -import { MetadatafieldComponent } from '../metadatafield/metadatafield.component'; import { SubmittedFileComponent } from '../submitted-file/submitted-file.component'; // Constants and types @@ -52,14 +45,13 @@ import { SubscriptionManager } from '../shared/types'; styleUrls: ['./submit.component.scss'], imports: [ ButtonDirective, + Button, Ripple, Dialog, Checkbox, FormsModule, - PrimeTemplate, - Button, - TreeTableModule, - MetadatafieldComponent, + // PrimeTemplate, + // TreeTableModule, // no longer used here SubmittedFileComponent, ], }) @@ -73,7 +65,7 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { private readonly utils = inject(UtilsService); dataService = inject(DataService); private readonly credentialsService = inject(CredentialsService); - private readonly datasetService = inject(DatasetService); + // private readonly datasetService = inject(DatasetService); private readonly notificationService = inject(NotificationService); // Subscriptions for cleanup @@ -101,18 +93,17 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { popup = false; hasAccessToCompute = false; - metadata?: Metadata; - - root?: TreeNode; - rootNodeChildren: TreeNode[] = []; - rowNodeMap: Map> = new Map>(); - - id = 0; + // Metadata selection is handled in MetadataSelectorComponent - constructor() { } + constructor() {} ngOnInit(): void { this.loadData(); + // If somehow navigated directly while creating a new dataset, send to metadata selection first + if (this.pid.endsWith(':New Dataset')) { + this.router.navigate(['/metadata-selector']); + return; + } const subscription = this.dataService .checkAccessToQueue( '', @@ -132,7 +123,7 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { ngOnDestroy(): void { // Clean up all subscriptions - this.subscriptions.forEach(sub => sub.unsubscribe()); + this.subscriptions.forEach((sub) => sub.unsubscribe()); this.subscriptions.clear(); } @@ -154,7 +145,9 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { }, error: (err) => { dataSubscription?.unsubscribe(); - this.notificationService.showError(`Getting status of data failed: ${err.error}`); + this.notificationService.showError( + `Getting status of data failed: ${err.error}`, + ); this.router.navigate(['/connect']); }, }); @@ -183,30 +176,6 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { this.updated = this.toUpdate(); this.deleted = this.toDelete(); this.data = [...this.created, ...this.updated, ...this.deleted]; - if (this.pid.endsWith(':New Dataset')) { - const credentials = this.credentialsService.credentials; - const req: MetadataRequest = { - pluginId: credentials.pluginId, - plugin: credentials.plugin, - repoName: credentials.repo_name, - url: credentials.url, - option: credentials.option, - user: credentials.user, - token: credentials.token, - dvToken: credentials.dataverse_token, - compareResult: this.dataStateService.getCurrentValue(), - }; - this.metadata = await firstValueFrom( - this.datasetService.getMetadata(req), - ); - const rowDataMap = this.mapFields(this.metadata); - rowDataMap.forEach((v) => this.addChild(v, rowDataMap)); - this.root = rowDataMap.get(''); - this.rowNodeMap = rowDataMap; - if (this.root?.children) { - this.rootNodeChildren = this.root.children; - } - } } toCreate(): Datafile[] { @@ -271,14 +240,7 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { return; } - if (this.pid.endsWith(':New Dataset')) { - const ids = this.pid.split(':'); - const ok = await this.newDataset(ids[0]); - if (!ok) { - this.disabled = false; - return; - } - } + // New dataset creation is handled in MetadataSelectorComponent const httpSubscription = this.submitService .submit(selected, this.sendEmailOnSuccess) @@ -286,7 +248,9 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { next: (data: StoreResult) => { if (data.status !== 'OK') { // this should not happen - this.notificationService.showError(`Store failed, status: ${data.status}`); + this.notificationService.showError( + `Store failed, status: ${data.status}`, + ); this.router.navigate(['/connect']); } else { this.getDataSubscription(); @@ -318,241 +282,7 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { return this.pluginService.sendMails(); } - async newDataset(collectionId: string): Promise { - const metadata = this.filteredMetadata(); - const data = await firstValueFrom( - this.datasetService.newDataset( - collectionId, - this.credentialsService.credentials.dataverse_token, - metadata, - ), - ); - if (data.persistentId !== undefined && data.persistentId !== '') { - this.pid = data.persistentId; - this.credentialsService.credentials.dataset_id = data.persistentId; - return true; - } else { - this.notificationService.showError('Creating new dataset failed'); - return false; - } - } - - action(): string { - if (this.root) { - return MetadatafieldComponent.actionIcon(this.root); - } - return MetadatafieldComponent.icon_ignore; - } - - toggleAction(): void { - if (this.root) { - MetadatafieldComponent.toggleNodeAction(this.root); - } - } - - rowClass(field: Field): string { - switch (field.action) { - case Fieldaction.Ignore: - return ''; - case Fieldaction.Copy: - return 'background-color: #c3e6cb; color: black'; - case Fieldaction.Custom: - return 'background-color: #FFFAA0; color: black'; - } - return ''; - } - - addChild(v: TreeNode, rowDataMap: Map>): void { - if (v.data?.id == '') { - return; - } - const parent = rowDataMap.get(v.data!.parent!)!; - const children = parent.children ? parent.children : []; - parent.children = children.concat(v); - } + // New dataset creation moved to MetadataSelectorComponent - mapFields(metadata: Metadata): Map> { - const rootData: Field = { - id: '', - parent: '', - name: '', - action: Fieldaction.Copy, - }; - - const rowDataMap: Map> = new Map< - string, - TreeNode - >(); - rowDataMap.set('', { - data: rootData, - }); - - metadata.datasetVersion.metadataBlocks.citation.fields.forEach((d) => { - this.addToDataMap(d, '', rowDataMap); - }); - return rowDataMap; - } - - private addToDataMap( - d: MetadataField, - parent: string, - rowDataMap: Map>, - ) { - if ( - d.value && - Array.isArray(d.value) && - d.value.length > 0 && - typeof d.value[0] === 'string' - ) { - let content = d.value[0]; - for (let i = 1; i < d.value.length; i++) { - content = `${content}, ${d.value[i]}`; - } - const id = `${this.id++}`; - d.id = id; - const data: Field = { - id: id, - parent: parent, - name: d.typeName, - action: Fieldaction.Copy, - leafValue: content, - field: d, - }; - rowDataMap.set(id, { - data: data, - }); - } else if (d.value && typeof d.value !== 'string') { - (d.value as FieldDictonary[]).forEach((v) => { - const id = `${this.id++}`; - const data: Field = { - id: id, - parent: parent, - name: d.typeName, - action: Fieldaction.Copy, - field: v, - }; - rowDataMap.set(id, { - data: data, - }); - this.mapChildField(id, v, rowDataMap); - }); - } else { - const id = `${this.id++}`; - d.id = id; - const data: Field = { - id: id, - parent: parent, - name: d.typeName, - action: Fieldaction.Copy, - leafValue: d.value, - field: d, - }; - rowDataMap.set(id, { - data: data, - }); - } - } - - mapChildField( - parent: string, - fieldDictonary: FieldDictonary, - rowDataMap: Map>, - ) { - Object.values(fieldDictonary).forEach((d) => { - this.addToDataMap(d, parent, rowDataMap); - }); - } - - filteredMetadata(): Metadata | undefined { - if ( - !this.metadata || - !this.rootNodeChildren || - this.rootNodeChildren.length === 0 - ) { - return undefined; - } - let res: MetadataField[] = []; - this.metadata.datasetVersion.metadataBlocks.citation.fields.forEach((f) => { - if (this.rowNodeMap.get(f.id!)?.data?.action == Fieldaction.Copy) { - const field: MetadataField = { - expandedvalue: f.expandedvalue, - multiple: f.multiple, - typeClass: f.typeClass, - typeName: f.typeName, - value: f.value, - }; - res = res.concat(field); - } else if ( - f.value && - Array.isArray(f.value) && - f.value.length > 0 && - typeof f.value[0] !== 'string' - ) { - const dicts = this.customValue(f.value as FieldDictonary[]); - if (dicts.length > 0) { - const field: MetadataField = { - expandedvalue: f.expandedvalue, - multiple: f.multiple, - typeClass: f.typeClass, - typeName: f.typeName, - value: dicts, - }; - res = res.concat(field); - } - } - }); - return { - datasetVersion: { - metadataBlocks: { - citation: { - displayName: - this.metadata.datasetVersion.metadataBlocks.citation.displayName, - fields: res, - name: this.metadata.datasetVersion.metadataBlocks.citation.name, - }, - }, - }, - }; - } - - customValue(metadataFields: FieldDictonary[]): FieldDictonary[] { - let res: FieldDictonary[] = []; - metadataFields.forEach((d) => { - const dict: FieldDictonary = {}; - Object.keys(d).forEach((k) => { - const f = d[k]; - if (this.rowNodeMap.get(f.id!)?.data?.action == Fieldaction.Copy) { - const field: MetadataField = { - expandedvalue: f.expandedvalue, - multiple: f.multiple, - typeClass: f.typeClass, - typeName: f.typeName, - value: f.value, - }; - dict[k] = field; - } else if ( - f.value && - Array.isArray(f.value) && - f.value.length > 0 && - typeof f.value[0] !== 'string' - ) { - const dicts = this.customValue(f.value as FieldDictonary[]); - if (dicts.length > 0) { - const field: MetadataField = { - expandedvalue: f.expandedvalue, - multiple: f.multiple, - typeClass: f.typeClass, - typeName: f.typeName, - value: this.customValue(f.value as FieldDictonary[]), - }; - dict[k] = field; - } - } - }); - if (Object.keys(dict).length > 0) { - res = res.concat(dict); - } - }); - return res; - } + // Metadata helper methods removed; file submission only } diff --git a/src/app/submitted-file/submitted-file.component.ts b/src/app/submitted-file/submitted-file.component.ts index ecdcdb1..75b7c18 100644 --- a/src/app/submitted-file/submitted-file.component.ts +++ b/src/app/submitted-file/submitted-file.component.ts @@ -22,7 +22,7 @@ export class SubmittedFileComponent implements OnInit { file(): string { const datafile = this.datafile(); - return `${datafile.path ? `${datafile.path }/` : ''}${this.datafile().name}`; + return `${datafile.path ? `${datafile.path}/` : ''}${this.datafile().name}`; } iconClass(): string { diff --git a/src/app/utils.service.ts b/src/app/utils.service.ts index f600777..8797149 100644 --- a/src/app/utils.service.ts +++ b/src/app/utils.service.ts @@ -46,7 +46,7 @@ export class UtilsService { data.forEach((d) => { let path = ''; d.path!.split('/').forEach((folder) => { - const id = path != '' ? `${path }/${ folder}` : folder; + const id = path != '' ? `${path}/${folder}` : folder; const folderData: Datafile = { path: path, name: folder, @@ -59,7 +59,7 @@ export class UtilsService { }); path = id; }); - rowDataMap.set(`${d.id! }:file`, { + rowDataMap.set(`${d.id!}:file`, { // avoid collisions between folders and files having the same path and name data: d, }); diff --git a/tests/governance/smoke.test.js b/tests/governance/smoke.test.js index 83b65c7..a4ee4f8 100644 --- a/tests/governance/smoke.test.js +++ b/tests/governance/smoke.test.js @@ -4,7 +4,7 @@ function main() { // Output success message using console.log - console.log('Governance smoke: OK'); + console.log("Governance smoke: OK"); // Implicit success exit } From 2ecf72847ae4554289f5c8d1592dac2426092561 Mon Sep 17 00:00:00 2001 From: Eryk Kullikowski Date: Tue, 16 Sep 2025 15:22:48 +0200 Subject: [PATCH 02/21] refactor(metadata-selector): remove unused dialog and checkbox components, streamline submit process --- .../metadata-selector.component.html | 15 +------------- .../metadata-selector.component.ts | 20 ++++--------------- 2 files changed, 5 insertions(+), 30 deletions(-) diff --git a/src/app/metadata-selector/metadata-selector.component.html b/src/app/metadata-selector/metadata-selector.component.html index 4f9182b..7d481a9 100644 --- a/src/app/metadata-selector/metadata-selector.component.html +++ b/src/app/metadata-selector/metadata-selector.component.html @@ -14,20 +14,7 @@

Make sure not to openly publish sensitive information, copyrighted materials or third-party libraries.

- -

Click on "OK" to start the update/transfer. You can close the window and the transfer will continue.

-

You will receive an email to notify you if the update/transfer has been unsuccessful.

-

-
-
- - -
-
- - OK - -
+ diff --git a/src/app/metadata-selector/metadata-selector.component.ts b/src/app/metadata-selector/metadata-selector.component.ts index a346cd4..305d834 100644 --- a/src/app/metadata-selector/metadata-selector.component.ts +++ b/src/app/metadata-selector/metadata-selector.component.ts @@ -30,11 +30,8 @@ import { // PrimeNG import { TreeNode, PrimeTemplate } from 'primeng/api'; -import { ButtonDirective, Button } from 'primeng/button'; +import { ButtonDirective } from 'primeng/button'; import { Ripple } from 'primeng/ripple'; -import { Dialog } from 'primeng/dialog'; -import { Checkbox } from 'primeng/checkbox'; -import { FormsModule } from '@angular/forms'; import { TreeTableModule } from 'primeng/treetable'; // Components @@ -50,11 +47,7 @@ import { SubscriptionManager } from '../shared/types'; styleUrls: ['./metadata-selector.component.scss'], imports: [ ButtonDirective, - Button, Ripple, - Dialog, - Checkbox, - FormsModule, PrimeTemplate, TreeTableModule, MetadatafieldComponent, @@ -95,8 +88,7 @@ export class MetadataSelectorComponent disabled = false; submitted = false; done = false; - sendEmailOnSuccess = false; - popup = false; + // No popup in metadata selector; direct navigation to submit page hasAccessToCompute = false; metadata?: Metadata; @@ -248,15 +240,11 @@ export class MetadataSelectorComponent } submit() { - if (this.credentialsService.credentials.plugin === 'globus') { - this.continueSubmit(); - } else { - this.popup = true; - } + // In metadata selector, clicking Submit should proceed immediately to the submit page + this.continueSubmit(); } async continueSubmit() { - this.popup = false; this.disabled = true; // If this is a new dataset flow, first create the dataset based on selected metadata From 69ff4a79ee38ea590d32ab4c50365d991773a4f5 Mon Sep 17 00:00:00 2001 From: Eryk Kullikowski Date: Tue, 16 Sep 2025 15:59:07 +0200 Subject: [PATCH 03/21] test(submit): add focused unit tests for submit flow, data categorization, and error handling; fix TDZ in subscriptions --- src/app/submit/submit.component.html | 23 +++- src/app/submit/submit.component.spec.ts | 154 +++++++++++++++++++++--- src/app/submit/submit.component.ts | 74 +++++++----- 3 files changed, 205 insertions(+), 46 deletions(-) diff --git a/src/app/submit/submit.component.html b/src/app/submit/submit.component.html index b26abf4..e3bf96e 100644 --- a/src/app/submit/submit.component.html +++ b/src/app/submit/submit.component.html @@ -31,7 +31,28 @@

- + + + + + + + + + + + + + + + + + diff --git a/src/app/submit/submit.component.spec.ts b/src/app/submit/submit.component.spec.ts index 041df3c..187040f 100644 --- a/src/app/submit/submit.component.spec.ts +++ b/src/app/submit/submit.component.spec.ts @@ -1,41 +1,99 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { - provideHttpClient, - withInterceptorsFromDi, -} from '@angular/common/http'; +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { of, throwError } from 'rxjs'; import { Router } from '@angular/router'; import { SubmitComponent } from './submit.component'; import { DataStateService } from '../data.state.service'; +import { CredentialsService } from '../credentials.service'; +import { DataService } from '../data.service'; +import { SubmitService } from '../submit.service'; +import { DatasetService } from '../dataset.service'; +import { DataUpdatesService } from '../data.updates.service'; +import { NotificationService } from '../shared/notification.service'; +import { PluginService } from '../plugin.service'; +import { Location } from '@angular/common'; +import { Fileaction, Filestatus, Datafile } from '../models/datafile'; describe('SubmitComponent', () => { let component: SubmitComponent; let fixture: ComponentFixture; - let routerNavigateSpy: jasmine.Spy; let dataStateStub: Partial; + let credentialsStub: any; + let dataServiceStub: any; + let submitServiceStub: any; + let datasetServiceStub: any; + let dataUpdatesServiceStub: any; + let notificationServiceStub: any; + let pluginServiceStub: any; + let routerStub: any; + let locationStub: any; beforeEach(async () => { - const routerStub = { - navigate: jasmine.createSpy('navigate'), - } as unknown as Router; dataStateStub = { - getCurrentValue: () => - ({ id: 'collectionId:New Dataset', data: [] }) as any, + getCurrentValue: () => ({ id: 'doi:123', data: [] }), + }; + + credentialsStub = { + credentials: { + plugin: 'other', + dataverse_token: 'dv-token', + dataset_id: undefined, + }, + }; + + dataServiceStub = { + checkAccessToQueue: () => of({ access: false }), + }; + + submitServiceStub = { + submit: jasmine.createSpy('submit').and.returnValue(of({ status: 'OK', datasetUrl: 'http://example.com' })), + }; + + datasetServiceStub = { + newDataset: jasmine.createSpy('newDataset').and.returnValue(of({ persistentId: 'doi:new' })), }; + + dataUpdatesServiceStub = { + updateData: jasmine.createSpy('updateData').and.returnValue(of({ data: undefined } as any)), + }; + + notificationServiceStub = { + showError: jasmine.createSpy('showError'), + }; + + pluginServiceStub = { + sendMails: () => true, + }; + + routerStub = { + navigate: jasmine.createSpy('navigate'), + } as unknown as Router; + + locationStub = { + back: jasmine.createSpy('back'), + } as unknown as Location; await TestBed.configureTestingModule({ imports: [SubmitComponent], providers: [ provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting(), - { provide: Router, useValue: routerStub }, { provide: DataStateService, useValue: dataStateStub }, + { provide: CredentialsService, useValue: credentialsStub }, + { provide: DataService, useValue: dataServiceStub }, + { provide: SubmitService, useValue: submitServiceStub }, + { provide: DatasetService, useValue: datasetServiceStub }, + { provide: DataUpdatesService, useValue: dataUpdatesServiceStub }, + { provide: NotificationService, useValue: notificationServiceStub }, + { provide: PluginService, useValue: pluginServiceStub }, + { provide: Router, useValue: routerStub }, + { provide: Location, useValue: locationStub }, ], }).compileComponents(); fixture = TestBed.createComponent(SubmitComponent); component = fixture.componentInstance; - routerNavigateSpy = TestBed.inject(Router).navigate as jasmine.Spy; fixture.detectChanges(); }); @@ -43,7 +101,75 @@ describe('SubmitComponent', () => { expect(component).toBeTruthy(); }); - it('should redirect to metadata-selector when pid indicates new dataset on init', () => { - expect(routerNavigateSpy).toHaveBeenCalledWith(['/metadata-selector']); + it('should categorize files into created/updated/deleted on setData', async () => { + const files: Datafile[] = [ + { action: Fileaction.Copy, status: Filestatus.Equal } as any, + { action: Fileaction.Update, status: Filestatus.Equal } as any, + { action: Fileaction.Delete, status: Filestatus.New, attributes: { remoteHashType: '', remoteHash: '' } } as any, + { action: Fileaction.Ignore, status: Filestatus.Equal } as any, + ]; + await component.setData(files); + expect(component.created.length).toBe(1); + expect(component.updated.length).toBe(1); + expect(component.deleted.length).toBe(1); + // deleted file should have unknown hash type set when missing + expect(component.deleted[0].attributes?.remoteHashType).toBe('unknown'); + // flattened data is concatenation of the three lists + expect(component.data.length).toBe(3); + }); + + it('submit should show popup when plugin is not globus', () => { + credentialsStub.credentials.plugin = 'other'; + component.submit(); + expect(component.popup).toBeTrue(); + }); + + it('continueSubmit should navigate to /connect when no changes selected', async () => { + component.data = [ + { action: Fileaction.Ignore, status: Filestatus.Equal } as any, + ]; + await component.continueSubmit(); + expect((routerStub.navigate as any)).toHaveBeenCalledWith(['/connect']); + }); + + it('continueSubmit should call submit service and mark submitted on OK', async () => { + const files: Datafile[] = [ + { action: Fileaction.Copy, status: Filestatus.Equal } as any, + ]; + await component.setData(files); + await component.continueSubmit(); + expect(submitServiceStub.submit).toHaveBeenCalled(); + expect(component.submitted).toBeTrue(); + expect(component.datasetUrl).toBe('http://example.com'); + }); + + it('newDataset should set pid and return true on success', async () => { + const ok = await component.newDataset('collection-1'); + expect(ok).toBeTrue(); + expect(component.pid).toBe('doi:new'); + expect(credentialsStub.credentials.dataset_id).toBe('doi:new'); }); + + it('getDataSubscription should navigate to /connect and show error on failure', () => { + (dataUpdatesServiceStub.updateData as jasmine.Spy).and.returnValue(throwError(() => ({ error: 'boom' }))); + component.getDataSubscription(); + expect(notificationServiceStub.showError).toHaveBeenCalled(); + expect((routerStub.navigate as any)).toHaveBeenCalledWith(['/connect']); + }); + + it('goToCompute should navigate to compute route with pid', () => { + component.pid = 'doi:abc'; + component.goToCompute(); + expect((routerStub.navigate as any)).toHaveBeenCalledWith(['/compute'], { queryParams: { pid: 'doi:abc' } }); + }); + + it('back should call Location.back', () => { + component.back(); + expect((locationStub.back as any)).toHaveBeenCalled(); + }); + + it('sendMails should return plugin service value', () => { + expect(component.sendMails()).toBeTrue(); + }); + }); diff --git a/src/app/submit/submit.component.ts b/src/app/submit/submit.component.ts index d2ba967..74d5c5b 100644 --- a/src/app/submit/submit.component.ts +++ b/src/app/submit/submit.component.ts @@ -3,7 +3,7 @@ import { Component, OnInit, OnDestroy, inject } from '@angular/core'; import { Router } from '@angular/router'; import { Location } from '@angular/common'; -import { Subscription } from 'rxjs'; +import { Subscription, firstValueFrom } from 'rxjs'; // Services import { DataUpdatesService } from '../data.updates.service'; @@ -13,24 +13,23 @@ import { PluginService } from '../plugin.service'; import { UtilsService } from '../utils.service'; import { CredentialsService } from '../credentials.service'; import { DataService } from '../data.service'; -// import { DatasetService } from '../dataset.service'; +import { DatasetService } from '../dataset.service'; import { NotificationService } from '../shared/notification.service'; // Models import { Datafile, Fileaction, Filestatus } from '../models/datafile'; import { StoreResult } from '../models/store-result'; import { CompareResult } from '../models/compare-result'; -// import { MetadataRequest } from '../models/metadata-request'; -// import { Field, Fieldaction, FieldDictonary, Metadata, MetadataField } from '../models/field'; +// Removed metadata-related imports as metadata selection is no longer handled here // PrimeNG -// import { TreeNode, PrimeTemplate } from 'primeng/api'; +import { PrimeTemplate } from 'primeng/api'; import { ButtonDirective, Button } from 'primeng/button'; import { Ripple } from 'primeng/ripple'; import { Dialog } from 'primeng/dialog'; import { Checkbox } from 'primeng/checkbox'; import { FormsModule } from '@angular/forms'; -// import { TreeTableModule } from 'primeng/treetable'; +// Removed TreeTableModule as metadata table is no longer used // Components import { SubmittedFileComponent } from '../submitted-file/submitted-file.component'; @@ -45,13 +44,12 @@ import { SubscriptionManager } from '../shared/types'; styleUrls: ['./submit.component.scss'], imports: [ ButtonDirective, - Button, Ripple, Dialog, Checkbox, FormsModule, - // PrimeTemplate, - // TreeTableModule, // no longer used here + PrimeTemplate, + Button, SubmittedFileComponent, ], }) @@ -65,7 +63,7 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { private readonly utils = inject(UtilsService); dataService = inject(DataService); private readonly credentialsService = inject(CredentialsService); - // private readonly datasetService = inject(DatasetService); + private readonly datasetService = inject(DatasetService); private readonly notificationService = inject(NotificationService); // Subscriptions for cleanup @@ -93,17 +91,12 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { popup = false; hasAccessToCompute = false; - // Metadata selection is handled in MetadataSelectorComponent + // Removed metadata tree state as metadata selection is not part of Submit anymore - constructor() {} + constructor() { } ngOnInit(): void { this.loadData(); - // If somehow navigated directly while creating a new dataset, send to metadata selection first - if (this.pid.endsWith(':New Dataset')) { - this.router.navigate(['/metadata-selector']); - return; - } const subscription = this.dataService .checkAccessToQueue( '', @@ -123,12 +116,13 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { ngOnDestroy(): void { // Clean up all subscriptions - this.subscriptions.forEach((sub) => sub.unsubscribe()); + this.subscriptions.forEach(sub => sub.unsubscribe()); this.subscriptions.clear(); } getDataSubscription(): void { - const dataSubscription = this.dataUpdatesService + let dataSubscription: Subscription | undefined; + dataSubscription = this.dataUpdatesService .updateData(this.data, this.pid) .subscribe({ next: async (res: CompareResult) => { @@ -145,9 +139,7 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { }, error: (err) => { dataSubscription?.unsubscribe(); - this.notificationService.showError( - `Getting status of data failed: ${err.error}`, - ); + this.notificationService.showError(`Getting status of data failed: ${err.error}`); this.router.navigate(['/connect']); }, }); @@ -240,24 +232,30 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { return; } - // New dataset creation is handled in MetadataSelectorComponent + if (this.pid.endsWith(':New Dataset')) { + const ids = this.pid.split(':'); + const ok = await this.newDataset(ids[0]); + if (!ok) { + this.disabled = false; + return; + } + } - const httpSubscription = this.submitService + let httpSubscription: Subscription | undefined; + httpSubscription = this.submitService .submit(selected, this.sendEmailOnSuccess) .subscribe({ next: (data: StoreResult) => { if (data.status !== 'OK') { // this should not happen - this.notificationService.showError( - `Store failed, status: ${data.status}`, - ); + this.notificationService.showError(`Store failed, status: ${data.status}`); this.router.navigate(['/connect']); } else { this.getDataSubscription(); this.submitted = true; this.datasetUrl = data.datasetUrl!; } - httpSubscription.unsubscribe(); + httpSubscription?.unsubscribe(); }, error: (err) => { this.notificationService.showError(`Store failed: ${err.error}`); @@ -282,7 +280,21 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { return this.pluginService.sendMails(); } - // New dataset creation moved to MetadataSelectorComponent - - // Metadata helper methods removed; file submission only + async newDataset(collectionId: string): Promise { + const data = await firstValueFrom( + this.datasetService.newDataset( + collectionId, + this.credentialsService.credentials.dataverse_token, + undefined, // no metadata selection in Submit; server may apply defaults + ), + ); + if (data.persistentId !== undefined && data.persistentId !== '') { + this.pid = data.persistentId; + this.credentialsService.credentials.dataset_id = data.persistentId; + return true; + } else { + this.notificationService.showError('Creating new dataset failed'); + return false; + } + } } From 395a4f242bfc43ee662547460cc7019cc3d88449 Mon Sep 17 00:00:00 2001 From: Eryk Kullikowski Date: Tue, 16 Sep 2025 15:59:31 +0200 Subject: [PATCH 04/21] refactor(submit): remove unused metadata display section from submit component --- src/app/submit/submit.component.html | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/src/app/submit/submit.component.html b/src/app/submit/submit.component.html index e3bf96e..6d8688c 100644 --- a/src/app/submit/submit.component.html +++ b/src/app/submit/submit.component.html @@ -31,28 +31,6 @@

- - - - - - - - - - - - - - - - - From 89668d959a6ee90bfcd56ad2b8653d5d4955c4ae Mon Sep 17 00:00:00 2001 From: Eryk Kullikowski Date: Tue, 16 Sep 2025 16:27:37 +0200 Subject: [PATCH 05/21] refactor(metadata-selector, submit): streamline metadata handling and remove unused code --- .../metadata-selector.component.html | 9 +- .../metadata-selector.component.spec.ts | 189 ++++++++++++-- .../metadata-selector.component.ts | 244 +++--------------- src/app/submit/submit.component.ts | 36 ++- 4 files changed, 229 insertions(+), 249 deletions(-) diff --git a/src/app/metadata-selector/metadata-selector.component.html b/src/app/metadata-selector/metadata-selector.component.html index 7d481a9..0bc8a64 100644 --- a/src/app/metadata-selector/metadata-selector.component.html +++ b/src/app/metadata-selector/metadata-selector.component.html @@ -5,23 +5,18 @@ - -
-

Make sure not to openly publish sensitive information, copyrighted materials or third-party libraries.

- -
Metadata that will be copied
FieldValue + +
Files that will be created
Metadata that will be copied
FieldValue - -
Files that will be created
- + - + diff --git a/src/app/metadata-selector/metadata-selector.component.spec.ts b/src/app/metadata-selector/metadata-selector.component.spec.ts index 2309473..cadb56e 100644 --- a/src/app/metadata-selector/metadata-selector.component.spec.ts +++ b/src/app/metadata-selector/metadata-selector.component.spec.ts @@ -1,4 +1,4 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed, fakeAsync, flushMicrotasks } from '@angular/core/testing'; import { provideHttpClient, withInterceptorsFromDi, @@ -8,11 +8,77 @@ import { of } from 'rxjs'; import { Router } from '@angular/router'; import { MetadataSelectorComponent } from './metadata-selector.component'; import { DatasetService } from '../dataset.service'; +import { Location } from '@angular/common'; +import { Field, Fieldaction, FieldDictonary, Metadata, MetadataField } from '../models/field'; +import { MetadatafieldComponent } from '../metadatafield/metadatafield.component'; describe('MetadataSelectorComponent', () => { let component: MetadataSelectorComponent; let fixture: ComponentFixture; let routerNavigateSpy: jasmine.Spy; + let locationBackSpy: jasmine.Spy; + + // Build a realistic metadata response with primitive, array, and compound fields + const makeMetadata = (): Metadata => { + const title: MetadataField = { + typeName: 'title', + typeClass: 'primitive' as any, + multiple: false, + value: 'My Dataset', + } as any; + const keyword: MetadataField = { + typeName: 'keyword', + typeClass: 'primitive' as any, + multiple: true, + value: ['science', 'data'], + } as any; + const author1: FieldDictonary = { + authorName: { + typeName: 'authorName', + typeClass: 'primitive' as any, + multiple: false, + value: 'Alice', + } as any, + authorAffiliation: { + typeName: 'authorAffiliation', + typeClass: 'primitive' as any, + multiple: false, + value: 'KU Leuven', + } as any, + }; + const author2: FieldDictonary = { + authorName: { + typeName: 'authorName', + typeClass: 'primitive' as any, + multiple: false, + value: 'Bob', + } as any, + authorAffiliation: { + typeName: 'authorAffiliation', + typeClass: 'primitive' as any, + multiple: false, + value: 'UAntwerpen', + } as any, + }; + const authorField: MetadataField = { + typeName: 'author', + typeClass: 'compound' as any, + multiple: true, + value: [author1, author2], + } as any; + const metadata: Metadata = { + datasetVersion: { + metadataBlocks: { + citation: { + displayName: 'Citation', + name: 'citation', + fields: [title, keyword, authorField], + }, + }, + }, + } as any; + return metadata; + }; beforeEach(async () => { const routerStub = { @@ -20,19 +86,9 @@ describe('MetadataSelectorComponent', () => { } as unknown as Router; const datasetStub = { newDataset: () => of({ persistentId: 'doi:10.1234/created' }), - getMetadata: () => - of({ - datasetVersion: { - metadataBlocks: { - citation: { - displayName: 'Citation', - name: 'citation', - fields: [], - }, - }, - }, - }), + getMetadata: () => of(makeMetadata()), } as unknown as DatasetService; + const locationStub = { back: jasmine.createSpy('back') } as any as Location; await TestBed.configureTestingModule({ imports: [MetadataSelectorComponent], providers: [ @@ -40,6 +96,7 @@ describe('MetadataSelectorComponent', () => { provideHttpClientTesting(), { provide: Router, useValue: routerStub }, { provide: DatasetService, useValue: datasetStub }, + { provide: Location, useValue: locationStub }, ], }) // Keep template as-is; component uses PrimeNG lightweightly @@ -48,6 +105,7 @@ describe('MetadataSelectorComponent', () => { fixture = TestBed.createComponent(MetadataSelectorComponent); component = fixture.componentInstance; routerNavigateSpy = TestBed.inject(Router).navigate as jasmine.Spy; + locationBackSpy = TestBed.inject(Location).back as jasmine.Spy; fixture.detectChanges(); }); @@ -55,9 +113,106 @@ describe('MetadataSelectorComponent', () => { expect(component).toBeTruthy(); }); - it('should navigate to submit after continueSubmit when new dataset is created', async () => { - (component as any).pid = 'collectionId:New Dataset'; - await component.continueSubmit(); - expect(routerNavigateSpy).toHaveBeenCalledWith(['/submit']); + it('should navigate to submit with metadata in state when submitting', async () => { + await component.submit(); + expect(routerNavigateSpy).toHaveBeenCalled(); + const args = (routerNavigateSpy.calls.mostRecent().args || []) as any[]; + expect(args[0]).toEqual(['/submit']); + expect(args[1]).toBeDefined(); + expect(args[1].state).toBeDefined(); + // metadata can be undefined if metadata hasn't loaded yet; we only assert the shape + expect(Object.prototype.hasOwnProperty.call(args[1].state, 'metadata')).toBeTrue(); + }); + + it('should load metadata and build tree map', fakeAsync(() => { + // ngOnInit already called; wait for async loadData to complete + flushMicrotasks(); + fixture.detectChanges(); + expect(component.metadata).toBeTruthy(); + expect(component.root).toBeTruthy(); + expect(component.rootNodeChildren.length).toBeGreaterThan(0); + // Expect names contain title, keyword, author + const names = component.rootNodeChildren.map(n => n.data?.name); + expect(names).toContain('title'); + expect(names).toContain('keyword'); + expect(names).toContain('author'); + // Row map should have entries for all nodes + expect(component.rowNodeMap.size).toBeGreaterThan(3); + })); + + it('rowClass should reflect Fieldaction values', () => { + const base: Field = { id: 'x', parent: '', name: 'f', action: Fieldaction.Ignore }; + expect(component.rowClass({ ...base, action: Fieldaction.Ignore })).toBe(''); + expect(component.rowClass({ ...base, action: Fieldaction.Copy })).toContain('#c3e6cb'); + expect(component.rowClass({ ...base, action: Fieldaction.Custom })).toContain('#FFFAA0'); + }); + + it('action() and toggleAction() should delegate to MetadatafieldComponent', () => { + // prepare a fake root and spy on static methods + const root: any = { data: { name: 'root' } }; + component.root = root; + const iconSpy = spyOn(MetadatafieldComponent, 'actionIcon').and.returnValue('icon-x'); + const toggleSpy = spyOn(MetadatafieldComponent, 'toggleNodeAction'); + expect(component.action()).toBe('icon-x'); + component.toggleAction(); + expect(iconSpy).toHaveBeenCalledWith(root); + expect(toggleSpy).toHaveBeenCalledWith(root); + }); + + it('filteredMetadata should return all fields by default', fakeAsync(() => { + flushMicrotasks(); + fixture.detectChanges(); + const fm = component.filteredMetadata(); + expect(fm).toBeTruthy(); + const fields = fm!.datasetVersion.metadataBlocks.citation.fields; + expect(fields.find(f => f.typeName === 'title')!.value).toBe('My Dataset'); + expect((fields.find(f => f.typeName === 'keyword')!.value as string[]).length).toBe(2); + expect((fields.find(f => f.typeName === 'author')!.value as any[]).length).toBe(2); + })); + + it('filteredMetadata should prune ignored primitive and nested values', fakeAsync(() => { + flushMicrotasks(); + fixture.detectChanges(); + // Ignore the title node + const titleNode = Array.from(component.rowNodeMap.values()).find(n => n.data?.name === 'title'); + expect(titleNode).toBeTruthy(); + if (titleNode && titleNode.data) { + titleNode.data.action = Fieldaction.Ignore; + } + // For compound author, remove one dictionary completely by ignoring all its children + const authorNameNodes = Array.from(component.rowNodeMap.values()).filter(n => n.data?.name === 'authorName'); + expect(authorNameNodes.length).toBeGreaterThan(0); + const parentId = authorNameNodes[0].data!.parent!; + // Ignore all children under this parent (e.g., authorName and authorAffiliation) + Array.from(component.rowNodeMap.values()) + .filter(n => n.data?.parent === parentId) + .forEach(n => (n.data!.action = Fieldaction.Ignore)); + + const fm = component.filteredMetadata(); + expect(fm).toBeTruthy(); + const fields = fm!.datasetVersion.metadataBlocks.citation.fields; + // Title should be removed + expect(fields.find(f => f.typeName === 'title')).toBeUndefined(); + // Author list should be smaller than original + const authors = fields.find(f => f.typeName === 'author')!.value as any[]; + expect(authors.length).toBe(1); + })); + + it('submit should navigate with populated metadata once loaded', fakeAsync(() => { + flushMicrotasks(); + fixture.detectChanges(); + component.submit(); + const args = (routerNavigateSpy.calls.mostRecent().args || []) as any[]; + expect(args[0]).toEqual(['/submit']); + const state = args[1]?.state; + expect(state).toBeDefined(); + expect(state.metadata).toBeDefined(); + const fields = state.metadata.datasetVersion.metadataBlocks.citation.fields; + expect(fields.length).toBeGreaterThan(0); + })); + + it('back should call Location.back()', () => { + component.back(); + expect(locationBackSpy).toHaveBeenCalled(); }); }); diff --git a/src/app/metadata-selector/metadata-selector.component.ts b/src/app/metadata-selector/metadata-selector.component.ts index 305d834..f64a255 100644 --- a/src/app/metadata-selector/metadata-selector.component.ts +++ b/src/app/metadata-selector/metadata-selector.component.ts @@ -1,24 +1,17 @@ // Author: Eryk Kulikowski @ KU Leuven (2023). Apache 2.0 License -import { Component, OnDestroy, OnInit, inject } from '@angular/core'; +import { Component, OnInit, inject } from '@angular/core'; import { Router } from '@angular/router'; import { Location } from '@angular/common'; -import { Subscription, firstValueFrom } from 'rxjs'; +import { firstValueFrom } from 'rxjs'; // Services -import { DataUpdatesService } from '../data.updates.service'; import { DataStateService } from '../data.state.service'; -import { PluginService } from '../plugin.service'; -import { UtilsService } from '../utils.service'; import { CredentialsService } from '../credentials.service'; -import { DataService } from '../data.service'; import { DatasetService } from '../dataset.service'; -import { NotificationService } from '../shared/notification.service'; // Models -import { Datafile, Fileaction, Filestatus } from '../models/datafile'; // import { StoreResult } from '../models/store-result'; -import { CompareResult } from '../models/compare-result'; import { MetadataRequest } from '../models/metadata-request'; import { Field, @@ -39,7 +32,7 @@ import { MetadatafieldComponent } from '../metadatafield/metadatafield.component // Constants and types import { APP_CONSTANTS } from '../shared/constants'; -import { SubscriptionManager } from '../shared/types'; +// SubscriptionManager not needed as we no longer manage subscriptions here @Component({ selector: 'app-metadata-selector', @@ -53,40 +46,19 @@ import { SubscriptionManager } from '../shared/types'; MetadatafieldComponent, ], }) -export class MetadataSelectorComponent - implements OnInit, OnDestroy, SubscriptionManager -{ +export class MetadataSelectorComponent implements OnInit { private readonly dataStateService = inject(DataStateService); - private readonly dataUpdatesService = inject(DataUpdatesService); private readonly location = inject(Location); - private readonly pluginService = inject(PluginService); private readonly router = inject(Router); - private readonly utils = inject(UtilsService); - dataService = inject(DataService); private readonly credentialsService = inject(CredentialsService); private readonly datasetService = inject(DatasetService); - private readonly notificationService = inject(NotificationService); - - // Subscriptions for cleanup - private readonly subscriptions = new Set(); // Icon constants readonly icon_warning = APP_CONSTANTS.ICONS.WARNING; readonly icon_copy = APP_CONSTANTS.ICONS.UPDATE; - readonly icon_update = 'pi pi-clone'; - readonly icon_delete = 'pi pi-trash'; - - data: Datafile[] = []; - pid = ''; - datasetUrl = ''; - - // local state derived from this.data: - created: Datafile[] = []; - updated: Datafile[] = []; - deleted: Datafile[] = []; + // Note: update/delete icons are not used in this component's template disabled = false; - submitted = false; done = false; // No popup in metadata selector; direct navigation to submit page hasAccessToCompute = false; @@ -99,203 +71,47 @@ export class MetadataSelectorComponent id = 0; - constructor() {} + constructor() { } ngOnInit(): void { + // Load metadata immediately for rendering in the tree table this.loadData(); - const subscription = this.dataService - .checkAccessToQueue( - '', - this.credentialsService.credentials.dataverse_token, - '', - ) - .subscribe({ - next: (access) => { - subscription.unsubscribe(); - this.hasAccessToCompute = access.access; - }, - error: () => { - subscription.unsubscribe(); - }, - }); - } - - ngOnDestroy(): void { - // Clean up all subscriptions - this.subscriptions.forEach((sub) => sub.unsubscribe()); - this.subscriptions.clear(); - } - - getDataSubscription(): void { - const dataSubscription = this.dataUpdatesService - .updateData(this.data, this.pid) - .subscribe({ - next: async (res: CompareResult) => { - dataSubscription?.unsubscribe(); - if (res.data !== undefined) { - this.setData(res.data); - } - if (!this.hasUnfinishedDataFiles()) { - this.done = true; - } else { - await this.utils.sleep(1000); - this.getDataSubscription(); - } - }, - error: (err) => { - dataSubscription?.unsubscribe(); - this.notificationService.showError( - `Getting status of data failed: ${err.error}`, - ); - this.router.navigate(['/connect']); - }, - }); } - hasUnfinishedDataFiles(): boolean { - return ( - this.created.some((d) => d.status !== Filestatus.Equal) || - this.updated.some((d) => d.status !== Filestatus.Equal) || - this.deleted.some((d) => d.status !== Filestatus.New) + async loadData() { + const credentials = this.credentialsService.credentials; + const req: MetadataRequest = { + pluginId: credentials.pluginId, + plugin: credentials.plugin, + repoName: credentials.repo_name, + url: credentials.url, + option: credentials.option, + user: credentials.user, + token: credentials.token, + dvToken: credentials.dataverse_token, + compareResult: this.dataStateService.getCurrentValue(), + }; + this.metadata = await firstValueFrom( + this.datasetService.getMetadata(req), ); - } - - loadData(): void { - const value = this.dataStateService.getCurrentValue(); - this.pid = value && value.id ? value.id : ''; - const data = value?.data; - if (data) { - this.setData(data); - } - } - - async setData(data: Datafile[]) { - this.data = data; - this.created = this.toCreate(); - this.updated = this.toUpdate(); - this.deleted = this.toDelete(); - this.data = [...this.created, ...this.updated, ...this.deleted]; - if (this.pid.endsWith(':New Dataset')) { - const credentials = this.credentialsService.credentials; - const req: MetadataRequest = { - pluginId: credentials.pluginId, - plugin: credentials.plugin, - repoName: credentials.repo_name, - url: credentials.url, - option: credentials.option, - user: credentials.user, - token: credentials.token, - dvToken: credentials.dataverse_token, - compareResult: this.dataStateService.getCurrentValue(), - }; - this.metadata = await firstValueFrom( - this.datasetService.getMetadata(req), - ); - const rowDataMap = this.mapFields(this.metadata); - rowDataMap.forEach((v) => this.addChild(v, rowDataMap)); - this.root = rowDataMap.get(''); - this.rowNodeMap = rowDataMap; - if (this.root?.children) { - this.rootNodeChildren = this.root.children; - } + const rowDataMap = this.mapFields(this.metadata); + rowDataMap.forEach((v) => this.addChild(v, rowDataMap)); + this.root = rowDataMap.get(''); + this.rowNodeMap = rowDataMap; + if (this.root?.children) { + this.rootNodeChildren = this.root.children; } } - toCreate(): Datafile[] { - const result: Datafile[] = []; - this.data.forEach((datafile) => { - if (datafile.action === Fileaction.Copy) { - result.push(datafile); - } - }); - return result; - } - - toUpdate(): Datafile[] { - const result: Datafile[] = []; - this.data.forEach((datafile) => { - if (datafile.action === Fileaction.Update) { - result.push(datafile); - } - }); - return result; - } - - toDelete(): Datafile[] { - const result: Datafile[] = []; - this.data.forEach((datafile) => { - if (datafile.action === Fileaction.Delete) { - if ( - datafile.attributes?.remoteHashType === undefined || - datafile.attributes?.remoteHashType === '' - ) { - // file from unknown origin, by setting the hash type to not empty value we can detect in comparison that the file got deleted - datafile.attributes!.remoteHashType = 'unknown'; - datafile.attributes!.remoteHash = 'unknown'; - } - result.push(datafile); - } - }); - return result; - } - submit() { - // In metadata selector, clicking Submit should proceed immediately to the submit page - this.continueSubmit(); - } - - async continueSubmit() { - this.disabled = true; - - // If this is a new dataset flow, first create the dataset based on selected metadata - if (this.pid.endsWith(':New Dataset')) { - const ids = this.pid.split(':'); - const ok = await this.newDataset(ids[0]); - if (!ok) { - this.disabled = false; - return; - } - } - - // After metadata selection (and dataset creation when needed), proceed to the Submit page for files - this.router.navigate(['/submit']); + const metadata = this.filteredMetadata(); + this.router.navigate(['/submit'], { state: { metadata } }); } back(): void { this.location.back(); } - goToDataset() { - window.open(this.datasetUrl, '_blank'); - } - - goToCompute() { - this.router.navigate(['/compute'], { queryParams: { pid: this.pid } }); - } - - sendMails(): boolean { - return this.pluginService.sendMails(); - } - - async newDataset(collectionId: string): Promise { - const metadata = this.filteredMetadata(); - const data = await firstValueFrom( - this.datasetService.newDataset( - collectionId, - this.credentialsService.credentials.dataverse_token, - metadata, - ), - ); - if (data.persistentId !== undefined && data.persistentId !== '') { - this.pid = data.persistentId; - this.credentialsService.credentials.dataset_id = data.persistentId; - return true; - } else { - this.notificationService.showError('Creating new dataset failed'); - return false; - } - } - action(): string { if (this.root) { return MetadatafieldComponent.actionIcon(this.root); diff --git a/src/app/submit/submit.component.ts b/src/app/submit/submit.component.ts index 74d5c5b..d09dc0c 100644 --- a/src/app/submit/submit.component.ts +++ b/src/app/submit/submit.component.ts @@ -1,7 +1,7 @@ // Author: Eryk Kulikowski @ KU Leuven (2023). Apache 2.0 License import { Component, OnInit, OnDestroy, inject } from '@angular/core'; -import { Router } from '@angular/router'; +import { Router, Navigation } from '@angular/router'; import { Location } from '@angular/common'; import { Subscription, firstValueFrom } from 'rxjs'; @@ -20,7 +20,7 @@ import { NotificationService } from '../shared/notification.service'; import { Datafile, Fileaction, Filestatus } from '../models/datafile'; import { StoreResult } from '../models/store-result'; import { CompareResult } from '../models/compare-result'; -// Removed metadata-related imports as metadata selection is no longer handled here +import { Metadata } from '../models/field'; // PrimeNG import { PrimeTemplate } from 'primeng/api'; @@ -29,7 +29,6 @@ import { Ripple } from 'primeng/ripple'; import { Dialog } from 'primeng/dialog'; import { Checkbox } from 'primeng/checkbox'; import { FormsModule } from '@angular/forms'; -// Removed TreeTableModule as metadata table is no longer used // Components import { SubmittedFileComponent } from '../submitted-file/submitted-file.component'; @@ -90,13 +89,26 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { sendEmailOnSuccess = false; popup = false; hasAccessToCompute = false; - - // Removed metadata tree state as metadata selection is not part of Submit anymore + private incomingMetadata?: Metadata; constructor() { } ngOnInit(): void { this.loadData(); + // Capture metadata from navigation state if coming from metadata-selector + let state: { metadata?: Metadata } | undefined; + const routerMaybe = this.router as unknown as { getCurrentNavigation?: () => Navigation | null }; + // Use Router.getCurrentNavigation when available + if (typeof routerMaybe.getCurrentNavigation === 'function') { + const nav = routerMaybe.getCurrentNavigation?.(); + state = (nav?.extras?.state as { metadata?: Metadata }) || undefined; + } else if (typeof history !== 'undefined' && (history as History & { state?: unknown }).state) { + // Fallback for environments/tests where Router.getCurrentNavigation is not available + state = (history as History & { state?: unknown }).state as { metadata?: Metadata }; + } + if (state?.metadata) { + this.incomingMetadata = state.metadata; + } const subscription = this.dataService .checkAccessToQueue( '', @@ -121,12 +133,13 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { } getDataSubscription(): void { - let dataSubscription: Subscription | undefined; + let dataSubscription: Subscription | null = null; dataSubscription = this.dataUpdatesService .updateData(this.data, this.pid) .subscribe({ next: async (res: CompareResult) => { - dataSubscription?.unsubscribe(); + // Defer unsubscribe to ensure assignment completed even if emission is synchronous + setTimeout(() => dataSubscription?.unsubscribe(), 0); if (res.data !== undefined) { this.setData(res.data); } @@ -138,7 +151,7 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { } }, error: (err) => { - dataSubscription?.unsubscribe(); + setTimeout(() => dataSubscription?.unsubscribe(), 0); this.notificationService.showError(`Getting status of data failed: ${err.error}`); this.router.navigate(['/connect']); }, @@ -241,7 +254,7 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { } } - let httpSubscription: Subscription | undefined; + let httpSubscription: Subscription | null = null; httpSubscription = this.submitService .submit(selected, this.sendEmailOnSuccess) .subscribe({ @@ -255,9 +268,10 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { this.submitted = true; this.datasetUrl = data.datasetUrl!; } - httpSubscription?.unsubscribe(); + setTimeout(() => httpSubscription?.unsubscribe(), 0); }, error: (err) => { + setTimeout(() => httpSubscription?.unsubscribe(), 0); this.notificationService.showError(`Store failed: ${err.error}`); this.router.navigate(['/connect']); }, @@ -285,7 +299,7 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { this.datasetService.newDataset( collectionId, this.credentialsService.credentials.dataverse_token, - undefined, // no metadata selection in Submit; server may apply defaults + this.incomingMetadata, // use metadata selected in metadata-selector when present ), ); if (data.persistentId !== undefined && data.persistentId !== '') { From 365f3e70ea060670480cf2e13af8729622da2980 Mon Sep 17 00:00:00 2001 From: Eryk Kullikowski Date: Tue, 16 Sep 2025 16:28:06 +0200 Subject: [PATCH 06/21] fmt --- .../metadata-selector.component.spec.ts | 74 ++++++++++++++----- .../metadata-selector.component.ts | 6 +- src/app/submit/submit.component.spec.ts | 40 +++++++--- src/app/submit/submit.component.ts | 25 +++++-- 4 files changed, 104 insertions(+), 41 deletions(-) diff --git a/src/app/metadata-selector/metadata-selector.component.spec.ts b/src/app/metadata-selector/metadata-selector.component.spec.ts index cadb56e..8607a54 100644 --- a/src/app/metadata-selector/metadata-selector.component.spec.ts +++ b/src/app/metadata-selector/metadata-selector.component.spec.ts @@ -1,4 +1,9 @@ -import { ComponentFixture, TestBed, fakeAsync, flushMicrotasks } from '@angular/core/testing'; +import { + ComponentFixture, + TestBed, + fakeAsync, + flushMicrotasks, +} from '@angular/core/testing'; import { provideHttpClient, withInterceptorsFromDi, @@ -9,7 +14,13 @@ import { Router } from '@angular/router'; import { MetadataSelectorComponent } from './metadata-selector.component'; import { DatasetService } from '../dataset.service'; import { Location } from '@angular/common'; -import { Field, Fieldaction, FieldDictonary, Metadata, MetadataField } from '../models/field'; +import { + Field, + Fieldaction, + FieldDictonary, + Metadata, + MetadataField, +} from '../models/field'; import { MetadatafieldComponent } from '../metadatafield/metadatafield.component'; describe('MetadataSelectorComponent', () => { @@ -120,8 +131,10 @@ describe('MetadataSelectorComponent', () => { expect(args[0]).toEqual(['/submit']); expect(args[1]).toBeDefined(); expect(args[1].state).toBeDefined(); - // metadata can be undefined if metadata hasn't loaded yet; we only assert the shape - expect(Object.prototype.hasOwnProperty.call(args[1].state, 'metadata')).toBeTrue(); + // metadata can be undefined if metadata hasn't loaded yet; we only assert the shape + expect( + Object.prototype.hasOwnProperty.call(args[1].state, 'metadata'), + ).toBeTrue(); }); it('should load metadata and build tree map', fakeAsync(() => { @@ -132,7 +145,7 @@ describe('MetadataSelectorComponent', () => { expect(component.root).toBeTruthy(); expect(component.rootNodeChildren.length).toBeGreaterThan(0); // Expect names contain title, keyword, author - const names = component.rootNodeChildren.map(n => n.data?.name); + const names = component.rootNodeChildren.map((n) => n.data?.name); expect(names).toContain('title'); expect(names).toContain('keyword'); expect(names).toContain('author'); @@ -141,17 +154,30 @@ describe('MetadataSelectorComponent', () => { })); it('rowClass should reflect Fieldaction values', () => { - const base: Field = { id: 'x', parent: '', name: 'f', action: Fieldaction.Ignore }; - expect(component.rowClass({ ...base, action: Fieldaction.Ignore })).toBe(''); - expect(component.rowClass({ ...base, action: Fieldaction.Copy })).toContain('#c3e6cb'); - expect(component.rowClass({ ...base, action: Fieldaction.Custom })).toContain('#FFFAA0'); + const base: Field = { + id: 'x', + parent: '', + name: 'f', + action: Fieldaction.Ignore, + }; + expect(component.rowClass({ ...base, action: Fieldaction.Ignore })).toBe( + '', + ); + expect(component.rowClass({ ...base, action: Fieldaction.Copy })).toContain( + '#c3e6cb', + ); + expect( + component.rowClass({ ...base, action: Fieldaction.Custom }), + ).toContain('#FFFAA0'); }); it('action() and toggleAction() should delegate to MetadatafieldComponent', () => { // prepare a fake root and spy on static methods const root: any = { data: { name: 'root' } }; component.root = root; - const iconSpy = spyOn(MetadatafieldComponent, 'actionIcon').and.returnValue('icon-x'); + const iconSpy = spyOn(MetadatafieldComponent, 'actionIcon').and.returnValue( + 'icon-x', + ); const toggleSpy = spyOn(MetadatafieldComponent, 'toggleNodeAction'); expect(component.action()).toBe('icon-x'); component.toggleAction(); @@ -165,36 +191,46 @@ describe('MetadataSelectorComponent', () => { const fm = component.filteredMetadata(); expect(fm).toBeTruthy(); const fields = fm!.datasetVersion.metadataBlocks.citation.fields; - expect(fields.find(f => f.typeName === 'title')!.value).toBe('My Dataset'); - expect((fields.find(f => f.typeName === 'keyword')!.value as string[]).length).toBe(2); - expect((fields.find(f => f.typeName === 'author')!.value as any[]).length).toBe(2); + expect(fields.find((f) => f.typeName === 'title')!.value).toBe( + 'My Dataset', + ); + expect( + (fields.find((f) => f.typeName === 'keyword')!.value as string[]).length, + ).toBe(2); + expect( + (fields.find((f) => f.typeName === 'author')!.value as any[]).length, + ).toBe(2); })); it('filteredMetadata should prune ignored primitive and nested values', fakeAsync(() => { flushMicrotasks(); fixture.detectChanges(); // Ignore the title node - const titleNode = Array.from(component.rowNodeMap.values()).find(n => n.data?.name === 'title'); + const titleNode = Array.from(component.rowNodeMap.values()).find( + (n) => n.data?.name === 'title', + ); expect(titleNode).toBeTruthy(); if (titleNode && titleNode.data) { titleNode.data.action = Fieldaction.Ignore; } // For compound author, remove one dictionary completely by ignoring all its children - const authorNameNodes = Array.from(component.rowNodeMap.values()).filter(n => n.data?.name === 'authorName'); + const authorNameNodes = Array.from(component.rowNodeMap.values()).filter( + (n) => n.data?.name === 'authorName', + ); expect(authorNameNodes.length).toBeGreaterThan(0); const parentId = authorNameNodes[0].data!.parent!; // Ignore all children under this parent (e.g., authorName and authorAffiliation) Array.from(component.rowNodeMap.values()) - .filter(n => n.data?.parent === parentId) - .forEach(n => (n.data!.action = Fieldaction.Ignore)); + .filter((n) => n.data?.parent === parentId) + .forEach((n) => (n.data!.action = Fieldaction.Ignore)); const fm = component.filteredMetadata(); expect(fm).toBeTruthy(); const fields = fm!.datasetVersion.metadataBlocks.citation.fields; // Title should be removed - expect(fields.find(f => f.typeName === 'title')).toBeUndefined(); + expect(fields.find((f) => f.typeName === 'title')).toBeUndefined(); // Author list should be smaller than original - const authors = fields.find(f => f.typeName === 'author')!.value as any[]; + const authors = fields.find((f) => f.typeName === 'author')!.value as any[]; expect(authors.length).toBe(1); })); diff --git a/src/app/metadata-selector/metadata-selector.component.ts b/src/app/metadata-selector/metadata-selector.component.ts index f64a255..3e1fa0b 100644 --- a/src/app/metadata-selector/metadata-selector.component.ts +++ b/src/app/metadata-selector/metadata-selector.component.ts @@ -71,7 +71,7 @@ export class MetadataSelectorComponent implements OnInit { id = 0; - constructor() { } + constructor() {} ngOnInit(): void { // Load metadata immediately for rendering in the tree table @@ -91,9 +91,7 @@ export class MetadataSelectorComponent implements OnInit { dvToken: credentials.dataverse_token, compareResult: this.dataStateService.getCurrentValue(), }; - this.metadata = await firstValueFrom( - this.datasetService.getMetadata(req), - ); + this.metadata = await firstValueFrom(this.datasetService.getMetadata(req)); const rowDataMap = this.mapFields(this.metadata); rowDataMap.forEach((v) => this.addChild(v, rowDataMap)); this.root = rowDataMap.get(''); diff --git a/src/app/submit/submit.component.spec.ts b/src/app/submit/submit.component.spec.ts index 187040f..7f203fb 100644 --- a/src/app/submit/submit.component.spec.ts +++ b/src/app/submit/submit.component.spec.ts @@ -1,5 +1,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { + provideHttpClient, + withInterceptorsFromDi, +} from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { of, throwError } from 'rxjs'; import { Router } from '@angular/router'; @@ -48,15 +51,23 @@ describe('SubmitComponent', () => { }; submitServiceStub = { - submit: jasmine.createSpy('submit').and.returnValue(of({ status: 'OK', datasetUrl: 'http://example.com' })), + submit: jasmine + .createSpy('submit') + .and.returnValue( + of({ status: 'OK', datasetUrl: 'http://example.com' }), + ), }; datasetServiceStub = { - newDataset: jasmine.createSpy('newDataset').and.returnValue(of({ persistentId: 'doi:new' })), + newDataset: jasmine + .createSpy('newDataset') + .and.returnValue(of({ persistentId: 'doi:new' })), }; dataUpdatesServiceStub = { - updateData: jasmine.createSpy('updateData').and.returnValue(of({ data: undefined } as any)), + updateData: jasmine + .createSpy('updateData') + .and.returnValue(of({ data: undefined } as any)), }; notificationServiceStub = { @@ -105,7 +116,11 @@ describe('SubmitComponent', () => { const files: Datafile[] = [ { action: Fileaction.Copy, status: Filestatus.Equal } as any, { action: Fileaction.Update, status: Filestatus.Equal } as any, - { action: Fileaction.Delete, status: Filestatus.New, attributes: { remoteHashType: '', remoteHash: '' } } as any, + { + action: Fileaction.Delete, + status: Filestatus.New, + attributes: { remoteHashType: '', remoteHash: '' }, + } as any, { action: Fileaction.Ignore, status: Filestatus.Equal } as any, ]; await component.setData(files); @@ -129,7 +144,7 @@ describe('SubmitComponent', () => { { action: Fileaction.Ignore, status: Filestatus.Equal } as any, ]; await component.continueSubmit(); - expect((routerStub.navigate as any)).toHaveBeenCalledWith(['/connect']); + expect(routerStub.navigate as any).toHaveBeenCalledWith(['/connect']); }); it('continueSubmit should call submit service and mark submitted on OK', async () => { @@ -151,25 +166,28 @@ describe('SubmitComponent', () => { }); it('getDataSubscription should navigate to /connect and show error on failure', () => { - (dataUpdatesServiceStub.updateData as jasmine.Spy).and.returnValue(throwError(() => ({ error: 'boom' }))); + (dataUpdatesServiceStub.updateData as jasmine.Spy).and.returnValue( + throwError(() => ({ error: 'boom' })), + ); component.getDataSubscription(); expect(notificationServiceStub.showError).toHaveBeenCalled(); - expect((routerStub.navigate as any)).toHaveBeenCalledWith(['/connect']); + expect(routerStub.navigate as any).toHaveBeenCalledWith(['/connect']); }); it('goToCompute should navigate to compute route with pid', () => { component.pid = 'doi:abc'; component.goToCompute(); - expect((routerStub.navigate as any)).toHaveBeenCalledWith(['/compute'], { queryParams: { pid: 'doi:abc' } }); + expect(routerStub.navigate as any).toHaveBeenCalledWith(['/compute'], { + queryParams: { pid: 'doi:abc' }, + }); }); it('back should call Location.back', () => { component.back(); - expect((locationStub.back as any)).toHaveBeenCalled(); + expect(locationStub.back as any).toHaveBeenCalled(); }); it('sendMails should return plugin service value', () => { expect(component.sendMails()).toBeTrue(); }); - }); diff --git a/src/app/submit/submit.component.ts b/src/app/submit/submit.component.ts index d09dc0c..bf82770 100644 --- a/src/app/submit/submit.component.ts +++ b/src/app/submit/submit.component.ts @@ -91,20 +91,27 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { hasAccessToCompute = false; private incomingMetadata?: Metadata; - constructor() { } + constructor() {} ngOnInit(): void { this.loadData(); // Capture metadata from navigation state if coming from metadata-selector let state: { metadata?: Metadata } | undefined; - const routerMaybe = this.router as unknown as { getCurrentNavigation?: () => Navigation | null }; + const routerMaybe = this.router as unknown as { + getCurrentNavigation?: () => Navigation | null; + }; // Use Router.getCurrentNavigation when available if (typeof routerMaybe.getCurrentNavigation === 'function') { const nav = routerMaybe.getCurrentNavigation?.(); state = (nav?.extras?.state as { metadata?: Metadata }) || undefined; - } else if (typeof history !== 'undefined' && (history as History & { state?: unknown }).state) { + } else if ( + typeof history !== 'undefined' && + (history as History & { state?: unknown }).state + ) { // Fallback for environments/tests where Router.getCurrentNavigation is not available - state = (history as History & { state?: unknown }).state as { metadata?: Metadata }; + state = (history as History & { state?: unknown }).state as { + metadata?: Metadata; + }; } if (state?.metadata) { this.incomingMetadata = state.metadata; @@ -128,7 +135,7 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { ngOnDestroy(): void { // Clean up all subscriptions - this.subscriptions.forEach(sub => sub.unsubscribe()); + this.subscriptions.forEach((sub) => sub.unsubscribe()); this.subscriptions.clear(); } @@ -152,7 +159,9 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { }, error: (err) => { setTimeout(() => dataSubscription?.unsubscribe(), 0); - this.notificationService.showError(`Getting status of data failed: ${err.error}`); + this.notificationService.showError( + `Getting status of data failed: ${err.error}`, + ); this.router.navigate(['/connect']); }, }); @@ -261,7 +270,9 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { next: (data: StoreResult) => { if (data.status !== 'OK') { // this should not happen - this.notificationService.showError(`Store failed, status: ${data.status}`); + this.notificationService.showError( + `Store failed, status: ${data.status}`, + ); this.router.navigate(['/connect']); } else { this.getDataSubscription(); From 35ed84dc2a9702d90afaee193b56311e6108d52a Mon Sep 17 00:00:00 2001 From: Eryk Kullikowski Date: Tue, 16 Sep 2025 16:38:22 +0200 Subject: [PATCH 07/21] refactor(metadata-selector): update button labels for clarity and remove unused 'done' property --- .../metadata-selector/metadata-selector.component.html | 8 ++++++-- src/app/metadata-selector/metadata-selector.component.ts | 1 - 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/app/metadata-selector/metadata-selector.component.html b/src/app/metadata-selector/metadata-selector.component.html index 0bc8a64..ecc46f7 100644 --- a/src/app/metadata-selector/metadata-selector.component.html +++ b/src/app/metadata-selector/metadata-selector.component.html @@ -4,8 +4,12 @@ + + Select metadata actions + + - + @@ -20,7 +24,7 @@ - +
Metadata that will be copied
FieldMetadata field Value + diff --git a/src/app/metadata-selector/metadata-selector.component.ts b/src/app/metadata-selector/metadata-selector.component.ts index c89f85f..444cee4 100644 --- a/src/app/metadata-selector/metadata-selector.component.ts +++ b/src/app/metadata-selector/metadata-selector.component.ts @@ -54,13 +54,7 @@ export class MetadataSelectorComponent implements OnInit { private readonly datasetService = inject(DatasetService); // Icon constants - readonly icon_warning = APP_CONSTANTS.ICONS.WARNING; readonly icon_copy = APP_CONSTANTS.ICONS.UPDATE; - // Note: update/delete icons are not used in this component's template - - disabled = false; - // No popup in metadata selector; direct navigation to submit page - hasAccessToCompute = false; metadata?: Metadata; From e6f7034ebe33e418d3fe66d3e854ccb3599b7ca5 Mon Sep 17 00:00:00 2001 From: Eryk Kullikowski Date: Tue, 16 Sep 2025 16:44:59 +0200 Subject: [PATCH 09/21] refactor(submit): update submit button style for consistency --- src/app/submit/submit.component.html | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/app/submit/submit.component.html b/src/app/submit/submit.component.html index 6d8688c..34927f9 100644 --- a/src/app/submit/submit.component.html +++ b/src/app/submit/submit.component.html @@ -4,10 +4,14 @@ + + Overview of actions + + - + From 9a985b982268d5404a00a77663cc6a23e4c6e4e2 Mon Sep 17 00:00:00 2001 From: Eryk Kulikowski <101262459+ErykKul@users.noreply.github.com> Date: Tue, 16 Sep 2025 17:05:34 +0200 Subject: [PATCH 10/21] Update src/app/submit/submit.component.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/app/submit/submit.component.ts | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/src/app/submit/submit.component.ts b/src/app/submit/submit.component.ts index bf82770..921cb0a 100644 --- a/src/app/submit/submit.component.ts +++ b/src/app/submit/submit.component.ts @@ -97,21 +97,13 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { this.loadData(); // Capture metadata from navigation state if coming from metadata-selector let state: { metadata?: Metadata } | undefined; - const routerMaybe = this.router as unknown as { - getCurrentNavigation?: () => Navigation | null; - }; - // Use Router.getCurrentNavigation when available - if (typeof routerMaybe.getCurrentNavigation === 'function') { - const nav = routerMaybe.getCurrentNavigation?.(); - state = (nav?.extras?.state as { metadata?: Metadata }) || undefined; - } else if ( - typeof history !== 'undefined' && - (history as History & { state?: unknown }).state - ) { + // Use Router.getCurrentNavigation when available (only during navigation) + const nav = this.router.getCurrentNavigation?.(); + if (nav?.extras?.state) { + state = nav.extras.state as { metadata?: Metadata }; + } else if (typeof window !== 'undefined' && window.history && window.history.state) { // Fallback for environments/tests where Router.getCurrentNavigation is not available - state = (history as History & { state?: unknown }).state as { - metadata?: Metadata; - }; + state = window.history.state as { metadata?: Metadata }; } if (state?.metadata) { this.incomingMetadata = state.metadata; From 25a9bbbcd30f6ff08ca11257951215b3aca40b84 Mon Sep 17 00:00:00 2001 From: Eryk Kullikowski Date: Tue, 16 Sep 2025 17:15:51 +0200 Subject: [PATCH 11/21] refactor(submit): improve subscription management and error handling --- src/app/submit/submit.component.ts | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/app/submit/submit.component.ts b/src/app/submit/submit.component.ts index 921cb0a..de6ec9b 100644 --- a/src/app/submit/submit.component.ts +++ b/src/app/submit/submit.component.ts @@ -67,6 +67,8 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { // Subscriptions for cleanup private readonly subscriptions = new Set(); + // Keep a handle only for polling, so we can cancel/replace safely + private dataStatusSub?: Subscription; // Icon constants readonly icon_warning = APP_CONSTANTS.ICONS.WARNING; @@ -108,7 +110,7 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { if (state?.metadata) { this.incomingMetadata = state.metadata; } - const subscription = this.dataService + const accessCheckSub = this.dataService .checkAccessToQueue( '', this.credentialsService.credentials.dataverse_token, @@ -116,13 +118,13 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { ) .subscribe({ next: (access) => { - subscription.unsubscribe(); this.hasAccessToCompute = access.access; }, error: () => { - subscription.unsubscribe(); + // ignore access check failures silently as before }, }); + this.subscriptions.add(accessCheckSub); } ngOnDestroy(): void { @@ -132,13 +134,13 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { } getDataSubscription(): void { - let dataSubscription: Subscription | null = null; - dataSubscription = this.dataUpdatesService + // Ensure previous polling subscription is stopped before starting a new one + this.dataStatusSub?.unsubscribe(); + this.dataStatusSub = this.dataUpdatesService .updateData(this.data, this.pid) .subscribe({ next: async (res: CompareResult) => { - // Defer unsubscribe to ensure assignment completed even if emission is synchronous - setTimeout(() => dataSubscription?.unsubscribe(), 0); + this.dataStatusSub?.unsubscribe(); if (res.data !== undefined) { this.setData(res.data); } @@ -150,13 +152,14 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { } }, error: (err) => { - setTimeout(() => dataSubscription?.unsubscribe(), 0); + this.dataStatusSub?.unsubscribe(); this.notificationService.showError( `Getting status of data failed: ${err.error}`, ); this.router.navigate(['/connect']); }, }); + this.subscriptions.add(this.dataStatusSub); } hasUnfinishedDataFiles(): boolean { @@ -255,8 +258,7 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { } } - let httpSubscription: Subscription | null = null; - httpSubscription = this.submitService + const submitSub = this.submitService .submit(selected, this.sendEmailOnSuccess) .subscribe({ next: (data: StoreResult) => { @@ -271,14 +273,13 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { this.submitted = true; this.datasetUrl = data.datasetUrl!; } - setTimeout(() => httpSubscription?.unsubscribe(), 0); }, error: (err) => { - setTimeout(() => httpSubscription?.unsubscribe(), 0); this.notificationService.showError(`Store failed: ${err.error}`); this.router.navigate(['/connect']); }, }); + this.subscriptions.add(submitSub); } back(): void { From d728a4033333a30f19a4d166c14f6267e2223c2c Mon Sep 17 00:00:00 2001 From: Eryk Kullikowski Date: Tue, 16 Sep 2025 17:19:40 +0200 Subject: [PATCH 12/21] refactor(submit): simplify router navigation state handling --- src/app/submit/submit.component.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/app/submit/submit.component.ts b/src/app/submit/submit.component.ts index de6ec9b..cbba8f8 100644 --- a/src/app/submit/submit.component.ts +++ b/src/app/submit/submit.component.ts @@ -1,7 +1,7 @@ // Author: Eryk Kulikowski @ KU Leuven (2023). Apache 2.0 License import { Component, OnInit, OnDestroy, inject } from '@angular/core'; -import { Router, Navigation } from '@angular/router'; +import { Router } from '@angular/router'; import { Location } from '@angular/common'; import { Subscription, firstValueFrom } from 'rxjs'; @@ -103,7 +103,11 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { const nav = this.router.getCurrentNavigation?.(); if (nav?.extras?.state) { state = nav.extras.state as { metadata?: Metadata }; - } else if (typeof window !== 'undefined' && window.history && window.history.state) { + } else if ( + typeof window !== 'undefined' && + window.history && + window.history.state + ) { // Fallback for environments/tests where Router.getCurrentNavigation is not available state = window.history.state as { metadata?: Metadata }; } From 6ecd40dfe72b0ec56e2019d5e13a140aebfd64cf Mon Sep 17 00:00:00 2001 From: Eryk Kulikowski <101262459+ErykKul@users.noreply.github.com> Date: Tue, 16 Sep 2025 17:23:04 +0200 Subject: [PATCH 13/21] Update src/app/submit/submit.component.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/app/submit/submit.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/submit/submit.component.ts b/src/app/submit/submit.component.ts index cbba8f8..43842ea 100644 --- a/src/app/submit/submit.component.ts +++ b/src/app/submit/submit.component.ts @@ -100,7 +100,7 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { // Capture metadata from navigation state if coming from metadata-selector let state: { metadata?: Metadata } | undefined; // Use Router.getCurrentNavigation when available (only during navigation) - const nav = this.router.getCurrentNavigation?.(); + const nav = this.router.getCurrentNavigation(); if (nav?.extras?.state) { state = nav.extras.state as { metadata?: Metadata }; } else if ( From c2f203400dc5d2729c45f21e9aa6675fb037e816 Mon Sep 17 00:00:00 2001 From: Eryk Kulikowski <101262459+ErykKul@users.noreply.github.com> Date: Tue, 16 Sep 2025 17:23:11 +0200 Subject: [PATCH 14/21] Update src/app/submit/submit.component.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/app/submit/submit.component.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/app/submit/submit.component.ts b/src/app/submit/submit.component.ts index 43842ea..88ebd70 100644 --- a/src/app/submit/submit.component.ts +++ b/src/app/submit/submit.component.ts @@ -103,13 +103,6 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { const nav = this.router.getCurrentNavigation(); if (nav?.extras?.state) { state = nav.extras.state as { metadata?: Metadata }; - } else if ( - typeof window !== 'undefined' && - window.history && - window.history.state - ) { - // Fallback for environments/tests where Router.getCurrentNavigation is not available - state = window.history.state as { metadata?: Metadata }; } if (state?.metadata) { this.incomingMetadata = state.metadata; From aed175a356b3ccbc3220cb8856649a0b9d057da4 Mon Sep 17 00:00:00 2001 From: Eryk Kulikowski <101262459+ErykKul@users.noreply.github.com> Date: Tue, 16 Sep 2025 17:23:18 +0200 Subject: [PATCH 15/21] Update src/app/metadata-selector/metadata-selector.component.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/app/metadata-selector/metadata-selector.component.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/metadata-selector/metadata-selector.component.ts b/src/app/metadata-selector/metadata-selector.component.ts index 444cee4..2db9f6e 100644 --- a/src/app/metadata-selector/metadata-selector.component.ts +++ b/src/app/metadata-selector/metadata-selector.component.ts @@ -11,7 +11,6 @@ import { CredentialsService } from '../credentials.service'; import { DatasetService } from '../dataset.service'; // Models -// import { StoreResult } from '../models/store-result'; import { MetadataRequest } from '../models/metadata-request'; import { Field, From 459e4ce122a023c75ff798c837cbbe696b4941c2 Mon Sep 17 00:00:00 2001 From: Eryk Kulikowski <101262459+ErykKul@users.noreply.github.com> Date: Tue, 16 Sep 2025 17:23:38 +0200 Subject: [PATCH 16/21] Update src/app/metadata-selector/metadata-selector.component.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/app/metadata-selector/metadata-selector.component.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/metadata-selector/metadata-selector.component.ts b/src/app/metadata-selector/metadata-selector.component.ts index 2db9f6e..920161b 100644 --- a/src/app/metadata-selector/metadata-selector.component.ts +++ b/src/app/metadata-selector/metadata-selector.component.ts @@ -31,7 +31,6 @@ import { MetadatafieldComponent } from '../metadatafield/metadatafield.component // Constants and types import { APP_CONSTANTS } from '../shared/constants'; -// SubscriptionManager not needed as we no longer manage subscriptions here @Component({ selector: 'app-metadata-selector', From b6eda88ce210f278b2d5cd78179a2d8a922e0e9a Mon Sep 17 00:00:00 2001 From: Eryk Kullikowski Date: Tue, 16 Sep 2025 17:31:14 +0200 Subject: [PATCH 17/21] fix(submit): enhance navigation state handling with fallback for router mocks --- src/app/submit/submit.component.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/app/submit/submit.component.ts b/src/app/submit/submit.component.ts index 88ebd70..0908495 100644 --- a/src/app/submit/submit.component.ts +++ b/src/app/submit/submit.component.ts @@ -99,10 +99,26 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { this.loadData(); // Capture metadata from navigation state if coming from metadata-selector let state: { metadata?: Metadata } | undefined; - // Use Router.getCurrentNavigation when available (only during navigation) - const nav = this.router.getCurrentNavigation(); + // Use Router.getCurrentNavigation when available (only during navigation). In tests/mocks this may be undefined. + type RouterWithNav = Router & { + getCurrentNavigation?: () => { extras?: { state?: unknown } } | null; + }; + const routerNav = this.router as RouterWithNav; + const hasGetCurrentNavigation = + typeof routerNav.getCurrentNavigation === 'function'; + const nav = hasGetCurrentNavigation ? routerNav.getCurrentNavigation!() : null; if (nav?.extras?.state) { state = nav.extras.state as { metadata?: Metadata }; + } else if ( + typeof window !== 'undefined' && + typeof window.history !== 'undefined' && + 'state' in window.history + ) { + // Fallback for cases where component is reloaded or Router mock lacks the API + const histState = window.history.state as unknown; + if (histState && typeof histState === 'object') { + state = histState as { metadata?: Metadata }; + } } if (state?.metadata) { this.incomingMetadata = state.metadata; From aa2f3709021afeb444b3a2e8868ba86a4e6a1c11 Mon Sep 17 00:00:00 2001 From: Eryk Kulikowski <101262459+ErykKul@users.noreply.github.com> Date: Tue, 16 Sep 2025 17:35:50 +0200 Subject: [PATCH 18/21] Update src/app/submit/submit.component.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/app/submit/submit.component.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/app/submit/submit.component.ts b/src/app/submit/submit.component.ts index 0908495..5fef330 100644 --- a/src/app/submit/submit.component.ts +++ b/src/app/submit/submit.component.ts @@ -100,13 +100,11 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { // Capture metadata from navigation state if coming from metadata-selector let state: { metadata?: Metadata } | undefined; // Use Router.getCurrentNavigation when available (only during navigation). In tests/mocks this may be undefined. - type RouterWithNav = Router & { - getCurrentNavigation?: () => { extras?: { state?: unknown } } | null; - }; - const routerNav = this.router as RouterWithNav; const hasGetCurrentNavigation = - typeof routerNav.getCurrentNavigation === 'function'; - const nav = hasGetCurrentNavigation ? routerNav.getCurrentNavigation!() : null; + typeof (this.router as any).getCurrentNavigation === 'function'; + const nav = hasGetCurrentNavigation + ? ((this.router as any).getCurrentNavigation() as { extras?: { state?: unknown } } | null) + : null; if (nav?.extras?.state) { state = nav.extras.state as { metadata?: Metadata }; } else if ( From 97ed8266958ee0d0fb92569456ec095e94cb57c7 Mon Sep 17 00:00:00 2001 From: Eryk Kulikowski <101262459+ErykKul@users.noreply.github.com> Date: Tue, 16 Sep 2025 17:36:00 +0200 Subject: [PATCH 19/21] Update src/app/metadata-selector/metadata-selector.component.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/app/metadata-selector/metadata-selector.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/metadata-selector/metadata-selector.component.ts b/src/app/metadata-selector/metadata-selector.component.ts index 920161b..b98448e 100644 --- a/src/app/metadata-selector/metadata-selector.component.ts +++ b/src/app/metadata-selector/metadata-selector.component.ts @@ -127,7 +127,7 @@ export class MetadataSelectorComponent implements OnInit { } addChild(v: TreeNode, rowDataMap: Map>): void { - if (v.data?.id == '') { + if (v.data?.id === '') { return; } const parent = rowDataMap.get(v.data!.parent!)!; From cc6fbe4ffd2030668effee1b2f990eb7f6c54fdd Mon Sep 17 00:00:00 2001 From: Eryk Kulikowski <101262459+ErykKul@users.noreply.github.com> Date: Tue, 16 Sep 2025 17:36:55 +0200 Subject: [PATCH 20/21] Update src/app/metadata-selector/metadata-selector.component.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/app/metadata-selector/metadata-selector.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/metadata-selector/metadata-selector.component.ts b/src/app/metadata-selector/metadata-selector.component.ts index b98448e..a96b4e9 100644 --- a/src/app/metadata-selector/metadata-selector.component.ts +++ b/src/app/metadata-selector/metadata-selector.component.ts @@ -285,7 +285,7 @@ export class MetadataSelectorComponent implements OnInit { const dict: FieldDictonary = {}; Object.keys(d).forEach((k) => { const f = d[k]; - if (this.rowNodeMap.get(f.id!)?.data?.action == Fieldaction.Copy) { + if (this.rowNodeMap.get(f.id!)?.data?.action === Fieldaction.Copy) { const field: MetadataField = { expandedvalue: f.expandedvalue, multiple: f.multiple, From b5541b0c60e64121678a410c7ba21808cd5a43a3 Mon Sep 17 00:00:00 2001 From: Eryk Kulikowski <101262459+ErykKul@users.noreply.github.com> Date: Tue, 16 Sep 2025 17:37:03 +0200 Subject: [PATCH 21/21] Update src/app/metadata-selector/metadata-selector.component.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/app/metadata-selector/metadata-selector.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/metadata-selector/metadata-selector.component.ts b/src/app/metadata-selector/metadata-selector.component.ts index a96b4e9..c8e46c9 100644 --- a/src/app/metadata-selector/metadata-selector.component.ts +++ b/src/app/metadata-selector/metadata-selector.component.ts @@ -237,7 +237,7 @@ export class MetadataSelectorComponent implements OnInit { } let res: MetadataField[] = []; this.metadata.datasetVersion.metadataBlocks.citation.fields.forEach((f) => { - if (this.rowNodeMap.get(f.id!)?.data?.action == Fieldaction.Copy) { + if (this.rowNodeMap.get(f.id!)?.data?.action === Fieldaction.Copy) { const field: MetadataField = { expandedvalue: f.expandedvalue, multiple: f.multiple,