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..e56b862 --- /dev/null +++ b/src/app/metadata-selector/metadata-selector.component.html @@ -0,0 +1,43 @@ + + + +
+ + + + + + + + + + + + + + + + + + + + + +
Metadata that will be copied
Metadata 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..8607a54 --- /dev/null +++ b/src/app/metadata-selector/metadata-selector.component.spec.ts @@ -0,0 +1,254 @@ +import { + ComponentFixture, + TestBed, + fakeAsync, + flushMicrotasks, +} 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'; +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 = { + navigate: jasmine.createSpy('navigate'), + } as unknown as Router; + const datasetStub = { + newDataset: () => of({ persistentId: 'doi:10.1234/created' }), + getMetadata: () => of(makeMetadata()), + } as unknown as DatasetService; + const locationStub = { back: jasmine.createSpy('back') } as any as Location; + await TestBed.configureTestingModule({ + imports: [MetadataSelectorComponent], + providers: [ + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + { provide: Router, useValue: routerStub }, + { provide: DatasetService, useValue: datasetStub }, + { provide: Location, useValue: locationStub }, + ], + }) + // Keep template as-is; component uses PrimeNG lightweightly + .compileComponents(); + + 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(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + 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 new file mode 100644 index 0000000..c8e46c9 --- /dev/null +++ b/src/app/metadata-selector/metadata-selector.component.ts @@ -0,0 +1,322 @@ +// Author: Eryk Kulikowski @ KU Leuven (2023). Apache 2.0 License + +import { Component, OnInit, inject } from '@angular/core'; +import { Router } from '@angular/router'; +import { Location } from '@angular/common'; +import { firstValueFrom } from 'rxjs'; + +// Services +import { DataStateService } from '../data.state.service'; +import { CredentialsService } from '../credentials.service'; +import { DatasetService } from '../dataset.service'; + +// Models +import { MetadataRequest } from '../models/metadata-request'; +import { + Field, + Fieldaction, + FieldDictonary, + Metadata, + MetadataField, +} from '../models/field'; + +// PrimeNG +import { TreeNode, PrimeTemplate } from 'primeng/api'; +import { ButtonDirective } from 'primeng/button'; +import { Ripple } from 'primeng/ripple'; +import { TreeTableModule } from 'primeng/treetable'; + +// Components +import { MetadatafieldComponent } from '../metadatafield/metadatafield.component'; + +// Constants and types +import { APP_CONSTANTS } from '../shared/constants'; + +@Component({ + selector: 'app-metadata-selector', + templateUrl: './metadata-selector.component.html', + styleUrls: ['./metadata-selector.component.scss'], + imports: [ + ButtonDirective, + Ripple, + PrimeTemplate, + TreeTableModule, + MetadatafieldComponent, + ], +}) +export class MetadataSelectorComponent implements OnInit { + private readonly dataStateService = inject(DataStateService); + private readonly location = inject(Location); + private readonly router = inject(Router); + private readonly credentialsService = inject(CredentialsService); + private readonly datasetService = inject(DatasetService); + + // Icon constants + readonly icon_copy = APP_CONSTANTS.ICONS.UPDATE; + + metadata?: Metadata; + + root?: TreeNode; + rootNodeChildren: TreeNode[] = []; + rowNodeMap: Map> = new Map>(); + + id = 0; + + constructor() {} + + ngOnInit(): void { + // Load metadata immediately for rendering in the tree table + this.loadData(); + } + + 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)); + 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; + } + } + + submit() { + const metadata = this.filteredMetadata(); + this.router.navigate(['/submit'], { state: { metadata } }); + } + + back(): void { + this.location.back(); + } + + 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..34927f9 100644 --- a/src/app/submit/submit.component.html +++ b/src/app/submit/submit.component.html @@ -4,10 +4,14 @@ + + Overview of actions + + - + @@ -31,28 +35,6 @@

- - 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..7f203fb 100644 --- a/src/app/submit/submit.component.spec.ts +++ b/src/app/submit/submit.component.spec.ts @@ -1,26 +1,107 @@ 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 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 () => { + dataStateStub = { + 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: 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 }, ], - }) - // 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; @@ -30,4 +111,83 @@ describe('SubmitComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + 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 8b374de..5fef330 100644 --- a/src/app/submit/submit.component.ts +++ b/src/app/submit/submit.component.ts @@ -20,26 +20,17 @@ 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'; -import { MetadataRequest } from '../models/metadata-request'; -import { - Field, - Fieldaction, - FieldDictonary, - Metadata, - MetadataField, -} from '../models/field'; +import { Metadata } from '../models/field'; // 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'; // Components -import { MetadatafieldComponent } from '../metadatafield/metadatafield.component'; import { SubmittedFileComponent } from '../submitted-file/submitted-file.component'; // Constants and types @@ -58,8 +49,6 @@ import { SubscriptionManager } from '../shared/types'; FormsModule, PrimeTemplate, Button, - TreeTableModule, - MetadatafieldComponent, SubmittedFileComponent, ], }) @@ -78,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; @@ -100,20 +91,37 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { sendEmailOnSuccess = false; popup = false; hasAccessToCompute = false; + private incomingMetadata?: Metadata; - metadata?: Metadata; - - root?: TreeNode; - rootNodeChildren: TreeNode[] = []; - rowNodeMap: Map> = new Map>(); - - id = 0; - - constructor() { } + constructor() {} ngOnInit(): void { this.loadData(); - const subscription = this.dataService + // 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. + const hasGetCurrentNavigation = + 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 ( + 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; + } + const accessCheckSub = this.dataService .checkAccessToQueue( '', this.credentialsService.credentials.dataverse_token, @@ -121,27 +129,29 @@ 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 { // 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 + // 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) => { - dataSubscription?.unsubscribe(); + this.dataStatusSub?.unsubscribe(); if (res.data !== undefined) { this.setData(res.data); } @@ -153,11 +163,14 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { } }, error: (err) => { - dataSubscription?.unsubscribe(); - this.notificationService.showError(`Getting status of data failed: ${err.error}`); + this.dataStatusSub?.unsubscribe(); + this.notificationService.showError( + `Getting status of data failed: ${err.error}`, + ); this.router.navigate(['/connect']); }, }); + this.subscriptions.add(this.dataStatusSub); } hasUnfinishedDataFiles(): boolean { @@ -183,30 +196,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[] { @@ -280,26 +269,28 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { } } - const httpSubscription = this.submitService + const submitSub = 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(); }, error: (err) => { this.notificationService.showError(`Store failed: ${err.error}`); this.router.navigate(['/connect']); }, }); + this.subscriptions.add(submitSub); } back(): void { @@ -319,12 +310,11 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { } async newDataset(collectionId: string): Promise { - const metadata = this.filteredMetadata(); const data = await firstValueFrom( this.datasetService.newDataset( collectionId, this.credentialsService.credentials.dataverse_token, - metadata, + this.incomingMetadata, // use metadata selected in metadata-selector when present ), ); if (data.persistentId !== undefined && data.persistentId !== '') { @@ -336,223 +326,4 @@ export class SubmitComponent implements OnInit, OnDestroy, SubscriptionManager { 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/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 }