diff --git a/.eslintrc.json b/.eslintrc.json index 0bc384a39a..eea6ff8a61 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,6 +1,8 @@ { "root": true, - "ignorePatterns": ["projects/**/*"], + "ignorePatterns": [ + "projects/**/*", + "src/app/shared/sdk/models/ingestor/**"], // Ignore autogenerated files "parserOptions": { "ecmaVersion": 2020, "sourceType": "module", diff --git a/package-lock.json b/package-lock.json index e10fd0d241..291c1a527d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@jsonforms/angular": "^3.5.1", "@jsonforms/angular-material": "^3.5.1", "@jsonforms/core": "^3.5.1", + "@jsonforms/react": "^3.5.1", "@ngbracket/ngx-layout": "^16.0.0", "@ngrx/effects": "^19.1.0", "@ngrx/operators": "^19.1.0", @@ -3914,6 +3915,19 @@ } } }, + "node_modules/@jsonforms/react": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@jsonforms/react/-/react-3.6.0.tgz", + "integrity": "sha512-dor7FYltCkNkAM+SVZGtabjpUhGlj0/coAqx7GIZ8h+leET+d1sLEAc8kfxxh6gZBq9C4KAErb0Pj3uHedOs9Q==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21" + }, + "peerDependencies": { + "@jsonforms/core": "3.6.0", + "react": "^16.12.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@jsonjoy.com/base64": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", @@ -15193,6 +15207,16 @@ "node": ">=0.10.0" } }, + "node_modules/react": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", + "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", diff --git a/package.json b/package.json index 38050d3e69..8d0a85cd95 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@jsonforms/angular": "^3.5.1", "@jsonforms/angular-material": "^3.5.1", "@jsonforms/core": "^3.5.1", + "@jsonforms/react": "^3.5.1", "@ngbracket/ngx-layout": "^16.0.0", "@ngrx/effects": "^19.1.0", "@ngrx/operators": "^19.1.0", diff --git a/src/app/_layout/app-header/_app-header-theme.scss b/src/app/_layout/app-header/_app-header-theme.scss index 604d21c036..1fbe57dc0f 100644 --- a/src/app/_layout/app-header/_app-header-theme.scss +++ b/src/app/_layout/app-header/_app-header-theme.scss @@ -7,10 +7,16 @@ .header { mat-toolbar { - background-color: mat.m2-get-color-from-palette($primary, "default-contrast"); - border-bottom: 2px solid mat.m2-get-color-from-palette($primary, "default"); + background-color: mat.m2-get-color-from-palette( + $primary, + "default-contrast" + ); + border-bottom: 2px solid + mat.m2-get-color-from-palette($primary, "default"); } - a, h3, div { + a, + h3, + div { color: mat.m2-get-color-from-palette($primary, "default"); } } diff --git a/src/app/_layout/app-header/app-header.component.html b/src/app/_layout/app-header/app-header.component.html index edb823889f..1123522112 100644 --- a/src/app/_layout/app-header/app-header.component.html +++ b/src/app/_layout/app-header/app-header.component.html @@ -1,6 +1,8 @@
- + + + + diff --git a/src/app/_layout/app-header/app-header.component.scss b/src/app/_layout/app-header/app-header.component.scss index a064ecd304..6579c1f34e 100644 --- a/src/app/_layout/app-header/app-header.component.scss +++ b/src/app/_layout/app-header/app-header.component.scss @@ -7,7 +7,8 @@ mat-toolbar { height: 3.5rem; - a, .main-menu { + a, + .main-menu { height: 2.5rem; padding: 0.5rem; @@ -22,7 +23,8 @@ cursor: pointer; } - .spacer, .title { + .spacer, + .title { flex: 1 1 auto; } diff --git a/src/app/_layout/app-header/app-header.component.ts b/src/app/_layout/app-header/app-header.component.ts index 58c7acdc76..aead229273 100644 --- a/src/app/_layout/app-header/app-header.component.ts +++ b/src/app/_layout/app-header/app-header.component.ts @@ -38,6 +38,7 @@ export class AppHeaderComponent implements OnInit { ? "scicat-header-logo-icon.png" : "scicat-header-logo-full.png"; siteHeaderLogo = this.config.siteHeaderLogo ?? "site-header-logo.png"; + ingestorEnabled = this.config.ingestorComponent?.ingestorEnabled ?? false; oAuth2Endpoints: OAuth2Endpoint[] = []; username$ = this.store.select(selectCurrentUserName); diff --git a/src/app/app-config.service.spec.ts b/src/app/app-config.service.spec.ts index 8ca39f2618..21f32257a3 100644 --- a/src/app/app-config.service.spec.ts +++ b/src/app/app-config.service.spec.ts @@ -250,6 +250,9 @@ const appConfig: AppConfigInterface = { ], conditions: [], }, + ingestorComponent: { + ingestorEnabled: true, + }, }; describe("AppConfigService", () => { diff --git a/src/app/app-config.service.ts b/src/app/app-config.service.ts index e20f86898b..ba3931b312 100644 --- a/src/app/app-config.service.ts +++ b/src/app/app-config.service.ts @@ -5,6 +5,7 @@ import { firstValueFrom, forkJoin, of } from "rxjs"; import { catchError, timeout } from "rxjs/operators"; import { DatasetDetailComponentConfig, + IngestorComponentConfig, LabelsLocalization, ListSettings, TableColumn, @@ -50,6 +51,7 @@ export class MainPageConfiguration { export class MainMenuOptions { datasets: boolean; + ingestor: boolean; files: boolean; instruments: boolean; jobs: boolean; @@ -143,6 +145,7 @@ export interface AppConfigInterface { mainMenu?: MainMenuConfiguration; supportEmail?: string; checkBoxFilterClickTrigger?: boolean; + ingestorComponent?: IngestorComponentConfig; } function isMainPageConfiguration(obj: any): obj is MainPageConfiguration { diff --git a/src/app/app-routing/app-routing.module.ts b/src/app/app-routing/app-routing.module.ts index 15b871c605..bebe91433f 100644 --- a/src/app/app-routing/app-routing.module.ts +++ b/src/app/app-routing/app-routing.module.ts @@ -5,6 +5,7 @@ import { ErrorPageComponent } from "shared/modules/error-page/error-page.compone import { AppLayoutComponent } from "_layout/app-layout/app-layout.component"; import { AppMainLayoutComponent } from "_layout/app-main-layout/app-main-layout.component"; import { ServiceGuard } from "./service.guard"; +import { IngestorGuard } from "./ingestor.guard"; import { MainPageGuard } from "./main-page"; import { RedirectingComponent } from "./redirecting.component"; @@ -108,6 +109,14 @@ export const routes: Routes = [ (m) => m.HelpFeatureModule, ), }, + { + path: "ingestor", + loadChildren: () => + import("./lazy/ingestor-routing/ingestor.feature.module").then( + (m) => m.IngestorFeatureModule, + ), + canActivate: [IngestorGuard], + }, { path: "logbooks", loadChildren: () => diff --git a/src/app/app-routing/ingestor.guard.ts b/src/app/app-routing/ingestor.guard.ts new file mode 100644 index 0000000000..8f71e11174 --- /dev/null +++ b/src/app/app-routing/ingestor.guard.ts @@ -0,0 +1,37 @@ +import { Injectable } from "@angular/core"; +import { + ActivatedRouteSnapshot, + CanActivate, + Router, + RouterStateSnapshot, +} from "@angular/router"; +import { AppConfigService } from "app-config.service"; + +@Injectable({ + providedIn: "root", +}) +export class IngestorGuard implements CanActivate { + appConfig = this.appConfigService.getConfig(); + + constructor( + private appConfigService: AppConfigService, + private router: Router, + ) {} + + canActivate( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + ): boolean { + if (this.appConfig.ingestorComponent?.ingestorEnabled) { + return true; + } else { + this.router.navigate(["/404"], { + skipLocationChange: true, + queryParams: { + url: state.url, + }, + }); + return false; + } + } +} diff --git a/src/app/app-routing/lazy/ingestor-routing/ingestor.feature.module.ts b/src/app/app-routing/lazy/ingestor-routing/ingestor.feature.module.ts new file mode 100644 index 0000000000..8796c9c34e --- /dev/null +++ b/src/app/app-routing/lazy/ingestor-routing/ingestor.feature.module.ts @@ -0,0 +1,8 @@ +import { NgModule } from "@angular/core"; +import { IngestorRoutingModule } from "./ingestor.routing.module"; +import { IngestorModule } from "ingestor/ingestor.module"; + +@NgModule({ + imports: [IngestorModule, IngestorRoutingModule], +}) +export class IngestorFeatureModule {} diff --git a/src/app/app-routing/lazy/ingestor-routing/ingestor.routing.module.ts b/src/app/app-routing/lazy/ingestor-routing/ingestor.routing.module.ts new file mode 100644 index 0000000000..24df36f663 --- /dev/null +++ b/src/app/app-routing/lazy/ingestor-routing/ingestor.routing.module.ts @@ -0,0 +1,15 @@ +import { NgModule } from "@angular/core"; +import { RouterModule, Routes } from "@angular/router"; +import { IngestorWrapperComponent } from "ingestor/ingestor-page/ingestor-wrapper.component"; + +const routes: Routes = [ + { + path: "", + component: IngestorWrapperComponent, + }, +]; +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class IngestorRoutingModule {} diff --git a/src/app/datasets/dashboard/dashboard.component.html b/src/app/datasets/dashboard/dashboard.component.html index 446ec7c745..11bbf53510 100644 --- a/src/app/datasets/dashboard/dashboard.component.html +++ b/src/app/datasets/dashboard/dashboard.component.html @@ -13,6 +13,10 @@
+ { }); describe("#openDialog()", () => { - it("should dispatch an addDatasetAction when dialog returns a value", () => { - jasmine.clock().install(); - jasmine.clock().mockDate(new Date(2019, 12, 17, 12, 0, 0)); - - dispatchSpy = spyOn(store, "dispatch"); - - const currentUser = createMock({ - id: "testId", - username: "ldap.Test User", - email: "test@email.com", - realm: "test", - emailVerified: true, - authStrategy: "local", - }); - - const dataset: DatasetsControllerCreateV3Request = { - accessGroups: [], - contactEmail: currentUser.email, - creationTime: new Date().toISOString(), - datasetName: "Test Name", - description: "Test description", - isPublished: false, - keywords: [], - owner: currentUser.username.replace("ldap.", ""), - ownerEmail: currentUser.email, - ownerGroup: "test", - packedSize: 0, - size: 0, - sourceFolder: "/nfs/test", - type: "derived", - inputDatasets: [], - investigator: currentUser.email, - scientificMetadata: {}, - usedSoftware: ["test software"], - numberOfFilesArchived: 0, - creationLocation: undefined, - principalInvestigator: undefined, - }; - - component.currentUser = currentUser; - component.userGroups = ["test"]; + it("should call Ingestor method", () => { + const mockIngestor = jasmine.createSpyObj("IngestorCreationComponent", [ + "onClickAddIngestion", + ]); + component.ingestor = mockIngestor; component.openDialog(); - expect(dispatchSpy).toHaveBeenCalledTimes(1); - expect(dispatchSpy).toHaveBeenCalledWith( - addDatasetAction({ - dataset: dataset, - }), - ); + expect(mockIngestor.onClickAddIngestion).toHaveBeenCalledTimes(1); }); }); describe("#onPageChange()", () => { - it("should dispatch a changePangeAction", () => { + it("should dispatch a changePageAction", () => { dispatchSpy = spyOn(store, "dispatch"); const event: PageChangeEvent = { diff --git a/src/app/datasets/dashboard/dashboard.component.ts b/src/app/datasets/dashboard/dashboard.component.ts index a71b29a35e..ed3b175cab 100644 --- a/src/app/datasets/dashboard/dashboard.component.ts +++ b/src/app/datasets/dashboard/dashboard.component.ts @@ -45,6 +45,7 @@ import { } from "@scicatproject/scicat-sdk-ts-angular"; import { loadDefaultSettings } from "state-management/actions/user.actions"; import { AppConfigService } from "app-config.service"; +import { IngestorCreationComponent } from "ingestor/ingestor-page/ingestor-creation.component"; @Component({ selector: "dashboard", @@ -73,6 +74,7 @@ export class DashboardComponent implements OnInit, OnDestroy { clearColumnSearch = false; @ViewChild(MatSidenav, { static: false }) sideNav!: MatSidenav; + @ViewChild("ingestor") ingestor: IngestorCreationComponent; constructor( public appConfigService: AppConfigService, @@ -105,47 +107,9 @@ export class DashboardComponent implements OnInit, OnDestroy { } openDialog(): void { - const dialogRef = this.dialog.open(AddDatasetDialogComponent, { - width: "500px", - data: { userGroups: this.userGroups }, - }); - - dialogRef.afterClosed().subscribe((res) => { - if (res) { - const { username, email } = this.currentUser; - const dataset = { - accessGroups: [], - contactEmail: email, // Required - creationTime: new Date().toISOString(), // Required - datasetName: res.datasetName, - description: res.description, - isPublished: false, - keywords: [], - owner: username.replace("ldap.", ""), // Required - ownerEmail: email, - ownerGroup: res.ownerGroup, // Required - packedSize: 0, - size: 0, - sourceFolder: res.sourceFolder, // Required - type: "derived", // Required - inputDatasets: [], // Required - investigator: email, // Required - scientificMetadata: {}, - numberOfFilesArchived: 0, // Required - principalInvestigator: undefined, // Required - creationLocation: undefined, // Required - usedSoftware: res.usedSoftware - .split(",") - .map((entry: string) => entry.trim()) - .filter((entry: string) => entry !== ""), // Required - }; - this.store.dispatch( - addDatasetAction({ - dataset: dataset, - }), - ); - } - }); + if (this.ingestor) { + this.ingestor.onClickAddIngestion(); + } } ngOnInit() { diff --git a/src/app/datasets/datafiles-actions/datafiles-action.component.scss b/src/app/datasets/datafiles-actions/datafiles-action.component.scss index 43d6d664c1..79b9d1c7a4 100644 --- a/src/app/datasets/datafiles-actions/datafiles-action.component.scss +++ b/src/app/datasets/datafiles-actions/datafiles-action.component.scss @@ -7,4 +7,4 @@ button { width: auto; vertical-align: middle; } -} \ No newline at end of file +} diff --git a/src/app/datasets/datafiles-actions/datafiles-actions.component.scss b/src/app/datasets/datafiles-actions/datafiles-actions.component.scss index cdc435d73b..2a13354b9b 100644 --- a/src/app/datasets/datafiles-actions/datafiles-actions.component.scss +++ b/src/app/datasets/datafiles-actions/datafiles-actions.component.scss @@ -1,3 +1,3 @@ .dataset-datafiles-actions { float: right; -} \ No newline at end of file +} diff --git a/src/app/datasets/datafiles/datafiles.component.scss b/src/app/datasets/datafiles/datafiles.component.scss index 89e181f679..f0663d1d2d 100644 --- a/src/app/datasets/datafiles/datafiles.component.scss +++ b/src/app/datasets/datafiles/datafiles.component.scss @@ -13,5 +13,4 @@ mat-icon { .nbr-of-files { font-size: larger; } - } diff --git a/src/app/datasets/dataset-detail/dataset-detail-dynamic/dataset-detail-dynamic.component.scss b/src/app/datasets/dataset-detail/dataset-detail-dynamic/dataset-detail-dynamic.component.scss index 465594005f..75c36a30c4 100644 --- a/src/app/datasets/dataset-detail/dataset-detail-dynamic/dataset-detail-dynamic.component.scss +++ b/src/app/datasets/dataset-detail/dataset-detail-dynamic/dataset-detail-dynamic.component.scss @@ -130,7 +130,7 @@ mat-card { .scientific-metadata-content { height: 100%; - + > div { height: 100%; } diff --git a/src/app/datasets/dataset-detail/dataset-detail/dataset-detail.component.scss b/src/app/datasets/dataset-detail/dataset-detail/dataset-detail.component.scss index 50dd208df2..013be16c0a 100644 --- a/src/app/datasets/dataset-detail/dataset-detail/dataset-detail.component.scss +++ b/src/app/datasets/dataset-detail/dataset-detail/dataset-detail.component.scss @@ -1,7 +1,11 @@ mat-card { margin: 1em; - .scientific-header, .related-header, .creator-header, .file-header, .general-header { + .scientific-header, + .related-header, + .creator-header, + .file-header, + .general-header { display: flex; align-items: center; padding: 1em; diff --git a/src/app/datasets/datasets-filter/datasets-filter.component.html b/src/app/datasets/datasets-filter/datasets-filter.component.html index a4a281bc32..ff6a7cd9ec 100644 --- a/src/app/datasets/datasets-filter/datasets-filter.component.html +++ b/src/app/datasets/datasets-filter/datasets-filter.component.html @@ -229,7 +229,10 @@ (input)="updateConditionUnit(i, $event)" [matAutocomplete]="rhsUnits" /> - + If you have any questions or need help, please contact - {{ supportEmail }}. + {{ supportEmail }}.

diff --git a/src/app/ingestor/_ingestor-theme.scss b/src/app/ingestor/_ingestor-theme.scss new file mode 100644 index 0000000000..794f62a937 --- /dev/null +++ b/src/app/ingestor/_ingestor-theme.scss @@ -0,0 +1,52 @@ +@use "sass:map"; +@use "@angular/material" as mat; + +@mixin color($theme) { + $color-config: map.get($theme, "color"); + $primary: map.get($color-config, "primary"); + $header-1: map.get($color-config, "header-1"); + $header-2: map.get($color-config, "header-2"); + $header-3: map.get($color-config, "header-3"); + $accent: map.get($color-config, "accent"); + $warn: map.get($color-config, "warn"); + mat-card { + .scicat-header { + background-color: mat.m2-get-color-from-palette($primary, "lighter"); + } + + .organizational-header { + background-color: mat.m2-get-color-from-palette($header-1, "lighter"); + } + + .sample-header { + background-color: mat.m2-get-color-from-palette($header-2, "lighter"); + } + + .instrument-header { + background-color: mat.m2-get-color-from-palette($header-3, "lighter"); + } + + .acquisition-header { + background-color: mat.m2-get-color-from-palette($accent, "lighter"); + } + } + + .error-message { + color: mat.m2-get-color-from-palette($warn, "default"); + } + + .error-icon { + color: mat.m2-get-color-from-palette($warn, "default"); + } + + .success-icon { + color: green; + } +} + +@mixin theme($theme) { + $color-config: mat.m2-get-color-config($theme); + @if $color-config != null { + @include color($theme); + } +} diff --git a/src/app/ingestor/ingestor-dialogs/confirmation-dialog/ingestor.confirmation-dialog.component.spec.ts b/src/app/ingestor/ingestor-dialogs/confirmation-dialog/ingestor.confirmation-dialog.component.spec.ts new file mode 100644 index 0000000000..6ccc6616bd --- /dev/null +++ b/src/app/ingestor/ingestor-dialogs/confirmation-dialog/ingestor.confirmation-dialog.component.spec.ts @@ -0,0 +1,52 @@ +import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing"; +import { IngestorConfirmationDialogComponent } from "./ingestor.confirmation-dialog.component"; +import { + MatDialogRef, + MAT_DIALOG_DATA, + MatDialogModule, +} from "@angular/material/dialog"; +import { NO_ERRORS_SCHEMA } from "@angular/core"; + +describe("IngestorConfirmationDialogComponent", () => { + let component: IngestorConfirmationDialogComponent; + let fixture: ComponentFixture; + + const mockDialogRef = { + close: jasmine.createSpy("close"), + }; + + const mockDialogData = { + message: "Test message", + header: "Test header", + }; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + declarations: [IngestorConfirmationDialogComponent], + imports: [MatDialogModule], + providers: [ + { provide: MatDialogRef, useValue: mockDialogRef }, + { provide: MAT_DIALOG_DATA, useValue: mockDialogData }, + ], + }); + + TestBed.compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(IngestorConfirmationDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + if (fixture) { + fixture.destroy(); + } + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/ingestor/ingestor-dialogs/confirmation-dialog/ingestor.confirmation-dialog.component.ts b/src/app/ingestor/ingestor-dialogs/confirmation-dialog/ingestor.confirmation-dialog.component.ts new file mode 100644 index 0000000000..eba0243a12 --- /dev/null +++ b/src/app/ingestor/ingestor-dialogs/confirmation-dialog/ingestor.confirmation-dialog.component.ts @@ -0,0 +1,39 @@ +import { Component, Inject, Injector, Type } from "@angular/core"; +import { MatDialogRef, MAT_DIALOG_DATA } from "@angular/material/dialog"; + +@Component({ + selector: "app-ingestor-confirmation-dialog", + templateUrl: "./ingestor.confirmation-dialog.html", + styleUrls: ["../../ingestor-page/ingestor.component.scss"], + standalone: false, +}) +export class IngestorConfirmationDialogComponent { + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: any, + ) {} + + get message(): string { + return this.data.message || "Are you sure?"; + } + + get header(): string { + return this.data.header || "Confirmation"; + } + + get injector(): Injector { + return this.data.injector || null; + } + + get messageComponent(): Type | null { + return this.data.messageComponent || null; + } + + onConfirm(): void { + this.dialogRef.close(true); + } + + onCancel(): void { + this.dialogRef.close(false); + } +} diff --git a/src/app/ingestor/ingestor-dialogs/confirmation-dialog/ingestor.confirmation-dialog.html b/src/app/ingestor/ingestor-dialogs/confirmation-dialog/ingestor.confirmation-dialog.html new file mode 100644 index 0000000000..054bb484f5 --- /dev/null +++ b/src/app/ingestor/ingestor-dialogs/confirmation-dialog/ingestor.confirmation-dialog.html @@ -0,0 +1,32 @@ +
+

{{ header }}

+ +
+ +
+

{{ message }}

+ +
+
+ + + + + diff --git a/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.confirm-transfer-dialog-page.component.spec.ts b/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.confirm-transfer-dialog-page.component.spec.ts new file mode 100644 index 0000000000..a4a87845b9 --- /dev/null +++ b/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.confirm-transfer-dialog-page.component.spec.ts @@ -0,0 +1,47 @@ +import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing"; +import { IngestorConfirmTransferDialogPageComponent } from "./ingestor.confirm-transfer-dialog-page.component"; +import { MatDialog, MatDialogModule } from "@angular/material/dialog"; +import { StoreModule } from "@ngrx/store"; +import { provideMockStore } from "@ngrx/store/testing"; +import { NO_ERRORS_SCHEMA } from "@angular/core"; + +describe("IngestorConfirmTransferDialogPageComponent", () => { + let component: IngestorConfirmTransferDialogPageComponent; + let fixture: ComponentFixture; + + const mockDialog = { + open: jasmine.createSpy("open"), + }; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + declarations: [IngestorConfirmTransferDialogPageComponent], + imports: [MatDialogModule, StoreModule.forRoot({})], + providers: [ + provideMockStore(), + { provide: MatDialog, useValue: mockDialog }, + ], + }); + + TestBed.compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent( + IngestorConfirmTransferDialogPageComponent, + ); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + if (fixture) { + fixture.destroy(); + } + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.confirm-transfer-dialog-page.component.ts b/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.confirm-transfer-dialog-page.component.ts new file mode 100644 index 0000000000..77c0320d3e --- /dev/null +++ b/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.confirm-transfer-dialog-page.component.ts @@ -0,0 +1,147 @@ +import { + Component, + EventEmitter, + inject, + OnDestroy, + OnInit, + Output, +} from "@angular/core"; +import { + APIInformation, + IngestionRequestInformation, + IngestorHelper, +} from "../../../ingestor-page/helper/ingestor.component-helper"; +import { Store } from "@ngrx/store"; +import { + selectIngestionObjectAPIInformation, + selectIngestionObject, + selectIsIngestDatasetLoading, +} from "state-management/selectors/ingestor.selectors"; +import { MatDialog } from "@angular/material/dialog"; +import { MatCheckboxChange } from "@angular/material/checkbox"; +import { IngestorConfirmationDialogComponent } from "ingestor/ingestor-dialogs/confirmation-dialog/ingestor.confirmation-dialog.component"; +import * as fromActions from "state-management/actions/ingestor.actions"; +import { Subscription } from "rxjs"; + +@Component({ + selector: "ingestor-confirm-transfer-dialog-page", + templateUrl: "ingestor.confirm-transfer-dialog-page.html", + styleUrls: ["../../../ingestor-page/ingestor.component.scss"], + standalone: false, +}) +export class IngestorConfirmTransferDialogPageComponent + implements OnInit, OnDestroy +{ + private subscriptions: Subscription[] = []; + readonly dialog = inject(MatDialog); + + createNewTransferData: IngestionRequestInformation = + IngestorHelper.createEmptyRequestInformation(); + createNewTransferDataApiInformation: APIInformation = + IngestorHelper.createEmptyAPIInformation(); + + ingestionObjectApiInformation$ = this.store.select( + selectIngestionObjectAPIInformation, + ); + + ingestionObject$ = this.store.select(selectIngestionObject); + ingestDatasetLoading$ = this.store.select(selectIsIngestDatasetLoading); + + @Output() nextStep = new EventEmitter(); + @Output() backStep = new EventEmitter(); + + provideMergeMetaData = ""; + + copiedToClipboard = false; + ingestionDatasetIsLoading = false; + + constructor(private store: Store) {} + + ngOnInit() { + this.subscriptions.push( + this.ingestionObject$.subscribe((ingestionObject) => { + if (ingestionObject) { + this.createNewTransferData = ingestionObject; + } + }), + ); + + this.subscriptions.push( + this.ingestionObjectApiInformation$.subscribe((apiInformation) => { + if (apiInformation) { + this.createNewTransferDataApiInformation = apiInformation; + } + }), + ); + + this.subscriptions.push( + this.ingestDatasetLoading$.subscribe((loading) => { + this.ingestionDatasetIsLoading = loading; + }), + ); + + this.provideMergeMetaData = IngestorHelper.createMetaDataString( + this.createNewTransferData, + this.createNewTransferData.editorMode === "CREATION", + ); + } + + ngOnDestroy(): void { + this.subscriptions.forEach((subscription) => subscription.unsubscribe()); + } + onAutoArchiveChange($event: MatCheckboxChange): void { + this.createNewTransferData.autoArchive = $event.checked; + + this.store.dispatch( + fromActions.updateIngestionObject({ + ingestionObject: this.createNewTransferData, + }), + ); + } + + onClickBack(): void { + // Reset the ingestion request + this.store.dispatch(fromActions.resetIngestDataset()); + this.backStep.emit(); // Open previous dialog + } + + onClickConfirm(): void { + const dialogRef = this.dialog.open(IngestorConfirmationDialogComponent, { + data: { + header: + this.createNewTransferData.editorMode === "CREATION" + ? "Confirm creation" + : "Confirm ingestion", + message: + this.createNewTransferData.editorMode === "CREATION" + ? "Create a new dataset?" + : "Create a new dataset and start data transfer?", + }, + }); + + const dialogSub = dialogRef.afterClosed().subscribe(async (result) => { + if (result) { + this.createNewTransferData.mergedMetaDataString = + this.provideMergeMetaData; + + this.nextStep.emit(); + } + dialogSub.unsubscribe(); + }); + } + + onClickRetryRequests(): void { + this.onClickConfirm(); + } + + onCopyMetadata(): void { + navigator.clipboard.writeText(this.provideMergeMetaData).then( + () => { + this.copiedToClipboard = true; + }, + (err) => { + console.error("Failed to copy metadata to clipboard: ", err); + }, + ); + } +} diff --git a/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.confirm-transfer-dialog-page.html b/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.confirm-transfer-dialog-page.html new file mode 100644 index 0000000000..4303189909 --- /dev/null +++ b/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.confirm-transfer-dialog-page.html @@ -0,0 +1,202 @@ +
+
+

Confirm transfer

+ +
+ +
+ +
+
+ + +
+ +
+ Wait for the response from the server... +
+
+ +
+

+ + + Transfer request failed + + +

+ error +

The transfer request has failed.

+

+ {{createNewTransferDataApiInformation.ingestionRequestErrorMessage}} +

+
+ + +

+ +
+ + + Dataset created + + +
+ check_circle +

+ The data set was successfully created. You can find the data set + with the recorded metadata under the following details: +

+

+ Dataset ID: {{createNewTransferData.ingestionRequest.transferId}} +

+

+ Dataset Name: {{createNewTransferData.ingestionRequest.status}} +

+
+
+
+
+ +
+ + + Transfer requested + + +
+ check_circle +

+ The transfer request was successfully sent to the server. You will + receive an email as soon as the data transfer is complete. +

+

+ Transfer ID: {{createNewTransferData.ingestionRequest.transferId}} +

+

+ Transfer status: {{createNewTransferData.ingestionRequest.status}} +

+
+
+
+
+ +
+ + + Transfer Options + + + + Auto Archive + +

+ Automatically trigger archiving jobs after the data transfer has + finished. +

+
+
+
+ +
+ + + Confirm Metadata + + +
+
+      
+ {{ line }} +
+
+
+
+
+
+
+
+ + + + + + + + diff --git a/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.custom-metadata-dialog-page.component.spec.ts b/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.custom-metadata-dialog-page.component.spec.ts new file mode 100644 index 0000000000..a83e2be541 --- /dev/null +++ b/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.custom-metadata-dialog-page.component.spec.ts @@ -0,0 +1,39 @@ +import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing"; +import { IngestorCustomMetadataDialogPageComponent } from "./ingestor.custom-metadata-dialog-page.component"; +import { StoreModule } from "@ngrx/store"; +import { provideMockStore } from "@ngrx/store/testing"; +import { NO_ERRORS_SCHEMA } from "@angular/core"; + +describe("IngestorCustomMetadataDialogPageComponent", () => { + let component: IngestorCustomMetadataDialogPageComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + declarations: [IngestorCustomMetadataDialogPageComponent], + imports: [StoreModule.forRoot({})], + providers: [provideMockStore()], + }); + + TestBed.compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent( + IngestorCustomMetadataDialogPageComponent, + ); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + if (fixture) { + fixture.destroy(); + } + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.custom-metadata-dialog-page.component.ts b/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.custom-metadata-dialog-page.component.ts new file mode 100644 index 0000000000..10d4de6e55 --- /dev/null +++ b/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.custom-metadata-dialog-page.component.ts @@ -0,0 +1,171 @@ +import { + ChangeDetectorRef, + Component, + EventEmitter, + OnDestroy, + OnInit, + Output, +} from "@angular/core"; +import { JsonSchema } from "@jsonforms/core"; +import { + IngestionRequestInformation, + IngestorHelper, +} from "../../../ingestor-page/helper/ingestor.component-helper"; +import { IngestorMetadataEditorHelper } from "ingestor/ingestor-metadata-editor/ingestor-metadata-editor-helper"; +import { + selectIngestionObject, + selectIngestorRenderView, + selectUpdateEditorFromThirdParty, +} from "state-management/selectors/ingestor.selectors"; +import * as fromActions from "state-management/actions/ingestor.actions"; +import { Store } from "@ngrx/store"; +import { Subscription } from "rxjs"; +import { renderView } from "ingestor/ingestor-metadata-editor/ingestor-metadata-editor.component"; + +@Component({ + selector: "ingestor-custom-metadata-dialog", + templateUrl: "ingestor.custom-metadata-dialog-page.html", + styleUrls: ["../../../ingestor-page/ingestor.component.scss"], + standalone: false, +}) +export class IngestorCustomMetadataDialogPageComponent + implements OnInit, OnDestroy +{ + private subscriptions: Subscription[] = []; + ingestionObject$ = this.store.select(selectIngestionObject); + renderView$ = this.store.select(selectIngestorRenderView); + selectUpdateEditorFromThirdParty$ = this.store.select( + selectUpdateEditorFromThirdParty, + ); + + createNewTransferData: IngestionRequestInformation = + IngestorHelper.createEmptyRequestInformation(); + + @Output() nextStep = new EventEmitter(); + @Output() backStep = new EventEmitter(); + + customMetadataSchema: JsonSchema; + activeRenderView: renderView | null = null; + updateEditorFromThirdParty = false; + + uiNextButtonReady = false; + isCustomMetadataOk = false; + customMetadataErrors = ""; + + isCardContentVisible = { + scientific: true, + }; + + constructor( + private store: Store, + private cdr: ChangeDetectorRef, + ) {} + + ngOnInit() { + this.subscriptions.push( + this.ingestionObject$.subscribe((ingestionObject) => { + if (ingestionObject) { + this.createNewTransferData = ingestionObject; + + const customSchema = + this.createNewTransferData.selectedResolvedDecodedSchema; + + if ( + // Remove all keys which start with $. Json Forms can't handle this. When preparing $refs are already resolved. + customSchema && + typeof customSchema === "object" && + !Array.isArray(customSchema) + ) { + Object.keys(customSchema) + .filter((key) => key.startsWith("$")) + .forEach((key) => { + delete customSchema[key]; + }); + } + + this.customMetadataSchema = customSchema; + } + }), + ); + + this.subscriptions.push( + this.renderView$.subscribe((renderView) => { + if (renderView) { + // Check if renderView changed + if (this.activeRenderView !== renderView) { + this.activeRenderView = renderView; + } + } + }), + ); + + this.subscriptions.push( + this.selectUpdateEditorFromThirdParty$.subscribe((updateEditor) => { + // We need to rerender the editor if the user has changed the metadata in the third party + // So we get a flag, if it is true we unrender the editor + // and then we set it to false to render it again + this.updateEditorFromThirdParty = updateEditor; + if (updateEditor) { + this.cdr.detectChanges(); // Force the change detection to unrender the editor + this.store.dispatch( + fromActions.resetIngestionObjectFromThirdPartyFlag(), + ); + } + }), + ); + } + + ngOnDestroy(): void { + this.subscriptions.forEach((subscription) => subscription.unsubscribe()); + } + + onClickBack(): void { + this.store.dispatch( + fromActions.updateIngestionObject({ + ingestionObject: this.createNewTransferData, + }), + ); + + this.backStep.emit(); // Open previous dialog + } + + onClickNext(): void { + this.store.dispatch( + fromActions.updateIngestionObject({ + ingestionObject: this.createNewTransferData, + }), + ); + + this.nextStep.emit(); // Open next dialog + } + + onDataChangeCustomMetadata(event: any) { + this.createNewTransferData.customMetaData = event; + } + + onCreateNewTransferDataChange(updatedData: IngestionRequestInformation) { + Object.assign(this.createNewTransferData, updatedData); + } + + toggleCardContent(card: string): void { + this.isCardContentVisible[card] = !this.isCardContentVisible[card]; + } + + customMetadataErrorsHandler(errors: any[]) { + const result = IngestorMetadataEditorHelper.processMetadataErrors( + errors, + this.customMetadataSchema, + this.activeRenderView, + ); + + this.isCustomMetadataOk = result.isValid; + this.customMetadataErrors = result.errorString; + this.validateNextButton(); + this.cdr.detectChanges(); + } + + validateNextButton(): void { + // Don't force the user to fix all required entries + this.uiNextButtonReady = true; //this.isCustomMetadataOk; + } +} diff --git a/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.custom-metadata-dialog-page.html b/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.custom-metadata-dialog-page.html new file mode 100644 index 0000000000..343d59f698 --- /dev/null +++ b/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.custom-metadata-dialog-page.html @@ -0,0 +1,68 @@ +
+
+

Fill out user-specific metadata

+ +
+ +
+ +
+
+ + + +
+ info +
+ Scientific Metadata + + Fields in Red Are Required By The Schema. You can proceed with ingestion + without filling them out. + + + {{ isCustomMetadataOk ? 'check_circle_outline' : + 'error_outline'}} + {{ isCardContentVisible.scientific ? 'expand_less' : 'expand_more' + }} +
+ + + +
+
+ + + + diff --git a/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.extractor-metadata-dialog-page.component.spec.ts b/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.extractor-metadata-dialog-page.component.spec.ts new file mode 100644 index 0000000000..3101f9b73e --- /dev/null +++ b/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.extractor-metadata-dialog-page.component.spec.ts @@ -0,0 +1,39 @@ +import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing"; +import { IngestorExtractorMetadataDialogPageComponent } from "./ingestor.extractor-metadata-dialog-page.component"; +import { StoreModule } from "@ngrx/store"; +import { provideMockStore } from "@ngrx/store/testing"; +import { NO_ERRORS_SCHEMA } from "@angular/core"; + +describe("IngestorExtractorMetadataDialogPageComponent", () => { + let component: IngestorExtractorMetadataDialogPageComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + declarations: [IngestorExtractorMetadataDialogPageComponent], + imports: [StoreModule.forRoot({})], + providers: [provideMockStore()], + }); + + TestBed.compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent( + IngestorExtractorMetadataDialogPageComponent, + ); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + if (fixture) { + fixture.destroy(); + } + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.extractor-metadata-dialog-page.component.ts b/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.extractor-metadata-dialog-page.component.ts new file mode 100644 index 0000000000..ba842c0dcb --- /dev/null +++ b/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.extractor-metadata-dialog-page.component.ts @@ -0,0 +1,216 @@ +import { + ChangeDetectorRef, + Component, + EventEmitter, + OnDestroy, + OnInit, + Output, +} from "@angular/core"; +import { JsonSchema } from "@jsonforms/core"; +import { + APIInformation, + IngestionRequestInformation, + IngestorHelper, +} from "../../../ingestor-page/helper/ingestor.component-helper"; +import { IngestorMetadataEditorHelper } from "ingestor/ingestor-metadata-editor/ingestor-metadata-editor-helper"; +import { Store } from "@ngrx/store"; +import { + selectIngestionObjectAPIInformation, + selectIngestionObject, + selectIngestorRenderView, + selectUpdateEditorFromThirdParty, +} from "state-management/selectors/ingestor.selectors"; +import * as fromActions from "state-management/actions/ingestor.actions"; +import { Subscription } from "rxjs"; +import { renderView } from "ingestor/ingestor-metadata-editor/ingestor-metadata-editor.component"; + +@Component({ + selector: "ingestor-extractor-metadata-dialog-page", + templateUrl: "ingestor.extractor-metadata-dialog-page.html", + styleUrls: ["../../../ingestor-page/ingestor.component.scss"], + standalone: false, +}) +export class IngestorExtractorMetadataDialogPageComponent + implements OnInit, OnDestroy +{ + private subscriptions: Subscription[] = []; + metadataSchemaInstrument: JsonSchema; + metadataSchemaAcquisition: JsonSchema; + createNewTransferData: IngestionRequestInformation = + IngestorHelper.createEmptyRequestInformation(); + createNewTransferDataApiInformation: APIInformation = + IngestorHelper.createEmptyAPIInformation(); + + ingestionObjectApiInformation$ = this.store.select( + selectIngestionObjectAPIInformation, + ); + ingestionObject$ = this.store.select(selectIngestionObject); + renderView$ = this.store.select(selectIngestorRenderView); + selectUpdateEditorFromThirdParty$ = this.store.select( + selectUpdateEditorFromThirdParty, + ); + + @Output() nextStep = new EventEmitter(); + @Output() backStep = new EventEmitter(); + + activeRenderView: renderView | null = null; + updateEditorFromThirdParty = false; + extractorMetaDataReady = false; + extractorMetaDataStatus = ""; + extractorMetaDataError = false; + process = 0; + + uiNextButtonReady = false; + isAcquisitionMetadataOk = false; + acquisitionErrors = ""; + isInstrumentMetadataOk = false; + instrumentErrors = ""; + + isCardContentVisible = { + instrument: true, + acquisition: true, + }; + + constructor( + private store: Store, + private cdr: ChangeDetectorRef, + ) {} + + ngOnInit() { + this.subscriptions.push( + this.ingestionObject$.subscribe((ingestionObject) => { + if (ingestionObject) { + this.createNewTransferData = ingestionObject; + + const instrumentSchema = + this.createNewTransferData.selectedResolvedDecodedSchema.properties + .instrument; + const acqusitionSchema = + this.createNewTransferData.selectedResolvedDecodedSchema.properties + .acquisition; + + this.metadataSchemaInstrument = instrumentSchema; + this.metadataSchemaAcquisition = acqusitionSchema; + } + }), + ); + + this.subscriptions.push( + this.ingestionObjectApiInformation$.subscribe((apiInformation) => { + if (apiInformation) { + this.createNewTransferDataApiInformation = apiInformation; + + this.extractorMetaDataReady = + this.createNewTransferDataApiInformation.extractorMetaDataReady; + this.extractorMetaDataError = + this.createNewTransferDataApiInformation.metaDataExtractionFailed; + this.extractorMetaDataStatus = + this.createNewTransferDataApiInformation.extractorMetaDataStatus; + this.process = + this.createNewTransferDataApiInformation.extractorMetadataProgress; + } + }), + ); + + this.subscriptions.push( + this.renderView$.subscribe((renderView) => { + if (renderView) { + // Check if renderView changed + if (this.activeRenderView !== renderView) { + this.activeRenderView = renderView; + } + } + }), + ); + + this.subscriptions.push( + this.selectUpdateEditorFromThirdParty$.subscribe((updateEditor) => { + // We need to rerender the editor if the user has changed the metadata in the third party + // So we get a flag, if it is true we unrender the editor + // and then we set it to false to render it again + this.updateEditorFromThirdParty = updateEditor; + if (updateEditor) { + this.cdr.detectChanges(); // Force the change detection to unrender the editor + this.store.dispatch( + fromActions.resetIngestionObjectFromThirdPartyFlag(), + ); + } + }), + ); + } + + ngOnDestroy(): void { + this.subscriptions.forEach((subscription) => subscription.unsubscribe()); + } + + onClickBack(): void { + this.store.dispatch( + fromActions.updateIngestionObject({ + ingestionObject: this.createNewTransferData, + }), + ); + + this.backStep.emit(); // Open previous dialog + } + + onClickNext(): void { + this.store.dispatch( + fromActions.updateIngestionObject({ + ingestionObject: this.createNewTransferData, + }), + ); + + this.nextStep.emit(); // Open next dialog + } + + onDataChangeExtractorMetadataInstrument(event: any) { + this.createNewTransferData.extractorMetaData["instrument"] = event; + } + + onDataChangeExtractorMetadataAcquisition(event: any) { + this.createNewTransferData.extractorMetaData["acquisition"] = event; + } + + onCreateNewTransferDataChange(updatedData: IngestionRequestInformation) { + Object.assign(this.createNewTransferData, updatedData); + } + + toggleCardContent(card: string): void { + this.isCardContentVisible[card] = !this.isCardContentVisible[card]; + } + + instrumentErrorsHandler(errors: any[]) { + const result = IngestorMetadataEditorHelper.processMetadataErrors( + errors, + this.metadataSchemaInstrument, + this.activeRenderView, + ); + + this.isInstrumentMetadataOk = result.isValid; + this.instrumentErrors = result.errorString; + this.validateNextButton(); + this.cdr.detectChanges(); + } + + acquisitionErrorsHandler(errors: any[]) { + const result = IngestorMetadataEditorHelper.processMetadataErrors( + errors, + this.metadataSchemaAcquisition, + this.activeRenderView, + ); + + this.isAcquisitionMetadataOk = result.isValid; + this.acquisitionErrors = result.errorString; + this.validateNextButton(); + this.cdr.detectChanges(); + } + + validateNextButton(): void { + /*this.uiNextButtonReady = + this.isInstrumentMetadataOk && + this.isAcquisitionMetadataOk && + this.extractorMetaDataReady;*/ + + this.uiNextButtonReady = this.extractorMetaDataReady; + } +} diff --git a/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.extractor-metadata-dialog-page.html b/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.extractor-metadata-dialog-page.html new file mode 100644 index 0000000000..7c698098e3 --- /dev/null +++ b/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.extractor-metadata-dialog-page.html @@ -0,0 +1,146 @@ +
+
+

Correct dataset-specific metadata

+ +
+ +
+ +
+
+ + +
+ +

Wait for data extraction to finish...

+ +
+ {{process}}% +
+
+ + error + +
+ {{extractorMetaDataStatus}} +
+
+ +
+
+ + +
+ error +
+ The automatic metadata extraction was not successful. Please enter the + data manually. +
+
+
+
+
+ + +
+ biotech +
+ + Instrument Information + + Fields in Red Are Required By The Schema. You can proceed with + ingestion without filling them out. + + + {{ isInstrumentMetadataOk ? 'check_circle_outline' : + 'error_outline'}} + {{ isCardContentVisible.instrument ? 'expand_less' : + 'expand_more' }} +
+ + + +
+ + +
+ category-search +
+ Acquisition Information + + Fields in Red Are Required By The Schema. You can proceed with + ingestion without filling them out. + + + {{ isAcquisitionMetadataOk ? 'check_circle_outline' : + 'error_outline'}} + {{ isCardContentVisible.acquisition ? 'expand_less' : + 'expand_more' }} +
+ + + +
+
+
+
+
+ + + + diff --git a/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.new-transfer-dialog-page.component.spec.ts b/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.new-transfer-dialog-page.component.spec.ts new file mode 100644 index 0000000000..18c15a7de8 --- /dev/null +++ b/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.new-transfer-dialog-page.component.spec.ts @@ -0,0 +1,45 @@ +import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing"; +import { IngestorNewTransferDialogPageComponent } from "./ingestor.new-transfer-dialog-page.component"; +import { MatDialog, MatDialogModule } from "@angular/material/dialog"; +import { StoreModule } from "@ngrx/store"; +import { provideMockStore } from "@ngrx/store/testing"; +import { NO_ERRORS_SCHEMA } from "@angular/core"; + +describe("IngestorNewTransferDialogPageComponent", () => { + let component: IngestorNewTransferDialogPageComponent; + let fixture: ComponentFixture; + + const mockDialog = { + open: jasmine.createSpy("open"), + }; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + declarations: [IngestorNewTransferDialogPageComponent], + imports: [MatDialogModule, StoreModule.forRoot({})], + providers: [ + provideMockStore(), + { provide: MatDialog, useValue: mockDialog }, + ], + }); + + TestBed.compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(IngestorNewTransferDialogPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + if (fixture) { + fixture.destroy(); + } + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.new-transfer-dialog-page.component.ts b/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.new-transfer-dialog-page.component.ts new file mode 100644 index 0000000000..2baa627262 --- /dev/null +++ b/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.new-transfer-dialog-page.component.ts @@ -0,0 +1,386 @@ +import { + ChangeDetectorRef, + Component, + EventEmitter, + inject, + OnDestroy, + OnInit, + Output, +} from "@angular/core"; +import { MatDialog } from "@angular/material/dialog"; +import { JsonSchema } from "@jsonforms/core"; +import { + decodeBase64ToUTF8, + getJsonSchemaFromDto, + ExtractionMethod, + IngestionRequestInformation, + IngestorHelper, + IngestorAutodiscovery, +} from "../../../ingestor-page/helper/ingestor.component-helper"; +import { convertJSONFormsErrorToString } from "ingestor/ingestor-metadata-editor/ingestor-metadata-editor-helper"; +import { IngestorMetadataEditorHelper } from "ingestor/ingestor-metadata-editor/ingestor-metadata-editor-helper"; +import { renderView } from "ingestor/ingestor-metadata-editor/ingestor-metadata-editor.component"; +import { GetExtractorResponse } from "shared/sdk/models/ingestor/models"; +import { PageChangeEvent } from "shared/modules/table/table.component"; +import { IngestorFileBrowserComponent } from "ingestor/ingestor-dialogs/ingestor-file-browser/ingestor.file-browser.component"; +import { Store } from "@ngrx/store"; +import { + selectIngestorEndpoint, + selectIngestionObject, + selectIngestorExtractionMethods, + selectIngestorRenderView, + selectUpdateEditorFromThirdParty, +} from "state-management/selectors/ingestor.selectors"; +import * as fromActions from "state-management/actions/ingestor.actions"; +import { selectUserSettingsPageViewModel } from "state-management/selectors/user.selectors"; +import { Subscription } from "rxjs"; +import { ReturnedUserDto } from "@scicatproject/scicat-sdk-ts-angular"; + +@Component({ + selector: "ingestor-new-transfer-dialog-page", + templateUrl: "ingestor.new-transfer-dialog-page.html", + styleUrls: ["../../../ingestor-page/ingestor.component.scss"], + standalone: false, +}) +export class IngestorNewTransferDialogPageComponent + implements OnInit, OnDestroy +{ + private subscriptions: Subscription[] = []; + readonly dialog = inject(MatDialog); + + facilityBackend$ = this.store.select(selectIngestorEndpoint); + ingestionObject$ = this.store.select(selectIngestionObject); + vm$ = this.store.select(selectUserSettingsPageViewModel); + ingestorExtractionMethods$ = this.store.select( + selectIngestorExtractionMethods, + ); + + @Output() nextStep = new EventEmitter(); + + createNewTransferData: IngestionRequestInformation = + IngestorHelper.createEmptyRequestInformation(); + + facilityInfo: IngestorAutodiscovery | null = null; + extractionMethods: GetExtractorResponse = null; + dropdownPageSize = 50; + extractionMethodsPage = 0; + + extractionMethodsError = ""; + extractionMethodsInitialized = false; + + userProfile: ReturnedUserDto | null = null; + uiNextButtonReady = false; + + renderView$ = this.store.select(selectIngestorRenderView); + selectUpdateEditorFromThirdParty$ = this.store.select( + selectUpdateEditorFromThirdParty, + ); + scicatHeaderSchema: JsonSchema; + activeRenderView: renderView | null = null; + updateEditorFromThirdParty = false; + + isSciCatHeaderOk = false; + scicatHeaderErrors = ""; + + isCardContentVisible = { + scicat: true, + }; + + constructor( + private store: Store, + private cdr: ChangeDetectorRef, + ) {} + + ngOnInit() { + // Fetch the API token that the ingestor can authenticate to scicat as the user + this.subscriptions.push( + this.vm$.subscribe((settings) => { + this.userProfile = settings.user; + }), + ); + + this.subscriptions.push( + this.ingestionObject$.subscribe((ingestionObject) => { + if (ingestionObject) { + this.createNewTransferData = ingestionObject; + this.validateNextButton(); + + if (!this.extractionMethodsInitialized) { + this.extractionMethodsInitialized = true; + + if ( + // Only load extraction methods if the editor mode is set to INGESTION or EDITOR + this.createNewTransferData.editorMode === "INGESTION" || + this.createNewTransferData.editorMode === "EDITOR" + ) { + this.loadExtractionMethods(); + + this.subscriptions.push( + this.ingestorExtractionMethods$.subscribe( + (extractionMethods) => { + if (extractionMethods) { + this.extractionMethods = extractionMethods; + this.extractionMethodsError = ""; + } else { + this.extractionMethodsError = + "No extraction methods available. Please check your connection."; + } + }, + ), + ); + this.subscriptions.push( + this.facilityBackend$.subscribe((ingestorEndpoint) => { + if (ingestorEndpoint) { + console.log("Ingestor endpoint changed:", ingestorEndpoint); + this.facilityInfo = ingestorEndpoint; + } + }), + ); + } else { + this.scicatHeaderSchema = getJsonSchemaFromDto(true); + } + } + } + }), + ); + if (this.createNewTransferData.editorMode === "CREATION") { + this.subscriptions.push( + this.renderView$.subscribe((renderView) => { + if (renderView) { + // Check if renderView changed + if (this.activeRenderView !== renderView) { + this.activeRenderView = renderView; + } + } + }), + ); + + this.subscriptions.push( + this.selectUpdateEditorFromThirdParty$.subscribe((updateEditor) => { + // We need to rerender the editor if the user has changed the metadata in the third party + // So we get a flag, if it is true we unrender the editor + // and then we set it to false to render it again + this.updateEditorFromThirdParty = updateEditor; + if (updateEditor) { + this.cdr.detectChanges(); // Force the change detection to unrender the editor + this.store.dispatch( + fromActions.resetIngestionObjectFromThirdPartyFlag(), + ); + } + }), + ); + } + } + + ngOnDestroy(): void { + this.subscriptions.forEach((subscription) => { + subscription.unsubscribe(); + }); + } + + set selectedMethod(value: ExtractionMethod) { + this.createNewTransferData.selectedMethod = value; + this.validateNextButton(); + } + + get selectedMethod(): ExtractionMethod { + return this.createNewTransferData.selectedMethod; + } + + async loadExtractionMethods(): Promise { + this.store.dispatch( + fromActions.getExtractionMethods({ + page: this.extractionMethodsPage + 1, // 1-based + pageNumber: this.dropdownPageSize, + }), + ); + } + + generateExampleDataForSciCatHeader(): void { + if (this.createNewTransferData.editorMode === "INGESTION") { + this.createNewTransferData.scicatHeader["sourceFolder"] = + this.createNewTransferData.selectedPath; + + const pathParts = this.createNewTransferData.selectedPath.split(/[/\\]/); + const nameWithoutPath = + pathParts[pathParts.length - 1] || + this.createNewTransferData.selectedPath; + + this.createNewTransferData.scicatHeader["datasetName"] = nameWithoutPath; + } + + if ( + this.createNewTransferData.editorMode === "INGESTION" || + this.createNewTransferData.editorMode === "EDITOR" + ) { + this.createNewTransferData.scicatHeader["keywords"] = ["OpenEM"]; + } + + this.createNewTransferData.scicatHeader["license"] = "MIT License"; + this.createNewTransferData.scicatHeader["type"] = "raw"; + this.createNewTransferData.scicatHeader["dataFormat"] = "root"; + this.createNewTransferData.scicatHeader["owner"] = "User"; + + this.createNewTransferData.scicatHeader["principalInvestigator"] = + this.userProfile.username; + this.createNewTransferData.scicatHeader["ownerEmail"] = + this.userProfile.email; + this.createNewTransferData.scicatHeader["contactEmail"] = + this.userProfile.email; + + const creationTime = new Date(); + const formattedCreationTime = creationTime.toISOString(); + this.createNewTransferData.scicatHeader["creationTime"] = + formattedCreationTime; + } + + prepareSchemaForProcessing(): void { + if ( + this.createNewTransferData.editorMode === "INGESTION" || + this.createNewTransferData.editorMode === "EDITOR" + ) { + const encodedSchema = this.createNewTransferData.selectedMethod.schema; + const decodedSchema = decodeBase64ToUTF8(encodedSchema); + const schema = JSON.parse(decodedSchema); + const resolvedSchema = IngestorMetadataEditorHelper.resolveRefs( + schema, + schema, + ); + this.createNewTransferData.selectedResolvedDecodedSchema = resolvedSchema; + } else { + if (this.createNewTransferData.selectedSchemaFileContent) { + const schema = JSON.parse( + this.createNewTransferData.selectedSchemaFileContent, + ); + const resolvedSchema = IngestorMetadataEditorHelper.resolveRefs( + schema, + schema, + ); + this.createNewTransferData.selectedResolvedDecodedSchema = + resolvedSchema; + } + } + } + + onClickRetryRequests(): void { + this.loadExtractionMethods(); + } + + onClickNext(): void { + this.store.dispatch( + fromActions.updateIngestionObject({ + ingestionObject: this.createNewTransferData, + }), + ); + this.generateExampleDataForSciCatHeader(); + this.prepareSchemaForProcessing(); + + // Emit once to go to next step + this.nextStep.emit(); + } + + onDataChangeUserScicatHeader(event: any) { + this.createNewTransferData.scicatHeader = event; + } + + toggleCardContent(card: string): void { + this.isCardContentVisible[card] = !this.isCardContentVisible[card]; + } + + scicatHeaderErrorsHandler(errors: any[]) { + this.isSciCatHeaderOk = errors.length === 0; + this.scicatHeaderErrors = convertJSONFormsErrorToString(errors); + this.validateNextButton(); + this.cdr.detectChanges(); + } + + validateNextButton(): void { + const selectedPathReady = + this.createNewTransferData.editorMode === "INGESTION" && + this.createNewTransferData.selectedPath !== ""; + + const selectedMethodReady = + this.selectedMethod !== null && + this.selectedMethod !== undefined && + this.selectedMethod.name !== ""; + + if ( + this.createNewTransferData.editorMode === "INGESTION" || + this.createNewTransferData.editorMode === "EDITOR" + ) { + this.uiNextButtonReady = !!selectedPathReady && !!selectedMethodReady; + } else if (this.createNewTransferData.editorMode === "CREATION") { + // user can proceed without the schema + this.uiNextButtonReady = this.isSciCatHeaderOk; + } else { + this.uiNextButtonReady = false; + } + } + + onExtractorMethodsPageChange(event: PageChangeEvent) { + this.extractionMethodsPage = event.pageIndex; // 0-based + this.loadExtractionMethods(); + } + + onClickOpenFileBrowser(): void { + const dialogRef = this.dialog.open(IngestorFileBrowserComponent, { + data: {}, + }); + + const dialogSub = dialogRef.afterClosed().subscribe(() => { + this.validateNextButton(); + dialogSub.unsubscribe(); + }); + } + + onSchemaUrlChange(url: string): void { + this.createNewTransferData.schemaUrl = url; + this.store.dispatch( + fromActions.updateIngestionObject({ + ingestionObject: this.createNewTransferData, + }), + ); + } + + async onUploadSchema(): Promise { + if (!this.createNewTransferData.schemaUrl) { + alert("Please enter a schema URL."); + return; + } + console.log( + "Fetching schema from URL:", + this.createNewTransferData.schemaUrl, + ); + let content: string; + let parsedJson: any; + + try { + const response = await fetch(this.createNewTransferData.schemaUrl); + if (!response.ok) { + throw new Error(`Failed to fetch schema: ${response.statusText}`); + } + content = await response.text(); + try { + parsedJson = JSON.parse(content); + } catch (e) { + alert("The provided URL does not contain valid JSON."); + return; + } + } catch (error: any) { + alert(`Error fetching schema: ${error.message}`); + return; + } + + this.createNewTransferData.selectedSchemaFileContent = content; + + // Update the store with both the URL and content + this.createNewTransferData.selectedSchemaFileContent = content; + this.store.dispatch( + fromActions.updateIngestionObject({ + ingestionObject: this.createNewTransferData, + }), + ); + + this.validateNextButton(); + } +} diff --git a/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.new-transfer-dialog-page.html b/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.new-transfer-dialog-page.html new file mode 100644 index 0000000000..e66ca2cdb4 --- /dev/null +++ b/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.new-transfer-dialog-page.html @@ -0,0 +1,191 @@ +
+
+

+ Fill out Dataset information +

+ +

+ Select your ingestion method +

+ +
+ +
+ +
+
+ + +
+ + +
+ info +
+ SciCat Information + + {{ isSciCatHeaderOk ? 'check_circle_outline' : + 'error_outline'}} + {{ isCardContentVisible.scicat ? 'expand_less' : 'expand_more' + }} +
+ + + +
+ + + + Scientific Metadata Schema + + +
+ + + + + +

+ Loaded schema is valid! +

+
+
+
+
+ +
+ + + Select Options + + +

+ Please select the dataset to be uploaded and the appropriate metadata + extractor method. +

+ +
+ + File Path + + + + +
+ +
+ + Extraction Method + Editor schema + + + {{ method.name }} + + + + {{ extractionMethodsError }} + + + +
+
+
+
+
+ + + + + diff --git a/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.no-rights-dialog-page.component.spec.ts b/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.no-rights-dialog-page.component.spec.ts new file mode 100644 index 0000000000..efd4617e8a --- /dev/null +++ b/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.no-rights-dialog-page.component.spec.ts @@ -0,0 +1,45 @@ +import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing"; +import { IngestorNoRightsDialogPageComponent } from "./ingestor.no-rights-dialog-page.component"; +import { MatDialog, MatDialogModule } from "@angular/material/dialog"; +import { StoreModule } from "@ngrx/store"; +import { provideMockStore } from "@ngrx/store/testing"; +import { NO_ERRORS_SCHEMA } from "@angular/core"; + +describe("IngestorNoRightsDialogPageComponent", () => { + let component: IngestorNoRightsDialogPageComponent; + let fixture: ComponentFixture; + + const mockDialog = { + open: jasmine.createSpy("open"), + }; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + declarations: [IngestorNoRightsDialogPageComponent], + imports: [MatDialogModule, StoreModule.forRoot({})], + providers: [ + provideMockStore(), + { provide: MatDialog, useValue: mockDialog }, + ], + }); + + TestBed.compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(IngestorNoRightsDialogPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + if (fixture) { + fixture.destroy(); + } + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.no-rights-dialog-page.component.ts b/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.no-rights-dialog-page.component.ts new file mode 100644 index 0000000000..e6d4548588 --- /dev/null +++ b/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.no-rights-dialog-page.component.ts @@ -0,0 +1,74 @@ +import { + Component, + EventEmitter, + inject, + OnDestroy, + OnInit, + Output, +} from "@angular/core"; +import { MatDialog } from "@angular/material/dialog"; +import { + APIInformation, + IngestionRequestInformation, + IngestorHelper, +} from "../../../ingestor-page/helper/ingestor.component-helper"; +import { Store } from "@ngrx/store"; +import { + selectIngestionObjectAPIInformation, + selectIngestionObject, +} from "state-management/selectors/ingestor.selectors"; +import { Subscription } from "rxjs"; + +@Component({ + selector: "ingestor-no-rights-dialog-page", + templateUrl: "ingestor.no-rights-dialog-page.html", + styleUrls: ["../../../ingestor-page/ingestor.component.scss"], + standalone: false, +}) +export class IngestorNoRightsDialogPageComponent implements OnInit, OnDestroy { + private subscriptions: Subscription[] = []; + readonly dialog = inject(MatDialog); + + ingestionObject$ = this.store.select(selectIngestionObject); + + @Output() nextStep = new EventEmitter(); + + createNewTransferData: IngestionRequestInformation = + IngestorHelper.createEmptyRequestInformation(); + createNewTransferDataApiInformation: APIInformation = + IngestorHelper.createEmptyAPIInformation(); + + ingestionObjectApiInformation$ = this.store.select( + selectIngestionObjectAPIInformation, + ); + + constructor(private store: Store) {} + + ngOnInit() { + this.subscriptions.push( + this.ingestionObject$.subscribe((ingestionObject) => { + if (ingestionObject) { + this.createNewTransferData = ingestionObject; + } + }), + ); + + this.subscriptions.push( + this.ingestionObjectApiInformation$.subscribe((apiInformation) => { + if (apiInformation) { + this.createNewTransferDataApiInformation = apiInformation; + } + }), + ); + } + + ngOnDestroy(): void { + this.subscriptions.forEach((subscription) => { + subscription.unsubscribe(); + }); + } + + onClickNext(): void { + this.nextStep.emit(); // Open next dialog + } +} diff --git a/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.no-rights-dialog-page.html b/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.no-rights-dialog-page.html new file mode 100644 index 0000000000..4ab96e8915 --- /dev/null +++ b/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.no-rights-dialog-page.html @@ -0,0 +1,45 @@ +
+
+

Lost Connection

+ +
+ +
+ +
+
+ + +

+ + + Action forbidden + + +

+ lock +

You do not have permission to continue.

+

Please check your user or login status.

+ If you have lost the connection, use the save button at the top + right to save the data you have entered. +

+ {{createNewTransferDataApiInformation.ingestionRequestErrorMessage}} +

+
+ + +

+
+ + + + diff --git a/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.user-metadata-dialog-page.component.spec.ts b/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.user-metadata-dialog-page.component.spec.ts new file mode 100644 index 0000000000..1908e4c198 --- /dev/null +++ b/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.user-metadata-dialog-page.component.spec.ts @@ -0,0 +1,37 @@ +import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing"; +import { IngestorUserMetadataDialogPageComponent } from "./ingestor.user-metadata-dialog-page.component"; +import { StoreModule } from "@ngrx/store"; +import { provideMockStore } from "@ngrx/store/testing"; +import { NO_ERRORS_SCHEMA } from "@angular/core"; + +describe("IngestorUserMetadataDialogPageComponent", () => { + let component: IngestorUserMetadataDialogPageComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + declarations: [IngestorUserMetadataDialogPageComponent], + imports: [StoreModule.forRoot({})], + providers: [provideMockStore()], + }); + + TestBed.compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(IngestorUserMetadataDialogPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + if (fixture) { + fixture.destroy(); + } + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.user-metadata-dialog-page.component.ts b/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.user-metadata-dialog-page.component.ts new file mode 100644 index 0000000000..60e553bb4c --- /dev/null +++ b/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.user-metadata-dialog-page.component.ts @@ -0,0 +1,197 @@ +import { + ChangeDetectorRef, + Component, + EventEmitter, + OnDestroy, + OnInit, + Output, +} from "@angular/core"; +import { JsonSchema } from "@jsonforms/core"; +import { + getJsonSchemaFromDto, + IngestionRequestInformation, + IngestorHelper, +} from "../../../ingestor-page/helper/ingestor.component-helper"; +import { IngestorMetadataEditorHelper } from "ingestor/ingestor-metadata-editor/ingestor-metadata-editor-helper"; +import { + selectIngestionObject, + selectIngestorRenderView, + selectUpdateEditorFromThirdParty, +} from "state-management/selectors/ingestor.selectors"; +import * as fromActions from "state-management/actions/ingestor.actions"; +import { Store } from "@ngrx/store"; +import { Subscription } from "rxjs"; +import { renderView } from "ingestor/ingestor-metadata-editor/ingestor-metadata-editor.component"; +import { convertJSONFormsErrorToString } from "ingestor/ingestor-metadata-editor/ingestor-metadata-editor-helper"; + +@Component({ + selector: "ingestor-user-metadata-dialog", + templateUrl: "ingestor.user-metadata-dialog-page.html", + styleUrls: ["../../../ingestor-page/ingestor.component.scss"], + standalone: false, +}) +export class IngestorUserMetadataDialogPageComponent + implements OnInit, OnDestroy +{ + private subscriptions: Subscription[] = []; + ingestionObject$ = this.store.select(selectIngestionObject); + renderView$ = this.store.select(selectIngestorRenderView); + selectUpdateEditorFromThirdParty$ = this.store.select( + selectUpdateEditorFromThirdParty, + ); + + createNewTransferData: IngestionRequestInformation = + IngestorHelper.createEmptyRequestInformation(); + + @Output() nextStep = new EventEmitter(); + @Output() backStep = new EventEmitter(); + + metadataSchemaOrganizational: JsonSchema; + metadataSchemaSample: JsonSchema; + scicatHeaderSchema: JsonSchema; + activeRenderView: renderView | null = null; + updateEditorFromThirdParty = false; + + uiNextButtonReady = false; + isSciCatHeaderOk = false; + scicatHeaderErrors = ""; + isOrganizationalMetadataOk = false; + organizationalErrors = ""; + isSampleInformationOk = false; + sampleErrors = ""; + + isCardContentVisible = { + scicat: true, + organizational: true, + sample: true, + }; + + constructor( + private store: Store, + private cdr: ChangeDetectorRef, + ) {} + + ngOnInit() { + this.subscriptions.push( + this.ingestionObject$.subscribe((ingestionObject) => { + if (ingestionObject) { + this.createNewTransferData = ingestionObject; + + this.metadataSchemaOrganizational = + this.createNewTransferData.selectedResolvedDecodedSchema.properties.organizational; + this.metadataSchemaSample = + this.createNewTransferData.selectedResolvedDecodedSchema.properties.sample; + this.scicatHeaderSchema = getJsonSchemaFromDto(); + } + }), + ); + + this.subscriptions.push( + this.renderView$.subscribe((renderView) => { + if (renderView) { + // Check if renderView changed + if (this.activeRenderView !== renderView) { + this.activeRenderView = renderView; + } + } + }), + ); + + this.subscriptions.push( + this.selectUpdateEditorFromThirdParty$.subscribe((updateEditor) => { + // We need to rerender the editor if the user has changed the metadata in the third party + // So we get a flag, if it is true we unrender the editor + // and then we set it to false to render it again + this.updateEditorFromThirdParty = updateEditor; + if (updateEditor) { + this.cdr.detectChanges(); // Force the change detection to unrender the editor + this.store.dispatch( + fromActions.resetIngestionObjectFromThirdPartyFlag(), + ); + } + }), + ); + } + + ngOnDestroy(): void { + this.subscriptions.forEach((subscription) => subscription.unsubscribe()); + } + + onClickBack(): void { + this.store.dispatch( + fromActions.updateIngestionObject({ + ingestionObject: this.createNewTransferData, + }), + ); + + this.backStep.emit(); // Open previous dialog + } + + onClickNext(): void { + this.store.dispatch( + fromActions.updateIngestionObject({ + ingestionObject: this.createNewTransferData, + }), + ); + + this.nextStep.emit(); // Open next dialog + } + + onDataChangeUserMetadataOrganization(event: any) { + this.createNewTransferData.userMetaData["organizational"] = event; + } + + onDataChangeUserMetadataSample(event: any) { + this.createNewTransferData.userMetaData["sample"] = event; + } + + onDataChangeUserScicatHeader(event: any) { + this.createNewTransferData.scicatHeader = event; + } + + onCreateNewTransferDataChange(updatedData: IngestionRequestInformation) { + Object.assign(this.createNewTransferData, updatedData); + } + + toggleCardContent(card: string): void { + this.isCardContentVisible[card] = !this.isCardContentVisible[card]; + } + + scicatHeaderErrorsHandler(errors: any[]) { + this.isSciCatHeaderOk = errors.length === 0; + this.scicatHeaderErrors = convertJSONFormsErrorToString(errors); + + this.validateNextButton(); + this.cdr.detectChanges(); + } + + organizationalErrorsHandler(errors: any[]) { + const result = IngestorMetadataEditorHelper.processMetadataErrors( + errors, + this.metadataSchemaOrganizational, + this.activeRenderView, + ); + + this.isOrganizationalMetadataOk = result.isValid; + this.organizationalErrors = result.errorString; + this.validateNextButton(); + this.cdr.detectChanges(); + } + + sampleErrorsHandler(errors: any[]) { + const result = IngestorMetadataEditorHelper.processMetadataErrors( + errors, + this.metadataSchemaSample, + this.activeRenderView, + ); + + this.isSampleInformationOk = result.isValid; + this.sampleErrors = result.errorString; + this.validateNextButton(); + this.cdr.detectChanges(); + } + + validateNextButton(): void { + this.uiNextButtonReady = this.isSciCatHeaderOk; + } +} diff --git a/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.user-metadata-dialog-page.html b/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.user-metadata-dialog-page.html new file mode 100644 index 0000000000..2795ba4901 --- /dev/null +++ b/src/app/ingestor/ingestor-dialogs/creation-dialog/creation-pages/ingestor.user-metadata-dialog-page.html @@ -0,0 +1,140 @@ +
+
+

Fill out user-specific metadata

+ +
+ +
+ +
+
+ + + +
+ info +
+ SciCat Information + + {{ isSciCatHeaderOk ? 'check_circle_outline' : + 'error_outline'}} + {{ isCardContentVisible.scicat ? 'expand_less' : 'expand_more' + }} +
+ + + +
+ + + +
+ person +
+ Organizational Information + + Fields in Red Are Required By The Schema. You can proceed with ingestion + without filling them out. + + + {{ isOrganizationalMetadataOk ? 'check_circle_outline' : + 'error_outline'}} + {{ isCardContentVisible.organizational ? 'expand_less' : 'expand_more' + }} +
+ + + +
+ + + +
+ description +
+ Sample Information + + Fields in Red Are Required By The Schema. You can proceed with ingestion + without filling them out. + + + {{ isSampleInformationOk ? 'check_circle_outline' : + 'error_outline'}} + {{ isCardContentVisible.sample ? 'expand_less' : 'expand_more' + }} +
+ + + +
+
+ + + + diff --git a/src/app/ingestor/ingestor-dialogs/creation-dialog/ingestor.creation-dialog-base.component.spec.ts b/src/app/ingestor/ingestor-dialogs/creation-dialog/ingestor.creation-dialog-base.component.spec.ts new file mode 100644 index 0000000000..f2367493c6 --- /dev/null +++ b/src/app/ingestor/ingestor-dialogs/creation-dialog/ingestor.creation-dialog-base.component.spec.ts @@ -0,0 +1,63 @@ +import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing"; +import { IngestorCreationDialogBaseComponent } from "./ingestor.creation-dialog-base.component"; +import { + MatDialog, + MAT_DIALOG_DATA, + MatDialogModule, +} from "@angular/material/dialog"; +import { StoreModule } from "@ngrx/store"; +import { provideMockStore } from "@ngrx/store/testing"; +import { IngestorMetadataSSEService } from "ingestor/ingestor-page/helper/ingestor.metadata-sse-service"; +import { NO_ERRORS_SCHEMA } from "@angular/core"; +import { of } from "rxjs"; + +describe("IngestorCreationDialogBaseComponent", () => { + let component: IngestorCreationDialogBaseComponent; + let fixture: ComponentFixture; + + const mockDialog = { + open: jasmine.createSpy("open"), + }; + + const mockSSEService = { + connect: jasmine.createSpy("connect"), + disconnect: jasmine.createSpy("disconnect"), + getMessages: jasmine.createSpy("getMessages").and.returnValue(of({})), + }; + + const mockDialogData = { + someData: "someValue", + }; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + declarations: [IngestorCreationDialogBaseComponent], + imports: [MatDialogModule, StoreModule.forRoot({})], + providers: [ + provideMockStore(), + { provide: MatDialog, useValue: mockDialog }, + { provide: IngestorMetadataSSEService, useValue: mockSSEService }, + { provide: MAT_DIALOG_DATA, useValue: mockDialogData }, + ], + }); + + TestBed.compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(IngestorCreationDialogBaseComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + if (fixture) { + fixture.destroy(); + } + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/ingestor/ingestor-dialogs/creation-dialog/ingestor.creation-dialog-base.component.ts b/src/app/ingestor/ingestor-dialogs/creation-dialog/ingestor.creation-dialog-base.component.ts new file mode 100644 index 0000000000..911abd5350 --- /dev/null +++ b/src/app/ingestor/ingestor-dialogs/creation-dialog/ingestor.creation-dialog-base.component.ts @@ -0,0 +1,290 @@ +import { Component, Inject, OnDestroy, OnInit } from "@angular/core"; +import { MatDialog, MAT_DIALOG_DATA } from "@angular/material/dialog"; +import { + APIInformation, + DialogDataObject, + IngestionRequestInformation, + IngestorHelper, + ScientificMetadata, +} from "../../ingestor-page/helper/ingestor.component-helper"; +import { + selectIngestionObjectAPIInformation, + selectIngestionObject, + selectIngestorEndpoint, + selectNoRightsError, +} from "state-management/selectors/ingestor.selectors"; +import { Store } from "@ngrx/store"; +import { IngestorMetadataSSEService } from "ingestor/ingestor-page/helper/ingestor.metadata-sse-service"; +import { HttpParams } from "@angular/common/http"; +import { PostDatasetRequest } from "shared/sdk/models/ingestor/models"; +import * as fromActions from "state-management/actions/ingestor.actions"; +import * as datasetActions from "state-management/actions/datasets.actions"; +import { selectUserSettingsPageViewModel } from "state-management/selectors/user.selectors"; +import { fetchScicatTokenAction } from "state-management/actions/user.actions"; +import { INGESTOR_API_ENDPOINTS_V1 } from "shared/sdk/apis/ingestor.service"; +import { Subscription } from "rxjs"; + +export type dialogStep = + | "NEW_TRANSFER" + | "USER_METADATA" + | "EXTRACTOR_METADATA" + | "CONFIRM_TRANSFER" + | "CUSTOM_METADATA" // Only in Creation Editor Mode + | "SCICAT_METADATA"; // Only in Creation Editor Mode + +@Component({ + selector: "ingestor.creation-dialog-base", + templateUrl: "ingestor.creation-dialog-base.html", + styleUrls: ["../../ingestor-page/ingestor.component.scss"], + standalone: false, +}) +export class IngestorCreationDialogBaseComponent implements OnInit, OnDestroy { + private subscriptions: Subscription[] = []; + vm$ = this.store.select(selectUserSettingsPageViewModel); + + createNewTransferData: IngestionRequestInformation = + IngestorHelper.createEmptyRequestInformation(); + createNewTransferDataApiInformation: APIInformation = + IngestorHelper.createEmptyAPIInformation(); + + ingestionObject$ = this.store.select(selectIngestionObject); + ingestionObjectApiInformation$ = this.store.select( + selectIngestionObjectAPIInformation, + ); + ingestorBackend$ = this.store.select(selectIngestorEndpoint); + selectNoRightsError$ = this.store.select(selectNoRightsError); + + showNoRightsDialog = false; + currentDialogStep: dialogStep = "NEW_TRANSFER"; + connectedFacilityBackend = ""; + tokenValue = ""; + + constructor( + public dialog: MatDialog, + private store: Store, + private sseService: IngestorMetadataSSEService, + @Inject(MAT_DIALOG_DATA) public data: DialogDataObject, + ) {} + + ngOnInit() { + this.subscriptions.push( + this.ingestionObject$.subscribe((ingestionObject) => { + if (ingestionObject) { + this.createNewTransferData = ingestionObject; + } + }), + ); + + this.subscriptions.push( + this.ingestionObjectApiInformation$.subscribe((apiInformation) => { + if (apiInformation) { + this.createNewTransferDataApiInformation = apiInformation; + } + }), + ); + + this.subscriptions.push( + this.ingestorBackend$.subscribe((ingestorBackend) => { + if (ingestorBackend) { + this.connectedFacilityBackend = ingestorBackend.facilityBackend; + } + }), + ); + + // Fetch the API token that the ingestor can authenticate to scicat as the user + this.subscriptions.push( + this.vm$.subscribe((settings) => { + this.tokenValue = settings.scicatToken; + + if (this.tokenValue === "") { + this.store.dispatch(fetchScicatTokenAction()); + } + }), + ); + + this.subscriptions.push( + this.selectNoRightsError$.subscribe((selectNoRightsError) => { + this.showNoRightsDialog = selectNoRightsError; + }), + ); + } + + ngOnDestroy(): void { + this.subscriptions.forEach((subscription) => { + subscription.unsubscribe(); + }); + } + + resetScientificMetadata(): void { + this.createNewTransferData.extractorMetaData = { + instrument: {}, + acquisition: {}, + }; + + this.createNewTransferData.userMetaData = { + organizational: {}, + sample: {}, + }; + + this.createNewTransferData.selectedMethod = { name: "", schema: "" }; + + this.store.dispatch( + fromActions.updateIngestionObject({ + ingestionObject: this.createNewTransferData, + }), + ); + } + + async startMetadataExtraction(): Promise { + this.createNewTransferDataApiInformation.metaDataExtractionFailed = false; + + if (this.createNewTransferDataApiInformation.extractMetaDataRequested) { + return false; + } + + this.createNewTransferDataApiInformation.extractorMetaDataReady = false; + this.createNewTransferDataApiInformation.extractMetaDataRequested = true; + + this.store.dispatch( + fromActions.updateIngestionObjectAPIInformation({ + ingestionObjectApiInformation: this.createNewTransferDataApiInformation, + }), + ); + + const params = new HttpParams() + .set("filePath", this.createNewTransferData.selectedPath) + .set("methodName", this.createNewTransferData.selectedMethod.name); + + const sseUrl = `${this.connectedFacilityBackend + "/" + INGESTOR_API_ENDPOINTS_V1.METADATA}?${params.toString()}`; + this.sseService.connect(sseUrl); + this.subscriptions.push( + this.sseService.getMessages().subscribe({ + next: (data) => { + //console.log("Received SSE data:", data); + this.createNewTransferDataApiInformation.extractorMetaDataStatus = + data.message; + this.createNewTransferDataApiInformation.extractorMetadataProgress = + data.progress; + + if (data.result) { + this.createNewTransferDataApiInformation.extractorMetaDataReady = true; + const extractedScientificMetadata = JSON.parse( + data.resultMessage, + ) as ScientificMetadata; + + this.createNewTransferData.extractorMetaData.instrument = + extractedScientificMetadata.instrument ?? {}; + this.createNewTransferData.extractorMetaData.acquisition = + extractedScientificMetadata.acquisition ?? {}; + + this.store.dispatch( + fromActions.updateIngestionObject({ + ingestionObject: this.createNewTransferData, + }), + ); + } else if (data.error) { + this.createNewTransferDataApiInformation.metaDataExtractionFailed = true; + this.createNewTransferDataApiInformation.extractMetaDataRequested = false; + this.createNewTransferDataApiInformation.extractorMetaDataStatus = + data.message; + this.createNewTransferDataApiInformation.extractorMetadataProgress = + data.progress; + } + + this.store.dispatch( + fromActions.updateIngestionObjectAPIInformation({ + ingestionObjectApiInformation: + this.createNewTransferDataApiInformation, + }), + ); + }, + error: (error) => { + console.error("Error receiving SSE data:", error); + this.createNewTransferDataApiInformation.metaDataExtractionFailed = true; + this.createNewTransferDataApiInformation.extractMetaDataRequested = false; + + this.store.dispatch( + fromActions.updateIngestionObjectAPIInformation({ + ingestionObjectApiInformation: + this.createNewTransferDataApiInformation, + }), + ); + }, + }), + ); + + return true; + } + + onClickNext(nextStep: dialogStep): void { + switch (nextStep) { + case "NEW_TRANSFER": + this.resetScientificMetadata(); + + // Reset metadata extractor + this.sseService.disconnect(); + this.createNewTransferDataApiInformation.extractMetaDataRequested = false; + this.createNewTransferDataApiInformation.extractorMetaDataReady = false; + this.store.dispatch( + fromActions.updateIngestionObjectAPIInformation({ + ingestionObjectApiInformation: + this.createNewTransferDataApiInformation, + }), + ); + break; + case "USER_METADATA": + if (this.createNewTransferData.editorMode === "INGESTION") { + this.startMetadataExtraction().catch((error) => { + console.error("Metadata extraction error", error); + }); + } else if ( + this.createNewTransferData.editorMode === "EDITOR" || + this.createNewTransferData.editorMode === "CREATION" + ) { + this.createNewTransferDataApiInformation.extractorMetaDataReady = true; + this.store.dispatch( + fromActions.updateIngestionObjectAPIInformation({ + ingestionObjectApiInformation: + this.createNewTransferDataApiInformation, + }), + ); + } + + break; + case "EXTRACTOR_METADATA": + break; + case "CONFIRM_TRANSFER": + break; + case "SCICAT_METADATA": + break; + case "CUSTOM_METADATA": + break; + default: + console.error("Unknown step", nextStep); + return; + } + this.currentDialogStep = nextStep; + } + + onClickStartIngestion(): void { + if (this.createNewTransferData.editorMode === "CREATION") { + this.store.dispatch( + datasetActions.addDatasetAction({ + dataset: JSON.parse(this.createNewTransferData.mergedMetaDataString), + }), + ); + } else if (this.createNewTransferData.editorMode === "INGESTION") { + const payload: PostDatasetRequest = { + metaData: this.createNewTransferData.mergedMetaDataString, + userToken: this.tokenValue, + autoArchive: this.createNewTransferData.autoArchive, + }; + + this.store.dispatch( + fromActions.ingestDataset({ + ingestionDataset: payload, + }), + ); + } + } +} diff --git a/src/app/ingestor/ingestor-dialogs/creation-dialog/ingestor.creation-dialog-base.html b/src/app/ingestor/ingestor-dialogs/creation-dialog/ingestor.creation-dialog-base.html new file mode 100644 index 0000000000..a3c6c3c987 --- /dev/null +++ b/src/app/ingestor/ingestor-dialogs/creation-dialog/ingestor.creation-dialog-base.html @@ -0,0 +1,44 @@ + + + + + + + + + + diff --git a/src/app/ingestor/ingestor-dialogs/dialog-mounting-components/ingestor.dialog-stepper.component.css b/src/app/ingestor/ingestor-dialogs/dialog-mounting-components/ingestor.dialog-stepper.component.css new file mode 100644 index 0000000000..b0400a5743 --- /dev/null +++ b/src/app/ingestor/ingestor-dialogs/dialog-mounting-components/ingestor.dialog-stepper.component.css @@ -0,0 +1,39 @@ +.stepper { + display: flex; + flex-direction: column; + align-items: center; +} + +.ingestor-stepper { + max-width: 100%; +} + +.dialog-control-buttons { + display: flex; + justify-content: flex-end; + gap: 1em; +} + +.control-button-icon { + margin: auto; +} + +@media (max-width: 768px) { + .stepper { + flex-direction: column; + width: 100%; + } + + .stepper div { + width: 100%; + text-align: center; + } +} +.field-toggle { + margin-bottom: 24px; + display: block; + + mat-slide-toggle { + margin-bottom: 0; + } +} diff --git a/src/app/ingestor/ingestor-dialogs/dialog-mounting-components/ingestor.dialog-stepper.component.html b/src/app/ingestor/ingestor-dialogs/dialog-mounting-components/ingestor.dialog-stepper.component.html new file mode 100644 index 0000000000..de3336b4f8 --- /dev/null +++ b/src/app/ingestor/ingestor-dialogs/dialog-mounting-components/ingestor.dialog-stepper.component.html @@ -0,0 +1,186 @@ +
+ + + + +
+ + + + + + + +
+ + + Select your ingestion method + + + + Fill out user-specific metadata + + + + Correct dataset-specific metadata + + + + Confirm inputs + + +
+ +
+ + + Fill out Dataset information + + + + Fill out custom scientific metadata + + + + Confirm inputs + + +
+ + +
+ + + Fill out Dataset information + + + + Confirm inputs + + +
+ +
+ + {{ activeRenderView === "requiredOnly" ? "Required Only" : "All Fields" }} + +
diff --git a/src/app/ingestor/ingestor-dialogs/dialog-mounting-components/ingestor.dialog-stepper.component.spec.ts b/src/app/ingestor/ingestor-dialogs/dialog-mounting-components/ingestor.dialog-stepper.component.spec.ts new file mode 100644 index 0000000000..e26c385f3f --- /dev/null +++ b/src/app/ingestor/ingestor-dialogs/dialog-mounting-components/ingestor.dialog-stepper.component.spec.ts @@ -0,0 +1,46 @@ +import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing"; +import { IngestorDialogStepperComponent } from "./ingestor.dialog-stepper.component"; +import { MatDialog, MatDialogModule } from "@angular/material/dialog"; +import { MatMenuModule } from "@angular/material/menu"; +import { StoreModule } from "@ngrx/store"; +import { provideMockStore } from "@ngrx/store/testing"; +import { NO_ERRORS_SCHEMA } from "@angular/core"; + +describe("IngestorDialogStepperComponent", () => { + let component: IngestorDialogStepperComponent; + let fixture: ComponentFixture; + + const mockDialog = { + open: jasmine.createSpy("open"), + }; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + declarations: [IngestorDialogStepperComponent], + imports: [MatDialogModule, MatMenuModule, StoreModule.forRoot({})], + providers: [ + provideMockStore(), + { provide: MatDialog, useValue: mockDialog }, + ], + }); + + TestBed.compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(IngestorDialogStepperComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + if (fixture) { + fixture.destroy(); + } + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/ingestor/ingestor-dialogs/dialog-mounting-components/ingestor.dialog-stepper.component.ts b/src/app/ingestor/ingestor-dialogs/dialog-mounting-components/ingestor.dialog-stepper.component.ts new file mode 100644 index 0000000000..f5cbc134fe --- /dev/null +++ b/src/app/ingestor/ingestor-dialogs/dialog-mounting-components/ingestor.dialog-stepper.component.ts @@ -0,0 +1,276 @@ +import { Component, Input, Injector, OnInit, OnDestroy } from "@angular/core"; +import { MatDialog } from "@angular/material/dialog"; +import { + IngestionRequestInformation, + IngestorHelper, +} from "../../ingestor-page/helper/ingestor.component-helper"; +import { IngestorConfirmationDialogComponent } from "../confirmation-dialog/ingestor.confirmation-dialog.component"; +import { + ExportOptions, + ExportTemplateHelperComponent, +} from "./ingestor.export-helper.component"; +import { Store } from "@ngrx/store"; +import { + selectIngestionObject, + selectIngestorRenderView, +} from "state-management/selectors/ingestor.selectors"; +import { renderView } from "ingestor/ingestor-metadata-editor/ingestor-metadata-editor.component"; +import * as fromActions from "state-management/actions/ingestor.actions"; +import { Subscription } from "rxjs"; +import { CreateDatasetDto } from "@scicatproject/scicat-sdk-ts-angular"; + +@Component({ + selector: "ingestor-dialog-stepper", + templateUrl: "./ingestor.dialog-stepper.component.html", + styleUrls: ["./ingestor.dialog-stepper.component.css"], + standalone: false, +}) +export class IngestorDialogStepperComponent implements OnInit, OnDestroy { + private subscriptions: Subscription[] = []; + @Input() activeStep = 0; + + testMessageComponent = ExportTemplateHelperComponent; + + createNewTransferData: IngestionRequestInformation = + IngestorHelper.createEmptyRequestInformation(); + renderView$ = this.store.select(selectIngestorRenderView); + ingestionObject$ = this.store.select(selectIngestionObject); + activeRenderView: renderView | null = null; + + exportValueOptions: ExportOptions = { + exportSciCat: true, + exportOrganizational: false, + exportSample: false, + exportAll: false, + exportAsJSON: false, + }; + + injector = Injector.create({ + providers: [ + { + provide: "data", + useValue: this.exportValueOptions, + }, + ], + }); + + constructor( + private dialog: MatDialog, + private store: Store, + ) {} + + ngOnInit() { + this.subscriptions.push( + this.ingestionObject$.subscribe((ingestionObject) => { + if (ingestionObject) { + this.createNewTransferData = ingestionObject; + } + }), + ); + + this.subscriptions.push( + this.renderView$.subscribe((renderView) => { + if (renderView) { + this.activeRenderView = renderView; + } + }), + ); + } + + ngOnDestroy(): void { + this.subscriptions.forEach((subscription) => subscription.unsubscribe()); + } + + onChangeViewMode() { + if (this.activeRenderView) { + switch (this.activeRenderView) { + case "requiredOnly": + this.store.dispatch( + fromActions.setRenderViewFromThirdParty({ renderView: "all" }), + ); + break; + case "all": + this.store.dispatch( + fromActions.setRenderViewFromThirdParty({ + renderView: "requiredOnly", + }), + ); + break; + default: + console.error("Unknown mode"); + } + } + } + + updateIngestionObject(updatedObject: IngestionRequestInformation) { + this.store.dispatch( + fromActions.updateIngestionObjectFromThirdParty({ + ingestionObject: updatedObject, + }), + ); + } + + // Save a template of metadata + onSave() { + const dialogRef = this.dialog.open(IngestorConfirmationDialogComponent, { + data: { + header: "Confirm template", + message: "Do you really want to apply the following values?", + messageComponent: this.testMessageComponent, + injector: this.injector, + }, + }); + dialogRef.afterClosed().subscribe((result) => { + if (result) { + if (this.createNewTransferData) { + const sciCatHeaderWithoutSourcePath = { + ...this.createNewTransferData.scicatHeader, + } as CreateDatasetDto; + delete sciCatHeaderWithoutSourcePath.sourceFolder; + + const exportData = {}; + if (this.exportValueOptions.exportSciCat) { + exportData["scicatHeader"] = { ...sciCatHeaderWithoutSourcePath }; + } + + if ( + this.exportValueOptions.exportOrganizational || + this.exportValueOptions.exportSample + ) { + exportData["userMetaData"] = {}; + } + + if (this.exportValueOptions.exportAll) { + exportData["extractorMetaData"] = {}; + } + + if (this.exportValueOptions.exportOrganizational) { + exportData["userMetaData"]["organizational"] = { + ...this.createNewTransferData.userMetaData.organizational, + }; + } + + if (this.exportValueOptions.exportSample) { + exportData["userMetaData"]["sample"] = { + ...this.createNewTransferData.userMetaData.sample, + }; + } + + if (this.exportValueOptions.exportAll) { + exportData["extractorMetaData"]["instrument"] = { + ...this.createNewTransferData.extractorMetaData.instrument, + }; + exportData["extractorMetaData"]["acquisition"] = { + ...this.createNewTransferData.extractorMetaData.acquisition, + }; + } + + let exportString = ""; + if (this.exportValueOptions.exportAsJSON) { + exportString = IngestorHelper.createMetaDataString( + exportData as IngestionRequestInformation, + ); + } else { + exportString = JSON.stringify(exportData); + } + + // Default file name is the current date and method name + const currentDate = new Date(); + const formattedDate = currentDate + .toISOString() + .replace(/:/g, "-") + .replace(/\..+/, ""); + const methodName = + this.createNewTransferData.selectedMethod.name ?? "no_method"; + const ending = this.exportValueOptions.exportAsJSON + ? ".json" + : ".ingestor.template"; + + const fileName = formattedDate + "_" + methodName + ending; + + const dataStr = + "data:text/json;charset=utf-8," + encodeURIComponent(exportString); + const downloadAnchorNode = document.createElement("a"); + downloadAnchorNode.setAttribute("href", dataStr); + downloadAnchorNode.setAttribute("download", fileName); + document.body.appendChild(downloadAnchorNode); + downloadAnchorNode.click(); + downloadAnchorNode.remove(); + } + } + }); + } + + // Upload a template of metadata + onUpload() { + const input = document.createElement("input"); + input.type = "file"; + input.accept = ".ingestor.template,.json"; + input.onchange = (event: Event) => { + const target = event.target as HTMLInputElement; + const file = target.files?.[0]; + if (file) { + const reader = new FileReader(); + reader.onload = (e) => { + const content = e.target?.result as string; + + const dialogRef = this.dialog.open( + IngestorConfirmationDialogComponent, + { + data: { + header: "Confirm template", + message: "Do you really want to apply the following values?", + }, + }, + ); + dialogRef.afterClosed().subscribe((result) => { + if (result) { + try { + const parsedData = JSON.parse(content); + const newTransferData = { + ...this.createNewTransferData, + scicatHeader: { + ...this.createNewTransferData.scicatHeader, + ...parsedData.scicatHeader, + }, + userMetaData: { + ...this.createNewTransferData.userMetaData, + ...parsedData.userMetaData, + }, + }; + + this.updateIngestionObject(newTransferData); + } catch (error) { + console.error("Error parsing JSON file:", error); + } + } + }); + }; + reader.readAsText(file); + } + }; + input.click(); + } + + onSwitchEditorMode(mode: string) { + if (this.createNewTransferData) { + switch (mode) { + case "INGESTION": + this.createNewTransferData.editorMode = "INGESTION"; + break; + case "EDITOR": + this.createNewTransferData.editorMode = "EDITOR"; + break; + default: + console.error("Unknown mode"); + } + + const newTransferData = { ...this.createNewTransferData }; + // Clean selected file and selected method + newTransferData.selectedPath = ""; + newTransferData.selectedMethod = null; + + this.updateIngestionObject(newTransferData); + } + } +} diff --git a/src/app/ingestor/ingestor-dialogs/dialog-mounting-components/ingestor.export-helper.component.spec.ts b/src/app/ingestor/ingestor-dialogs/dialog-mounting-components/ingestor.export-helper.component.spec.ts new file mode 100644 index 0000000000..b22e7b3453 --- /dev/null +++ b/src/app/ingestor/ingestor-dialogs/dialog-mounting-components/ingestor.export-helper.component.spec.ts @@ -0,0 +1,49 @@ +import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing"; +import { + ExportTemplateHelperComponent, + ExportOptions, +} from "./ingestor.export-helper.component"; +import { MatCheckboxModule } from "@angular/material/checkbox"; +import { MatDividerModule } from "@angular/material/divider"; +import { FormsModule } from "@angular/forms"; +import { NO_ERRORS_SCHEMA } from "@angular/core"; + +describe("ExportTemplateHelperComponent", () => { + let component: ExportTemplateHelperComponent; + let fixture: ComponentFixture; + + const mockData: ExportOptions = { + exportSciCat: false, + exportOrganizational: false, + exportSample: false, + exportAll: false, + exportAsJSON: false, + }; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + declarations: [ExportTemplateHelperComponent], + imports: [MatCheckboxModule, MatDividerModule, FormsModule], + providers: [{ provide: "data", useValue: mockData }], + }); + + TestBed.compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ExportTemplateHelperComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + if (fixture) { + fixture.destroy(); + } + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/ingestor/ingestor-dialogs/dialog-mounting-components/ingestor.export-helper.component.ts b/src/app/ingestor/ingestor-dialogs/dialog-mounting-components/ingestor.export-helper.component.ts new file mode 100644 index 0000000000..c74ad18b34 --- /dev/null +++ b/src/app/ingestor/ingestor-dialogs/dialog-mounting-components/ingestor.export-helper.component.ts @@ -0,0 +1,55 @@ +import { Component, Injector } from "@angular/core"; + +export interface ExportOptions { + exportSciCat: boolean; + exportOrganizational: boolean; + exportSample: boolean; + exportAll: boolean; + exportAsJSON: boolean; +} + +@Component({ + selector: "export-template-checkbox", + template: ` +
+ Export SciCat + Export Organizational + Export Sample + + Additional options + Export All (includes extracted metadata) + Export As JSON (not a template format) + +
+ `, + styles: [ + ` + div { + display: flex; + flex-direction: column; + } + `, + ], + standalone: false, +}) +export class ExportTemplateHelperComponent { + data: ExportOptions; + + constructor(public injector: Injector) { + this.data = injector.get("data"); + } + + onExportAllChange() { + if (this.data.exportAll) { + this.data.exportSciCat = true; + this.data.exportOrganizational = true; + this.data.exportSample = true; + } + } +} diff --git a/src/app/ingestor/ingestor-dialogs/ingestor-file-browser/ingestor.file-browser.component.html b/src/app/ingestor/ingestor-dialogs/ingestor-file-browser/ingestor.file-browser.component.html new file mode 100644 index 0000000000..c72f04b7cc --- /dev/null +++ b/src/app/ingestor/ingestor-dialogs/ingestor-file-browser/ingestor.file-browser.component.html @@ -0,0 +1,91 @@ +
+

Ingestor Dataset Browser

+ +
+ +
+ +
+
+ +
Selected Path
+ + {{ + activeNode.probablyDataset ? "folder_special" : "folder" + }} +
{{ activeNode.path }}
+
+ This node is probably a valid data set. +
Children
+ + + + + + folder +
{{ goBackNode.name }}
+
{{ goBackNode.path }}
+
+ + + {{ + folder.probablyDataset ? "folder_special" : "folder" + }} +
{{ folder.name }}
+
{{ folder.path }}
+
+
+
+
+
+ +
+
+ + + + + diff --git a/src/app/ingestor/ingestor-dialogs/ingestor-file-browser/ingestor.file-browser.component.spec.ts b/src/app/ingestor/ingestor-dialogs/ingestor-file-browser/ingestor.file-browser.component.spec.ts new file mode 100644 index 0000000000..07bc13ee57 --- /dev/null +++ b/src/app/ingestor/ingestor-dialogs/ingestor-file-browser/ingestor.file-browser.component.spec.ts @@ -0,0 +1,54 @@ +import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing"; +import { IngestorFileBrowserComponent } from "./ingestor.file-browser.component"; +import { + MatDialog, + MAT_DIALOG_DATA, + MatDialogModule, +} from "@angular/material/dialog"; +import { StoreModule } from "@ngrx/store"; +import { provideMockStore } from "@ngrx/store/testing"; +import { NO_ERRORS_SCHEMA } from "@angular/core"; + +describe("IngestorFileBrowserComponent", () => { + let component: IngestorFileBrowserComponent; + let fixture: ComponentFixture; + + const mockDialog = { + open: jasmine.createSpy("open"), + }; + + const mockDialogData = { + someData: "someValue", + }; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + declarations: [IngestorFileBrowserComponent], + imports: [MatDialogModule, StoreModule.forRoot({})], + providers: [ + provideMockStore(), + { provide: MatDialog, useValue: mockDialog }, + { provide: MAT_DIALOG_DATA, useValue: mockDialogData }, + ], + }); + + TestBed.compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(IngestorFileBrowserComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + if (fixture) { + fixture.destroy(); + } + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/ingestor/ingestor-dialogs/ingestor-file-browser/ingestor.file-browser.component.ts b/src/app/ingestor/ingestor-dialogs/ingestor-file-browser/ingestor.file-browser.component.ts new file mode 100644 index 0000000000..e53886605e --- /dev/null +++ b/src/app/ingestor/ingestor-dialogs/ingestor-file-browser/ingestor.file-browser.component.ts @@ -0,0 +1,191 @@ +import { Component, Inject, OnDestroy, OnInit } from "@angular/core"; +import { MAT_DIALOG_DATA, MatDialog } from "@angular/material/dialog"; +import { + IngestionRequestInformation, + IngestorHelper, + DialogDataObject, +} from "ingestor/ingestor-page/helper/ingestor.component-helper"; +import { FolderNode } from "shared/sdk/models/ingestor/folderNode"; +import { GetBrowseDatasetResponse } from "shared/sdk/models/ingestor/models"; +import { PageChangeEvent } from "shared/modules/table/table.component"; +import { + selectIngestionObject, + selectIngestorBrowserActiveNode, +} from "state-management/selectors/ingestor.selectors"; +import { Store } from "@ngrx/store"; +import * as fromActions from "state-management/actions/ingestor.actions"; +import { Subscription } from "rxjs"; + +export interface BrowsableNode extends FolderNode { + childrenNodes?: BrowsableNode[]; + isExpanded?: boolean; + isChildrenLoaded?: boolean; + isLoading?: boolean; +} + +export interface GoBackNode extends FolderNode { + isRootNode: boolean; +} + +@Component({ + selector: "ingestor.file-browser.component", + templateUrl: "ingestor.file-browser.component.html", + styleUrls: ["../../ingestor-page/ingestor.component.scss"], + standalone: false, +}) +export class IngestorFileBrowserComponent implements OnInit, OnDestroy { + private _activeNode: BrowsableNode | null = null; + private subscriptions: Subscription[] = []; + + ingestionObject$ = this.store.select(selectIngestionObject); + selectIngestorBrowserActiveNode$ = this.store.select( + selectIngestorBrowserActiveNode, + ); + + activeNodeChildrenTotal = 0; + nextNode: BrowsableNode | null = null; + + isListView = true; + createNewTransferData: IngestionRequestInformation = + IngestorHelper.createEmptyRequestInformation(); + + browseFolderPageSize = 50; + browseFolderPage = 0; + + availableNodes: BrowsableNode | null = null; + goBackNode: GoBackNode | null = null; + rootNode: FolderNode | null = null; + + constructor( + public dialog: MatDialog, + @Inject(MAT_DIALOG_DATA) public data: DialogDataObject, + private store: Store, + ) {} + + get activeNode(): BrowsableNode | null { + return this._activeNode; + } + + set activeNode(node: BrowsableNode | null) { + this._activeNode = node; + + let backNodePath = "/"; + let isRootNode = false; + + if (node != null) { + backNodePath = node?.path.split("/").slice(0, -1).join("/"); + if (backNodePath === "") { + backNodePath = "/"; + } + + isRootNode = node?.path === "/"; + } + + this.goBackNode = { + name: "..", + path: backNodePath, + children: true, + isRootNode: isRootNode, + probablyDataset: false, + }; + } + + ngOnInit(): void { + this.subscriptions.push( + this.ingestionObject$.subscribe((ingestionObject) => { + if (ingestionObject) { + this.createNewTransferData = ingestionObject; + + const rootNode: FolderNode = { + name: "", + path: + this.createNewTransferData.selectedPath !== "" + ? this.createNewTransferData.selectedPath + : "/", + children: false, + probablyDataset: false, + }; + + this.subscriptions.push( + this.selectIngestorBrowserActiveNode$.subscribe((newNode) => { + if (newNode) { + const newActiveNode: BrowsableNode = { + ...(this.nextNode ?? rootNode), + childrenNodes: [], + children: false, + }; + this.setExtendedNodeActive(newActiveNode, newNode); + } + }), + ); + + this.onLoadFolderNode(rootNode, true); + } + }), + ); + } + + ngOnDestroy(): void { + this.subscriptions.forEach((subscription) => subscription.unsubscribe()); + } + + setExtendedNodeActive( + newNode: FolderNode, + requestResponse: GetBrowseDatasetResponse, + ): void { + this.activeNodeChildrenTotal = requestResponse.total; + + if (requestResponse.folders.length > 0) { + const rootNodeExtended: BrowsableNode = { + ...newNode, + children: true, + childrenNodes: requestResponse.folders, + }; + + this.activeNode = rootNodeExtended; + } else { + this.activeNode = newNode; + } + } + + onSelect = (): void => { + if (this.activeNode) { + this.createNewTransferData.selectedPath = this.activeNode.path; + this.store.dispatch( + fromActions.updateIngestionObject({ + ingestionObject: this.createNewTransferData, + }), + ); + } + }; + + toggleView = (): void => { + this.isListView = !this.isListView; + }; + + trackByFn(index: number, item: any): number { + return item.id; // or any unique identifier for the items + } + + onLoadFolderNode(item: BrowsableNode, resetPage = true): void { + if (resetPage) { + // Reset Page, when clicking on an item + this.browseFolderPage = 0; + } + + this.nextNode = item; + + this.store.dispatch( + fromActions.getBrowseFilePath({ + page: this.browseFolderPage + 1, // 1-based + pageNumber: this.browseFolderPageSize, + path: item.path, + }), + ); + } + + onFileBrowserChildrenPageChange(event: PageChangeEvent) { + this.browseFolderPage = event.pageIndex; + this.onLoadFolderNode(this.activeNode, false); + } +} diff --git a/src/app/ingestor/ingestor-dialogs/transfer-detail-view/ingestor.transfer-detail-view-dialog.component.spec.ts b/src/app/ingestor/ingestor-dialogs/transfer-detail-view/ingestor.transfer-detail-view-dialog.component.spec.ts new file mode 100644 index 0000000000..801d97087e --- /dev/null +++ b/src/app/ingestor/ingestor-dialogs/transfer-detail-view/ingestor.transfer-detail-view-dialog.component.spec.ts @@ -0,0 +1,80 @@ +import { IngestorTransferViewDialogComponent } from "./ingestor.transfer-detail-view-dialog.component"; +import { MatCardModule } from "@angular/material/card"; +import { MatListModule } from "@angular/material/list"; +import { + MatDialogModule, + MatDialogRef, + MAT_DIALOG_DATA, +} from "@angular/material/dialog"; +import { MockActivatedRoute, MockUserApi } from "shared/MockStubs"; +import { StoreModule } from "@ngrx/store"; +import { NO_ERRORS_SCHEMA } from "@angular/core"; +import { provideMockStore } from "@ngrx/store/testing"; +import { Router, ActivatedRoute } from "@angular/router"; +import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing"; +import { AppConfigService } from "app-config.service"; +import { UsersService } from "@scicatproject/scicat-sdk-ts-angular"; + +describe("IngestorTransferViewDialogComponent", () => { + let component: IngestorTransferViewDialogComponent; + let fixture: ComponentFixture; + + const router = { + navigateByUrl: jasmine.createSpy("navigateByUrl"), + }; + + const getConfig = () => ({ + ingestorEnabled: true, + }); + + const mockDialogRef = { + close: jasmine.createSpy("close"), + }; + + const mockDialogData = { + transferId: "test-transfer-123", + }; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + declarations: [IngestorTransferViewDialogComponent], + imports: [ + MatCardModule, + MatListModule, + MatDialogModule, + StoreModule.forRoot({}), + ], + providers: [ + provideMockStore(), + { provide: Router, useValue: router }, + { provide: MatDialogRef, useValue: mockDialogRef }, + { provide: MAT_DIALOG_DATA, useValue: mockDialogData }, + { + provide: AppConfigService, + useValue: { getConfig }, + }, + { provide: ActivatedRoute, useClass: MockActivatedRoute }, + { provide: UsersService, useClass: MockUserApi }, + ], + }); + + TestBed.compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(IngestorTransferViewDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + if (fixture) { + fixture.destroy(); + } + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/ingestor/ingestor-dialogs/transfer-detail-view/ingestor.transfer-detail-view-dialog.component.ts b/src/app/ingestor/ingestor-dialogs/transfer-detail-view/ingestor.transfer-detail-view-dialog.component.ts new file mode 100644 index 0000000000..4a89335fdb --- /dev/null +++ b/src/app/ingestor/ingestor-dialogs/transfer-detail-view/ingestor.transfer-detail-view-dialog.component.ts @@ -0,0 +1,154 @@ +import { Component, Inject, OnDestroy, OnInit } from "@angular/core"; +import { MatDialogRef, MAT_DIALOG_DATA } from "@angular/material/dialog"; +import { Store } from "@ngrx/store"; +import { Subscription } from "rxjs"; +import { TransferItem } from "shared/sdk/models/ingestor/transferItem"; +import * as fromActions from "state-management/actions/ingestor.actions"; +import { selectIngestorTransferDetailList } from "state-management/selectors/ingestor.selectors"; + +@Component({ + selector: "app-ingestor-transfer-view-dialog", + templateUrl: "./ingestor.transfer-detail-view-dialog.html", + styleUrls: ["../../ingestor-page/ingestor.component.scss"], + standalone: false, +}) +export class IngestorTransferViewDialogComponent implements OnInit, OnDestroy { + private subscriptions: Subscription[] = []; + transferAutoRefreshIntervalDetail = 3000; + autoRefreshInterval: NodeJS.Timeout = null; + transferDetailList$ = this.store.select(selectIngestorTransferDetailList); + detailItem: TransferItem | null = null; + // Define tables + displayedColumns: string[] = ["property", "value"]; + tableData: { property: string; value: string }[] = []; + + constructor( + private store: Store, + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: any, + ) {} + + ngOnInit() { + this.subscriptions.push( + this.transferDetailList$.subscribe((transferList) => { + if (transferList) { + this.detailItem = transferList.transfers?.find( + (item) => item.transferId === this.transferId, + ); + + this.tableData = [ + { property: "Transfer ID", value: this.transferId }, + ...(this.detailItem?.status !== undefined + ? [{ property: "Status", value: this.detailItem.status }] + : []), + ...(this.detailItem?.message !== undefined + ? [{ property: "Message", value: this.detailItem.message }] + : []), + ...(this.detailItem?.bytesTransferred !== undefined && + this.detailItem?.bytesTotal !== undefined + ? [ + { + property: "GB transferred", + value: this.getByteTransferRelative(), + }, + { + property: "GB transferred (total)", + value: this.getByteTransferTotal(), + }, + ] + : []), + ...(this.detailItem?.filesTransferred !== undefined && + this.detailItem?.filesTotal !== undefined + ? [ + { + property: "Files transferred (total)", + value: this.getFileTransferTotal(), + }, + ] + : []), + ]; + } + }), + ); + + this.startAutoRefresh(); + } + + ngOnDestroy(): void { + // Unsubscribe from all subscriptions + this.subscriptions.forEach((subscription) => subscription.unsubscribe()); + + this.stopAutoRefresh(); + } + + getByteTransferRelative(): string { + if (this.detailItem) { + const bytesToGB = (bytes: number) => bytes / 1024 ** 3; + return `${( + bytesToGB(this.detailItem.bytesTransferred) / + bytesToGB(this.detailItem.bytesTotal) + ).toFixed(2)} %`; + } + return "No further information available."; + } + + getByteTransferTotal(): string { + if (this.detailItem) { + const bytesToGB = (bytes: number) => bytes / 1024 ** 3; + return `${bytesToGB(this.detailItem.bytesTotal).toFixed(3)} GB`; + } + return "No further information available."; + } + + getFileTransferTotal(): string { + if (this.detailItem) { + return `${this.detailItem.filesTransferred} / ${this.detailItem.filesTotal} Files`; + } + return "No further information available."; + } + + get transferId(): string { + return this.data.transferId || null; + } + + get header(): string { + return "Transfer Details"; + } + + startAutoRefresh(): void { + this.doRefreshTransferList(this.transferId); + this.stopAutoRefresh(); + this.autoRefreshInterval = setInterval(() => { + this.doRefreshTransferList(this.transferId); + }, this.transferAutoRefreshIntervalDetail); + } + + stopAutoRefresh(): void { + if (this.autoRefreshInterval) { + clearInterval(this.autoRefreshInterval); + this.autoRefreshInterval = null; + } + } + + doRefreshTransferList( + transferId?: string, + page?: number, + pageNumber?: number, + ): void { + this.store.dispatch( + fromActions.updateTransferList({ + transferId, + page: page ? page + 1 : undefined, + pageNumber: pageNumber, + }), + ); + } + + onConfirm(): void { + this.dialogRef.close(true); + } + + onCancel(): void { + this.dialogRef.close(false); + } +} diff --git a/src/app/ingestor/ingestor-dialogs/transfer-detail-view/ingestor.transfer-detail-view-dialog.html b/src/app/ingestor/ingestor-dialogs/transfer-detail-view/ingestor.transfer-detail-view-dialog.html new file mode 100644 index 0000000000..5142d5854e --- /dev/null +++ b/src/app/ingestor/ingestor-dialogs/transfer-detail-view/ingestor.transfer-detail-view-dialog.html @@ -0,0 +1,35 @@ +
+

{{ header }}

+ +
+ + + + + + + + + + + + + + + + +
Property{{element.property}}Value{{element.value}}
+
+ + + + diff --git a/src/app/ingestor/ingestor-metadata-editor/customRenderer/all-of-renderer.ts b/src/app/ingestor/ingestor-metadata-editor/customRenderer/all-of-renderer.ts new file mode 100644 index 0000000000..bc0df73f5e --- /dev/null +++ b/src/app/ingestor/ingestor-metadata-editor/customRenderer/all-of-renderer.ts @@ -0,0 +1,15 @@ +import { Component, OnInit } from "@angular/core"; +import { JsonFormsControl } from "@jsonforms/angular"; + +@Component({ + selector: "AllOfRenderer", + styleUrls: ["../ingestor-metadata-editor.component.scss"], + template: `
AllOf Renderer
`, +}) +export class AllOfRendererComponent extends JsonFormsControl implements OnInit { + data: any[] = []; + + ngOnInit() { + this.data = this.uischema?.options?.items || []; + } +} diff --git a/src/app/ingestor/ingestor-metadata-editor/customRenderer/any-of-renderer.ts b/src/app/ingestor/ingestor-metadata-editor/customRenderer/any-of-renderer.ts new file mode 100644 index 0000000000..7b3b72ac9d --- /dev/null +++ b/src/app/ingestor/ingestor-metadata-editor/customRenderer/any-of-renderer.ts @@ -0,0 +1,161 @@ +import { ChangeDetectionStrategy, Component } from "@angular/core"; +import { JsonFormsAngularService, JsonFormsControl } from "@jsonforms/angular"; +import { + ControlProps, + findUISchema, + Generate, + GroupLayout, + JsonSchema, + setReadonly, + UISchemaElement, +} from "@jsonforms/core"; +import { configuredRenderer } from "../ingestor-metadata-editor-helper"; +import { MatCheckboxChange } from "@angular/material/checkbox"; +import { cloneDeep, isEmpty, startCase } from "lodash-es"; + +@Component({ + selector: "app-anyof-renderer", + styleUrls: ["../ingestor-metadata-editor.component.scss"], + template: ` + + {{ anyOfTitle }} + + + Enabled + + + + animationDuration="0ms" [selectedIndex]="selectedTabIndex" > + +
+ + +
+
+
+ +
+ + +
+
+
+ `, + standalone: false, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AnyOfRendererComponent extends JsonFormsControl { + dataAsString: string; + options: string[] = []; + filteredOptions: string[] = []; + anyOfTitle: string; + notNullOptionSelected = false; + selectedTabIndex = 0; // default value + tabAmount = 0; // max tabs + + rendererService: JsonFormsAngularService; + + defaultRenderer = configuredRenderer; + passedProps: ControlProps; + + constructor(service: JsonFormsAngularService) { + super(service); + this.rendererService = service; + } + + public mapAdditionalProps(props: ControlProps) { + this.passedProps = props; + this.anyOfTitle = props.label || "AnyOf"; + this.options = props.schema.anyOf.map( + (option: any) => option.title || option.type || JSON.stringify(option), + ); + + if (this.options.includes("null") && !props.data) { + this.selectedTabIndex = this.options.indexOf("null"); + this.notNullOptionSelected = false; + } + + this.filteredOptions = this.options.filter((option) => option !== "null"); + this.tabAmount = this.filteredOptions.length; + } + + public getUISchema(tabOption: string): UISchemaElement { + const selectedSchema = this.getTabSchema(tabOption); + + const isQuantityValue = + selectedSchema.title == "QuantityValue" || + selectedSchema.title == "QuantitySI"; + + const detailUiSchema = findUISchema( + undefined, + selectedSchema, + this.passedProps.uischema.scope, + this.passedProps.path, + () => { + const newSchema = cloneDeep(selectedSchema); + return Generate.uiSchema( + newSchema, + isQuantityValue ? "QuantityValueLayout" : "VerticalLayout", + undefined, + this.rootSchema, + ); + }, + this.passedProps.uischema, + this.passedProps.rootSchema, + ); + if (isEmpty(this.passedProps.path)) { + detailUiSchema.type = "VerticalLayout"; + } else { + (detailUiSchema as GroupLayout).label = startCase(this.passedProps.path); + } + if (!this.isEnabled()) { + setReadonly(detailUiSchema); + } + + return detailUiSchema; + } + + public getTabSchema(tabOption: string): JsonSchema { + const selectedSchema = (this.passedProps.schema.anyOf as any).find( + (option: any) => + option.title === tabOption || + option.type === tabOption || + JSON.stringify(option) === tabOption, + ); + + return selectedSchema; + } + + public onEnableCheckboxChange(event: MatCheckboxChange) { + this.notNullOptionSelected = event.checked; + + const updatedData = + this.rendererService.getState().jsonforms.core.data ?? {}; + + // Update the data in the correct path + const pathSegments = this.passedProps.path.split("."); + let current = updatedData ?? {}; + for (let i = 0; i < pathSegments.length - 1; i++) { + current = current[pathSegments[i]]; + } + current[pathSegments[pathSegments.length - 1]] = !this.notNullOptionSelected + ? null + : {}; + + this.rendererService.setData(updatedData); + } +} diff --git a/src/app/ingestor/ingestor-metadata-editor/customRenderer/array-renderer.ts b/src/app/ingestor/ingestor-metadata-editor/customRenderer/array-renderer.ts new file mode 100644 index 0000000000..148273fbd6 --- /dev/null +++ b/src/app/ingestor/ingestor-metadata-editor/customRenderer/array-renderer.ts @@ -0,0 +1,213 @@ +import { + ChangeDetectionStrategy, + Component, + OnDestroy, + OnInit, +} from "@angular/core"; +import { + JsonFormsAngularService, + JsonFormsAbstractControl, +} from "@jsonforms/angular"; +import { + arrayDefaultTranslations, + ArrayLayoutProps, + ArrayTranslations, + createDefaultValue, + defaultJsonFormsI18nState, + findUISchema, + getArrayTranslations, + JsonFormsState, + mapDispatchToArrayControlProps, + mapStateToArrayLayoutProps, + OwnPropsOfRenderer, + Paths, + setReadonly, + StatePropsOfArrayLayout, + UISchemaElement, + UISchemaTester, + unsetReadonly, +} from "@jsonforms/core"; + +@Component({ + selector: "app-array-layout-renderer-custom", + styleUrls: ["../ingestor-metadata-editor.component.scss"], + template: ` + + +

{{ label }}

+ + + error_outline + + +
+ +

{{ translations.noDataMessage }}

+
+ + + + + + + + + + + + +
+ `, + standalone: false, + + changeDetection: ChangeDetectionStrategy.OnPush, +}) +// eslint-disable-next-line @angular-eslint/component-class-suffix +export class ArrayLayoutRendererCustom + extends JsonFormsAbstractControl + implements OnInit, OnDestroy +{ + noData: boolean; + minOne: boolean; + translations: ArrayTranslations = {}; + addItem: (path: string, value: any) => () => void; + moveItemUp: (path: string, index: number) => () => void; + moveItemDown: (path: string, index: number) => () => void; + removeItems: (path: string, toDelete: number[]) => () => void; + uischemas: { + tester: UISchemaTester; + uischema: UISchemaElement; + }[]; + constructor(jsonFormsService: JsonFormsAngularService) { + super(jsonFormsService); + } + mapToProps( + state: JsonFormsState, + ): StatePropsOfArrayLayout & { translations: ArrayTranslations } { + const props = mapStateToArrayLayoutProps(state, this.getOwnProps()); + const t = + state.jsonforms.i18n?.translate ?? defaultJsonFormsI18nState.translate; + const translations = getArrayTranslations( + t, + arrayDefaultTranslations, + props.i18nKeyPrefix, + props.label, + ); + return { ...props, translations }; + } + remove(index: number): void { + this.removeItems(this.propsPath, [index])(); + } + add(): void { + this.addItem( + this.propsPath, + createDefaultValue(this.scopedSchema, this.rootSchema), + )(); + } + up(index: number): void { + this.moveItemUp(this.propsPath, index)(); + } + down(index: number): void { + this.moveItemDown(this.propsPath, index)(); + } + ngOnInit() { + super.ngOnInit(); + const { addItem, removeItems, moveUp, moveDown } = + mapDispatchToArrayControlProps( + this.jsonFormsService.updateCore.bind(this.jsonFormsService), + ); + this.addItem = addItem; + this.moveItemUp = moveUp; + this.moveItemDown = moveDown; + this.removeItems = removeItems; + } + mapAdditionalProps( + props: ArrayLayoutProps & { translations: ArrayTranslations }, + ) { + this.translations = props.translations; + this.noData = !props.data || props.data === 0; + this.uischemas = props.uischemas; + this.minOne = props.required; + } + getProps(index: number): OwnPropsOfRenderer { + const uischema = findUISchema( + this.uischemas, + this.scopedSchema, + this.uischema.scope, + this.propsPath, + undefined, + this.uischema, + this.rootSchema, + ); + if (this.isEnabled()) { + unsetReadonly(uischema); + } else { + setReadonly(uischema); + } + return { + schema: this.scopedSchema, + path: Paths.compose(this.propsPath, `${index}`), + uischema, + }; + } + trackByFn(index: number) { + return index; + } +} diff --git a/src/app/ingestor/ingestor-metadata-editor/customRenderer/custom-renderers.ts b/src/app/ingestor/ingestor-metadata-editor/customRenderer/custom-renderers.ts new file mode 100644 index 0000000000..23e0efdeb4 --- /dev/null +++ b/src/app/ingestor/ingestor-metadata-editor/customRenderer/custom-renderers.ts @@ -0,0 +1,53 @@ +import { + isAnyOfControl, + isObjectArrayWithNesting, + JsonFormsRendererRegistryEntry, +} from "@jsonforms/core"; +import { AnyOfRendererComponent } from "ingestor/ingestor-metadata-editor/customRenderer/any-of-renderer"; +import { rankWith } from "@jsonforms/core"; +import { + ObjectControlRendererTester, + TableRendererTester, +} from "@jsonforms/angular-material"; +import { ArrayLayoutRendererCustom } from "./array-renderer"; +import { CustomObjectControlRendererComponent } from "./object-group-renderer"; +import { + OwnerGroupFieldComponent, + ownerGroupFieldTester, +} from "./owner-group-field-renderer"; +import { + QuantityValueLayoutRendererComponent, + quantityValueLayoutTester, +} from "./quantity-value-layout-renderer"; +import { + SIFieldHiderRendererComponent, + isSIFieldTester, +} from "./quantity-field-renderer"; + +export const customRenderers: JsonFormsRendererRegistryEntry[] = [ + { + tester: ownerGroupFieldTester, + renderer: OwnerGroupFieldComponent, + }, + { + tester: quantityValueLayoutTester, + renderer: QuantityValueLayoutRendererComponent, + }, + { + tester: rankWith(4, isAnyOfControl), + renderer: AnyOfRendererComponent, + }, + { + tester: rankWith(4, isObjectArrayWithNesting), + renderer: ArrayLayoutRendererCustom, + }, + { tester: TableRendererTester, renderer: ArrayLayoutRendererCustom }, + { + tester: ObjectControlRendererTester, + renderer: CustomObjectControlRendererComponent, + }, + { + tester: isSIFieldTester, + renderer: SIFieldHiderRendererComponent, + }, +]; diff --git a/src/app/ingestor/ingestor-metadata-editor/customRenderer/object-group-renderer.ts b/src/app/ingestor/ingestor-metadata-editor/customRenderer/object-group-renderer.ts new file mode 100644 index 0000000000..34e05756bc --- /dev/null +++ b/src/app/ingestor/ingestor-metadata-editor/customRenderer/object-group-renderer.ts @@ -0,0 +1,149 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, +} from "@angular/core"; +import { + JsonFormsAngularService, + JsonFormsControlWithDetail, +} from "@jsonforms/angular"; +import { + ControlProps, + findUISchema, + Generate, + GroupLayout, + setReadonly, + UISchemaElement, +} from "@jsonforms/core"; +import { + configuredRenderer, + convertJSONFormsErrorToString, +} from "../ingestor-metadata-editor-helper"; +import { cloneDeep, startCase } from "lodash-es"; +import isEmpty from "lodash/isEmpty"; + +@Component({ + selector: "app-object-group-renderer", + styleUrls: ["../ingestor-metadata-editor.component.scss"], + template: ` + + {{ objectTitle }} + + + error_outline + + + +
+ + +
+ +

+ error_outline + Recursive data structure in selected JSON Schema detected. +

+
+
+ `, + standalone: false, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CustomObjectControlRendererComponent extends JsonFormsControlWithDetail { + rendererService: JsonFormsAngularService; + detailUiSchema: UISchemaElement; + + defaultRenderer = configuredRenderer; + objectTitle: string; + errorRecursiveStructure: boolean; + + constructor(service: JsonFormsAngularService) { + super(service); + this.rendererService = service; + } + + public mapAdditionalProps(props: ControlProps) { + const pathTitle = props.path || "Object"; + + this.errorRecursiveStructure = this.isRecursive(); + + this.objectTitle = pathTitle + .replaceAll(".", " ") + .replaceAll("_", " ") + .split(" ") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); + + const isQuantityValue = + props.schema.title == "QuantityValue" || + props.schema.title == "QuantitySI"; + + this.detailUiSchema = findUISchema( + undefined, + props.schema, + props.uischema.scope, + props.path, + () => { + const newSchema = cloneDeep(props.schema); + return Generate.uiSchema( + newSchema, + isQuantityValue ? "QuantityValueLayout" : "VerticalLayout", + undefined, + this.rootSchema, + ); + }, + props.uischema, + props.rootSchema, + ); + if (isEmpty(props.path)) { + this.detailUiSchema.type = "VerticalLayout"; + } else { + (this.detailUiSchema as GroupLayout).label = startCase(props.path); + } + if (!this.isEnabled()) { + setReadonly(this.detailUiSchema); + } + + // Get error from child elements + const path = props.path || ""; + const allErrors = + this.rendererService.getState().jsonforms.core.errors ?? []; + const filteredErrors = allErrors.filter( + (e) => + (e.instancePath === "" && path === "") || + e.instancePath === "/" + path.replaceAll(".", "/") || + e.instancePath.startsWith("/" + path.replaceAll(".", "/") + "/"), + ); + + this.error = convertJSONFormsErrorToString(filteredErrors); + } + + isRecursive(): boolean { + const rootSchemaAsString = JSON.stringify(this.scopedSchema); + const scopedSchemaAsString = JSON.stringify(this.rootSchema); + + // Check if root and scoped schema are equal and if expected type is object + if ( + rootSchemaAsString === scopedSchemaAsString && + ((Array.isArray(this.scopedSchema.type) && + this.scopedSchema.type.includes("object")) || + (typeof this.scopedSchema.type === "string" && + this.scopedSchema.type === "object")) + ) { + return true; + } + + return false; + } +} diff --git a/src/app/ingestor/ingestor-metadata-editor/customRenderer/one-of-renderer.ts b/src/app/ingestor/ingestor-metadata-editor/customRenderer/one-of-renderer.ts new file mode 100644 index 0000000000..76324cab20 --- /dev/null +++ b/src/app/ingestor/ingestor-metadata-editor/customRenderer/one-of-renderer.ts @@ -0,0 +1,88 @@ +import { Component } from "@angular/core"; +import { JsonFormsAngularService, JsonFormsControl } from "@jsonforms/angular"; +import { ControlProps, JsonSchema } from "@jsonforms/core"; +import { configuredRenderer } from "../ingestor-metadata-editor-helper"; + +@Component({ + selector: "app-oneof-component", + styleUrls: ["../ingestor-metadata-editor.component.scss"], + template: ` +
+

{{ anyOfTitle }}

+ + + {{ option }} + + +
+ +
+
+ `, +}) +export class OneOfRendererComponent extends JsonFormsControl { + dataAsString: string; + options: string[] = []; + anyOfTitle: string; + selectedOption: string; + selectedAnyOption: JsonSchema; + + rendererService: JsonFormsAngularService; + + defaultRenderer = configuredRenderer; + passedProps: ControlProps; + + constructor(service: JsonFormsAngularService) { + super(service); + this.rendererService = service; + } + + public mapAdditionalProps(props: ControlProps) { + this.passedProps = props; + this.anyOfTitle = props.label || "AnyOf"; + this.options = props.schema.anyOf.map( + (option: any) => option.title || option.type || JSON.stringify(option), + ); + if (!props.data) { + this.selectedOption = "null"; // Auf "null" setzen, wenn die Daten leer sind + } + } + + public onOptionChange() { + this.selectedAnyOption = (this.passedProps.schema.anyOf as any).find( + (option: any) => + option.title === this.selectedOption || + option.type === this.selectedOption || + JSON.stringify(option) === this.selectedOption, + ); + } + + public onInnerJsonFormsChange(event: any) { + // Check if data is equal to the passedProps.data + if (event !== this.passedProps.data) { + const updatedData = this.rendererService.getState().jsonforms.core.data; + + // aktualisiere das aktuelle Datenobjekt + const pathSegments = this.passedProps.path.split("."); + let current = updatedData; + for (let i = 0; i < pathSegments.length - 1; i++) { + current = current[pathSegments[i]]; + } + current[pathSegments[pathSegments.length - 1]] = event; + + this.rendererService.setData(updatedData); + } + } +} diff --git a/src/app/ingestor/ingestor-metadata-editor/customRenderer/owner-group-field-renderer.ts b/src/app/ingestor/ingestor-metadata-editor/customRenderer/owner-group-field-renderer.ts new file mode 100644 index 0000000000..97a3cace33 --- /dev/null +++ b/src/app/ingestor/ingestor-metadata-editor/customRenderer/owner-group-field-renderer.ts @@ -0,0 +1,156 @@ +import { + Component, + OnInit, + ChangeDetectionStrategy, + OnDestroy, +} from "@angular/core"; +import { JsonFormsAngularService, JsonFormsControl } from "@jsonforms/angular"; +import { RankedTester, rankWith, scopeEndsWith } from "@jsonforms/core"; +import { Store } from "@ngrx/store"; +import { selectUserSettingsPageViewModel } from "state-management/selectors/user.selectors"; +import { fetchCurrentUserAction } from "state-management/actions/user.actions"; +import { MatAutocompleteSelectedEvent } from "@angular/material/autocomplete"; +import { Subscription } from "rxjs"; + +@Component({ + selector: "owner-group-renderer", + styleUrls: ["../ingestor-metadata-editor.component.scss"], + template: ` +
+ + {{ label }} + + + + {{ ownerGroup }} + + + {{ + description + }} + {{ error }} + + + + {{ label }} + + +
+ `, + standalone: false, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class OwnerGroupFieldComponent + extends JsonFormsControl + implements OnInit, OnDestroy +{ + private componentSubscriptions: Subscription[] = []; + focused = false; + vm$ = this.store.select(selectUserSettingsPageViewModel); + userOwnerGroups = []; + + constructor( + jsonformsService: JsonFormsAngularService, + private store: Store, + ) { + super(jsonformsService); + } + + getEventValue = (event: any) => event.target.value || undefined; + + ngOnInit() { + // Call ngOnInit from super class + super.ngOnInit(); + + // Fetch the owner groups from the scicat user + this.componentSubscriptions.push( + this.vm$.subscribe((settings) => { + const getclaims = (profile: any): string[] | null => { + if (!profile) { + return null; + } + + if (profile.oidcClaims !== undefined) { + return settings.profile.oidcClaims.accessGroups ?? null; + } + + if (profile.accessGroups !== undefined) { + return settings.profile.accessGroups ?? null; + } + + return null; + }; + + const claims = getclaims(settings.profile); + if (claims !== null && claims.length > 0) { + this.userOwnerGroups = claims; + } else { + this.userOwnerGroups = []; + + // Fetch the current user to get the owner groups if not available + this.store.dispatch(fetchCurrentUserAction()); + } + + if (this.form.disabled) { + this.form.enable(); + } + }), + ); + } + + ngOnDestroy(): void { + // Unsubscribe from all subscriptions + this.componentSubscriptions.forEach((subscription) => + subscription.unsubscribe(), + ); + } + + onSelectAutocompleteValue($event: MatAutocompleteSelectedEvent) { + const selectedValue = $event.option.value; + // Create a fake json from event + const fakeChangeEvent = { + target: { + value: selectedValue, + }, + }; + this.onChange(fakeChangeEvent); + } +} + +export const ownerGroupFieldTester: RankedTester = rankWith( + 2, + scopeEndsWith("ownerGroup"), +); diff --git a/src/app/ingestor/ingestor-metadata-editor/customRenderer/quantity-field-renderer.ts b/src/app/ingestor/ingestor-metadata-editor/customRenderer/quantity-field-renderer.ts new file mode 100644 index 0000000000..e2b6b29dc7 --- /dev/null +++ b/src/app/ingestor/ingestor-metadata-editor/customRenderer/quantity-field-renderer.ts @@ -0,0 +1,18 @@ +import { Component } from "@angular/core"; +import { JsonFormsAngularService, JsonFormsControl } from "@jsonforms/angular"; +import { rankWith, scopeEndsWith, or } from "@jsonforms/core"; + +export const isSIFieldTester = rankWith( + 100, + or(scopeEndsWith("unitSI"), scopeEndsWith("valueSI")), +); + +@Component({ + selector: "si-field-hider", + template: "", // Empty template - renders nothing +}) +export class SIFieldHiderRendererComponent extends JsonFormsControl { + constructor(jsonformsService: JsonFormsAngularService) { + super(jsonformsService); + } +} diff --git a/src/app/ingestor/ingestor-metadata-editor/customRenderer/quantity-value-layout-renderer.ts b/src/app/ingestor/ingestor-metadata-editor/customRenderer/quantity-value-layout-renderer.ts new file mode 100644 index 0000000000..39d5f5b020 --- /dev/null +++ b/src/app/ingestor/ingestor-metadata-editor/customRenderer/quantity-value-layout-renderer.ts @@ -0,0 +1,100 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + Pipe, + PipeTransform, +} from "@angular/core"; +import { + JsonSchema, + Layout, + OwnPropsOfRenderer, + RankedTester, + rankWith, + UISchemaElement, + uiTypeIs, +} from "@jsonforms/core"; +import { JsonFormsAngularService } from "@jsonforms/angular"; +import { LayoutRenderer } from "@jsonforms/angular-material"; + +@Component({ + selector: "QuantityValueLayoutRendererComponent", + template: ` +
+
+ +
+
+ `, + styleUrls: ["../ingestor-metadata-editor.component.scss"], + standalone: false, + + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class QuantityValueLayoutRendererComponent extends LayoutRenderer { + constructor( + jsonFormsService: JsonFormsAngularService, + changeDetectionRef: ChangeDetectorRef, + ) { + super(jsonFormsService, changeDetectionRef); + } +} +export const quantityValueLayoutTester: RankedTester = rankWith( + 6, + uiTypeIs("QuantityValueLayout"), +); + +export interface QuantityValueLayout extends Layout { + type: "QuantityValueLayout"; +} + +@Pipe({ name: "customLayoutChildrenRenderProps", standalone: false }) +export class CustomLayoutChildrenRenderPropsPipe implements PipeTransform { + transform( + uischema: Layout, + schema: JsonSchema, + path: string, + ): OwnPropsOfRenderer[] { + const elements = (uischema.elements || []) + .map((el: UISchemaElement) => ({ + uischema: el, + schema: schema, + path: path, + })) + .sort((a: any, b: any) => { + const scopeA = a.uischema.scope || ""; + const scopeB = b.uischema.scope || ""; + + const order = ["value", "unit", "valueSI", "unitSI"]; + + const indexA = order.findIndex((key) => scopeA.endsWith(key)); + const indexB = order.findIndex((key) => scopeB.endsWith(key)); + + if (indexA !== -1 && indexB !== -1) { + return indexA - indexB; // Sort based on the defined order + } + + if (indexA !== -1) { + return -1; // a comes before b if a matches and b doesn't + } + if (indexB !== -1) { + return 1; // b comes before a if b matches and a doesn't + } + + // Fallback to localeCompare if neither matches the order + return scopeA.localeCompare(scopeB); + }); + + return elements; + } +} diff --git a/src/app/ingestor/ingestor-metadata-editor/ingestor-metadata-editor-helper.spec.ts b/src/app/ingestor/ingestor-metadata-editor/ingestor-metadata-editor-helper.spec.ts new file mode 100644 index 0000000000..fcb0e9cd95 --- /dev/null +++ b/src/app/ingestor/ingestor-metadata-editor/ingestor-metadata-editor-helper.spec.ts @@ -0,0 +1,197 @@ +import { + IngestorMetadataEditorHelper, + convertJSONFormsErrorToString, +} from "./ingestor-metadata-editor-helper"; +import { JsonSchema } from "@jsonforms/core"; + +describe("IngestorMetadataEditorHelper", () => { + describe("resolveRefs", () => { + it("should resolve a simple $ref", () => { + const schema = { + definitions: { + name: { type: "string" }, + }, + properties: { + userName: { $ref: "#/definitions/name" }, + }, + }; + + const resolved = IngestorMetadataEditorHelper.resolveRefs( + schema.properties.userName, + schema, + ); + expect(resolved.type).toBe("string"); + }); + + it("should return schema as-is when no $ref exists", () => { + const schema = { type: "string" }; + const resolved = IngestorMetadataEditorHelper.resolveRefs(schema, schema); + expect(resolved).toEqual(schema); + }); + + it("should handle null or undefined schemas", () => { + expect(IngestorMetadataEditorHelper.resolveRefs(null, {})).toBeNull(); + expect( + IngestorMetadataEditorHelper.resolveRefs(undefined, {}), + ).toBeUndefined(); + }); + }); + + describe("isRequiredField", () => { + const schema: JsonSchema = { + type: "object", + properties: { + instrument: { + type: "object", + properties: { + name: { type: "string" }, + voltage: { type: "number" }, + }, + required: ["name"], + }, + }, + required: ["instrument"], + }; + + it("should return true for required fields", () => { + expect( + IngestorMetadataEditorHelper.isRequiredField("/instrument", schema), + ).toBe(true); + expect( + IngestorMetadataEditorHelper.isRequiredField( + "/instrument/name", + schema, + ), + ).toBe(true); + }); + + it("should return false for non-required fields", () => { + expect( + IngestorMetadataEditorHelper.isRequiredField( + "/instrument/voltage", + schema, + ), + ).toBe(false); + }); + + it("should return false for invalid paths", () => { + expect( + IngestorMetadataEditorHelper.isRequiredField("/nonexistent", schema), + ).toBe(false); + expect(IngestorMetadataEditorHelper.isRequiredField("", schema)).toBe( + false, + ); + }); + + it("should handle null or undefined inputs", () => { + expect(IngestorMetadataEditorHelper.isRequiredField(null, schema)).toBe( + false, + ); + expect(IngestorMetadataEditorHelper.isRequiredField("/test", null)).toBe( + false, + ); + }); + }); + + describe("filterErrorsForRenderView", () => { + const schema: JsonSchema = { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + required: ["name"], + }; + + const errors = [ + { instancePath: "/name", message: "is required" }, + { instancePath: "/age", message: "must be a number" }, + ]; + + it("should return all errors when renderView is 'all'", () => { + const filtered = IngestorMetadataEditorHelper.filterErrorsForRenderView( + errors, + schema, + "all", + ); + expect(filtered.length).toBe(2); + }); + + it("should filter to only required field errors when renderView is 'requiredOnly'", () => { + const filtered = IngestorMetadataEditorHelper.filterErrorsForRenderView( + errors, + schema, + "requiredOnly", + ); + expect(filtered.length).toBe(1); + expect(filtered[0].instancePath).toBe("/name"); + }); + + it("should return empty array when errors is empty", () => { + const filtered = IngestorMetadataEditorHelper.filterErrorsForRenderView( + [], + schema, + "requiredOnly", + ); + expect(filtered.length).toBe(0); + }); + }); + + describe("processMetadataErrors", () => { + const schema: JsonSchema = { + type: "object", + properties: { + name: { type: "string" }, + }, + required: ["name"], + }; + + it("should return valid when no errors", () => { + const result = IngestorMetadataEditorHelper.processMetadataErrors( + [], + schema, + "all", + ); + expect(result.isValid).toBe(true); + expect(result.errorString).toBe(""); + }); + + it("should return invalid with error string when errors exist", () => { + const errors = [{ instancePath: "/name", message: "is required" }]; + const result = IngestorMetadataEditorHelper.processMetadataErrors( + errors, + schema, + "all", + ); + expect(result.isValid).toBe(false); + expect(result.errorString).toContain("is required"); + }); + }); +}); + +describe("convertJSONFormsErrorToString", () => { + it("should convert errors to string format", () => { + const errors = [ + { instancePath: "/name", message: "is required" }, + { instancePath: "/age", message: "must be a number" }, + ]; + const result = convertJSONFormsErrorToString(errors); + expect(result).toContain("1. @/name is required"); + expect(result).toContain("2. @/age must be a number"); + }); + + it("should skip errors with 'must NOT have additional properties' message", () => { + const errors = [ + { message: "must NOT have additional properties" }, + { instancePath: "/name", message: "is required" }, + ]; + const result = convertJSONFormsErrorToString(errors); + expect(result).not.toContain("additional properties"); + expect(result).toContain("1. @/name is required"); + }); + + it("should handle empty error array", () => { + const result = convertJSONFormsErrorToString([]); + expect(result).toBe(""); + }); +}); diff --git a/src/app/ingestor/ingestor-metadata-editor/ingestor-metadata-editor-helper.ts b/src/app/ingestor/ingestor-metadata-editor/ingestor-metadata-editor-helper.ts new file mode 100644 index 0000000000..9fe41e2375 --- /dev/null +++ b/src/app/ingestor/ingestor-metadata-editor/ingestor-metadata-editor-helper.ts @@ -0,0 +1,179 @@ +import { angularMaterialRenderers } from "@jsonforms/angular-material"; +import { customRenderers } from "./customRenderer/custom-renderers"; +import { JsonSchema } from "@jsonforms/core"; + +export const configuredRenderer = [ + ...customRenderers, + ...angularMaterialRenderers, +]; + +export class IngestorMetadataEditorHelper { + // Resolve all $ref in a schema + static resolveRefs(schema: any, rootSchema: any): any { + if (schema === null || schema === undefined) { + return schema; + } + + if (schema.$ref) { + const refPath = schema.$ref.replace("#/", "").split("/"); + let ref = rootSchema; + refPath.forEach((part) => { + ref = ref[part]; + }); + return IngestorMetadataEditorHelper.resolveRefs(ref, rootSchema); + } else if (typeof schema === "object") { + for (const key in schema) { + if (Object.prototype.hasOwnProperty.call(schema, key)) { + schema[key] = IngestorMetadataEditorHelper.resolveRefs( + schema[key], + rootSchema, + ); + } + } + } + return schema; + } + + static reduceToRequiredProperties(schema: any): any { + if (!schema || typeof schema !== "object") { + return schema; + } + + // Copy the schema and initialize a reduced structure + const reducedSchema: any = { ...schema }; + + // remove not required properties + if (schema.properties && Array.isArray(schema.required)) { + reducedSchema.properties = {}; + for (const key of schema.required) { + if (schema.properties[key]) { + const property = schema.properties[key]; + // recursive call for the nested structur + reducedSchema.properties[key] = + IngestorMetadataEditorHelper.reduceToRequiredProperties(property); + } + } + } + + // when type is an array, reduce the items as well + if (schema.type === "array" && schema.items) { + reducedSchema.items = + IngestorMetadataEditorHelper.reduceToRequiredProperties(schema.items); + } + + return reducedSchema; + } + + /** + * Checks if a field at the given error path is marked as required in the JSON schema + * @param errorPath - The path to the field (e.g., "/instrument/name" or "/acquisition/voltage") + * @param schema - The JSON schema to check against + * @returns true if the field is required, false otherwise + */ + static isRequiredField(errorPath: string, schema: JsonSchema): boolean { + if (!schema || !errorPath) { + return false; + } + const pathSegments = errorPath.replace(/^\//, "").split("/"); + if (pathSegments.length === 0 || pathSegments[0] === "") { + return false; + } + let currentSchema = schema; + + for (let i = 0; i < pathSegments.length - 1; i++) { + const segment = pathSegments[i]; + + if (currentSchema.properties && currentSchema.properties[segment]) { + currentSchema = currentSchema.properties[segment]; + } else { + return false; + } + } + + const fieldName = pathSegments[pathSegments.length - 1]; + + if (currentSchema.required && Array.isArray(currentSchema.required)) { + return currentSchema.required.includes(fieldName); + } + + // If no required array found, field is not required + return false; + } + + /** + * Filters validation errors to only include those for required fields when in 'requiredOnly' render mode + * @param errors - Array of validation errors from JSONForms + * @param schema - The JSON schema to validate against + * @param renderView - The current render view mode + * @returns Filtered array of errors (only required field errors if renderView is 'requiredOnly') + */ + static filterErrorsForRenderView( + errors: any[], + schema: JsonSchema, + renderView: string, + ): any[] { + if (renderView !== "requiredOnly" || !errors || errors.length === 0) { + return errors; + } + + return errors.filter((error) => { + const errorPath = + error.instancePath || error.dataPath || error.schemaPath || ""; + return this.isRequiredField(errorPath, schema); + }); + } + + /** + * Processes validation errors for metadata forms + * @param errors - Array of validation errors from JSONForms + * @param schema - The JSON schema to validate against + * @param renderView - The current render view mode + * @returns Object containing processed validation results + */ + static processMetadataErrors( + errors: any[], + schema: JsonSchema, + renderView: string, + ): { isValid: boolean; errorString: string } { + const filteredErrors = this.filterErrorsForRenderView( + errors, + schema, + renderView || "all", + ); + + return { + isValid: filteredErrors.length === 0, + errorString: convertJSONFormsErrorToString(filteredErrors), + }; + } +} + +export const convertJSONFormsErrorToString = (error: any): string => { + let errorString = ""; + let displayIterNum = 1; + + for (let counter = 0; counter < error.length; counter++) { + const currentError = error[counter]; + + if (currentError.message === undefined || currentError.message === null) + continue; + + // Filter json forms internal errors + if (currentError.message === "must NOT have additional properties") { + continue; + } + + errorString += + displayIterNum + + ". " + + (currentError.instancePath && currentError.instancePath !== "" + ? "@" + currentError.instancePath + " " + : "") + + currentError.message + + " "; + + displayIterNum++; + } + + return errorString; +}; diff --git a/src/app/ingestor/ingestor-metadata-editor/ingestor-metadata-editor-schema_demo.ts b/src/app/ingestor/ingestor-metadata-editor/ingestor-metadata-editor-schema_demo.ts new file mode 100644 index 0000000000..d39912e268 --- /dev/null +++ b/src/app/ingestor/ingestor-metadata-editor/ingestor-metadata-editor-schema_demo.ts @@ -0,0 +1,553 @@ +export const demo_organizational_schema = { + type: "object", + properties: { + grants: { + type: "array", + items: { + type: "object", + properties: { + grant_name: { + type: "string", + description: "name of the grant", + }, + start_date: { + type: "string", + format: "date", + description: "start date", + }, + end_date: { + type: "string", + format: "date", + description: "end date", + }, + budget: { + type: "number", + description: "budget", + }, + project_id: { + type: "string", + description: "project id", + }, + country: { + type: "string", + description: "Country of the institution", + }, + }, + }, + description: "List of grants associated with the project", + }, + authors: { + type: "array", + items: { + type: "object", + properties: { + first_name: { + type: "string", + description: "first name", + }, + work_status: { + type: "boolean", + description: "work status", + }, + email: { + type: "string", + description: "email", + pattern: "^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$", + }, + work_phone: { + type: "string", + description: "work phone", + }, + name: { + type: "string", + description: "name", + }, + name_org: { + type: "string", + description: "Name of the organization", + }, + type_org: { + type: "string", + description: + "Type of organization, academic, commercial, governmental, etc.", + enum: ["Academic", "Commercial", "Government", "Other"], + }, + country: { + type: "string", + description: "Country of the institution", + }, + role: { + type: "string", + description: + "Role of the author, for example principal investigator", + }, + orcid: { + type: "string", + description: "ORCID of the author, a type of unique identifier", + }, + }, + }, + description: "List of authors associated with the project", + }, + funder: { + type: "array", + items: { + type: "object", + properties: { + funder_name: { + type: "string", + description: "funding organization/person.", + }, + type_org: { + type: "string", + description: + "Type of organization, academic, commercial, governmental, etc.", + enum: ["Academic", "Commercial", "Government", "Other"], + }, + country: { + type: "string", + description: "Country of the institution", + }, + }, + }, + description: "Description of the project funding", + }, + }, + required: ["authors", "funder"], +}; + +export const demo_acquisition_schema = { + type: "object", + properties: { + nominal_defocus: { + type: "object", + description: "Target defocus set, min and max values in µm.", + }, + calibrated_defocus: { + type: "object", + description: + "Machine estimated defocus, min and max values in µm. Has a tendency to be off.", + }, + nominal_magnification: { + type: "integer", + description: + "Magnification level as indicated by the instrument, no unit", + }, + calibrated_magnification: { + type: "integer", + description: "Calculated magnification, no unit", + }, + holder: { + type: "string", + description: "Speciman holder model", + }, + holder_cryogen: { + type: "string", + description: + "Type of cryogen used in the holder - if the holder is cooled seperately", + }, + temperature_range: { + type: "object", + description: + "Temperature during data collection, in K with min and max values.", + }, + microscope_software: { + type: "string", + description: "Software used for instrument control", + }, + detector: { + type: "string", + description: "Make and model of the detector used", + }, + detector_mode: { + type: "string", + description: "Operating mode of the detector", + }, + dose_per_movie: { + type: "object", + description: + "Average dose per image/movie/tilt - given in electrons per square Angstrom", + }, + energy_filter: { + type: "object", + description: "Whether an energy filter was used and its specifics.", + }, + image_size: { + type: "object", + description: "The size of the image in pixels, height and width given.", + }, + date_time: { + type: "string", + description: "Time and date of the data acquisition", + }, + exposure_time: { + type: "object", + description: "Time of data acquisition per movie/tilt - in s", + }, + cryogen: { + type: "string", + description: + "Cryogen used in cooling the instrument and sample, usually nitrogen", + }, + frames_per_movie: { + type: "integer", + description: + "Number of frames that on average constitute a full movie, can be a bit hard to define for some detectors", + }, + grids_imaged: { + type: "integer", + description: + "Number of grids imaged for this project - here with qualifier during this data acquisition", + }, + images_generated: { + type: "integer", + description: + "Number of images generated total for this data collection - might need a qualifier for tilt series to determine whether full series or individual tilts are counted", + }, + binning_camera: { + type: "number", + description: + "Level of binning on the images applied during data collection", + }, + pixel_size: { + type: "object", + description: "Pixel size, in Angstrom", + }, + specialist_optics: { + type: "object", + description: "Any type of special optics, such as a phaseplate", + }, + beamshift: { + type: "object", + description: + "Movement of the beam above the sample for data collection purposes that does not require movement of the stage. Given in mrad.", + }, + beamtilt: { + type: "object", + description: + "Another way to move the beam above the sample for data collection purposes that does not require movement of the stage. Given in mrad.", + }, + imageshift: { + type: "object", + description: + "Movement of the Beam below the image in order to shift the image on the detector. Given in µm.", + }, + beamtiltgroups: { + type: "integer", + description: + "Number of Beamtilt groups present in this dataset - for optimized processing split dataset into groups of same tilt angle. Despite its name Beamshift is often used to achieve this result.", + }, + gainref_flip_rotate: { + type: "string", + description: + "Whether and how you have to flip or rotate the gainref in order to align with your acquired images", + }, + }, + required: [ + "detector", + "dose_per_movie", + "date_time", + "binning_camera", + "pixel_size", + ], +}; + +export const demo_sample_schema = { + type: "object", + properties: { + overall_molecule: { + type: "object", + description: "Description of the overall molecule", + properties: { + molecular_type: { + type: "string", + description: + "Description of the overall molecular type, i.e., a complex", + }, + name_sample: { + type: "string", + description: "Name of the full sample", + }, + source: { + type: "string", + description: + "Where the sample was taken from, i.e., natural host, recombinantly expressed, etc.", + }, + molecular_weight: { + type: "object", + description: "Molecular weight in Da", + }, + assembly: { + type: "string", + description: + "What type of higher order structure your sample forms - if any.", + enum: ["FILAMENT", "HELICAL ARRAY", "PARTICLE"], + }, + }, + required: ["molecular_type", "name_sample", "source", "assembly"], + }, + molecule: { + type: "array", + items: { + type: "object", + properties: { + name_mol: { + type: "string", + description: + "Name of an individual molecule (often protein) in the sample", + }, + molecular_type: { + type: "string", + description: + "Description of the overall molecular type, i.e., a complex", + }, + molecular_class: { + type: "string", + description: "Class of the molecule", + enum: ["Antibiotic", "Carbohydrate", "Chimera", "None of these"], + }, + sequence: { + type: "string", + description: + "Full sequence of the sample as in the data, i.e., cleaved tags should also be removed from sequence here", + }, + natural_source: { + type: "string", + description: "Scientific name of the natural host organism", + }, + taxonomy_id_source: { + type: "string", + description: "Taxonomy ID of the natural source organism", + }, + expression_system: { + type: "string", + description: + "Scientific name of the organism used to produce the molecule of interest", + }, + taxonomy_id_expression: { + type: "string", + description: "Taxonomy ID of the expression system organism", + }, + gene_name: { + type: "string", + description: "Name of the gene of interest", + }, + }, + }, + required: [ + "name_mol", + "molecular_type", + "molecular_class", + "sequence", + "natural_source", + "taxonomy_id_source", + "expression_system", + "taxonomy_id_expression", + ], + }, + ligands: { + type: "array", + items: { + type: "object", + properties: { + present: { + type: "boolean", + description: "Whether the model contains any ligands", + }, + smiles: { + type: "string", + description: "Provide a valid SMILES string of your ligand", + }, + reference: { + type: "string", + description: + "Link to a reference of your ligand, i.e., CCD, PubChem, etc.", + }, + }, + }, + description: "List of ligands associated with the sample", + }, + specimen: { + type: "object", + description: "Description of the specimen", + properties: { + buffer: { + type: "string", + description: + "Name/composition of the (chemical) sample buffer during grid preparation", + }, + concentration: { + type: "object", + description: + "Concentration of the (supra)molecule in the sample, in mg/ml", + }, + ph: { + type: "number", + description: "pH of the sample buffer", + }, + vitrification: { + type: "boolean", + description: "Whether the sample was vitrified", + }, + vitrification_cryogen: { + type: "string", + description: "Which cryogen was used for vitrification", + }, + humidity: { + type: "object", + description: "Environmental humidity just before vitrification, in %", + }, + temperature: { + type: "object", + description: + "Environmental temperature just before vitrification, in K", + minimum: 0.0, + }, + staining: { + type: "boolean", + description: "Whether the sample was stained", + }, + embedding: { + type: "boolean", + description: "Whether the sample was embedded", + }, + shadowing: { + type: "boolean", + description: "Whether the sample was shadowed", + }, + }, + required: [ + "ph", + "vitrification", + "vitrification_cryogen", + "staining", + "embedding", + "shadowing", + ], + }, + grid: { + type: "object", + description: "Description of the grid used", + properties: { + manufacturer: { + type: "string", + description: "Grid manufacturer", + }, + material: { + type: "string", + description: "Material out of which the grid is made", + }, + mesh: { + type: "number", + description: "Grid mesh in lines per inch", + }, + film_support: { + type: "boolean", + description: "Whether a support film was used", + }, + film_material: { + type: "string", + description: "Type of material the support film is made of", + }, + film_topology: { + type: "string", + description: "Topology of the support film", + }, + film_thickness: { + type: "string", + description: "Thickness of the support film", + }, + pretreatment_type: { + type: "string", + description: "Type of pretreatment of the grid, i.e., glow discharge", + }, + pretreatment_time: { + type: "object", + description: "Length of time of the pretreatment in s", + }, + pretreatment_pressure: { + type: "object", + description: "Pressure of the chamber during pretreatment, in Pa", + }, + pretreatment_atmosphere: { + type: "string", + description: + "Atmospheric conditions in the chamber during pretreatment, i.e., addition of specific gases, etc.", + }, + }, + }, + }, + required: ["overall_molecule", "molecule", "specimen", "grid"], +}; + +export const demo_instrument_schema = { + type: "object", + properties: { + microscope: { + type: "string", + description: "Name/Type of the Microscope", + }, + illumination: { + type: "string", + description: "Mode of illumination used during data collection", + }, + imaging: { + type: "string", + description: "Mode of imaging used during data collection", + }, + electron_source: { + type: "string", + description: + "Type of electron source used in the microscope, such as FEG", + }, + acceleration_voltage: { + type: "object", + description: "Voltage used for the electron acceleration, in kV", + }, + c2_aperture: { + type: "object", + description: "C2 aperture size used in data acquisition, in µm", + }, + cs: { + type: "object", + description: "Spherical aberration of the instrument, in mm", + }, + }, + required: [ + "microscope", + "illumination", + "imaging", + "electron_source", + "acceleration_voltage", + "cs", + ], +}; + +export const demo_scicatheader_schema = { + type: "object", + properties: { + datasetName: { type: "string" }, + description: { type: "string" }, + creationLocation: { type: "string" }, + dataFormat: { type: "string" }, + ownerGroup: { type: "string" }, + type: { type: "string" }, + license: { type: "string" }, + keywords: { + type: "array", + items: { type: "string" }, + }, + scientificMetadata: { type: "string" }, + }, + required: [ + "datasetName", + "description", + "creationLocation", + "dataFormat", + "ownerGroup", + "type", + "license", + "keywords", + "scientificMetadata", + ], +}; diff --git a/src/app/ingestor/ingestor-metadata-editor/ingestor-metadata-editor.component.html b/src/app/ingestor/ingestor-metadata-editor/ingestor-metadata-editor.component.html new file mode 100644 index 0000000000..690d60cbf7 --- /dev/null +++ b/src/app/ingestor/ingestor-metadata-editor/ingestor-metadata-editor.component.html @@ -0,0 +1,9 @@ + diff --git a/src/app/ingestor/ingestor-metadata-editor/ingestor-metadata-editor.component.scss b/src/app/ingestor/ingestor-metadata-editor/ingestor-metadata-editor.component.scss new file mode 100644 index 0000000000..3d0b7f61da --- /dev/null +++ b/src/app/ingestor/ingestor-metadata-editor/ingestor-metadata-editor.component.scss @@ -0,0 +1,390 @@ +.ingestor-metadata-editor { + width: 100%; + font-family: + "Inter", + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; +} + +.spacer { + flex: 1 1 auto; +} + +// Modern glassmorphism card styling +mat-card { + .mat-mdc-card-title { + display: flex; + padding: 24px; + font-weight: 700; + font-size: 1.2em; + background: linear-gradient( + 135deg, + rgba(255, 255, 255, 0.1), + rgba(255, 255, 255, 0.05) + ); + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 16px 16px 0 0; + color: #1a1a1a; + letter-spacing: -0.02em; + } +} + +// Modern form sections with subtle shadows and gradients +.form-section { + margin-bottom: 32px; + padding: 32px; + background: linear-gradient(145deg, #ffffff, #f8fafc); + border-radius: 20px; + border: 1px solid rgba(226, 232, 240, 0.8); + box-shadow: + 0 1px 3px rgba(0, 0, 0, 0.05), + 0 10px 30px rgba(0, 0, 0, 0.02); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + + &:hover { + transform: translateY(-2px); + box-shadow: + 0 4px 12px rgba(0, 0, 0, 0.08), + 0 20px 40px rgba(0, 0, 0, 0.04); + } + + &:last-child { + margin-bottom: 0; + } +} + +// Bold, modern section headers +.section-header { + font-size: 1.25em; + font-weight: 800; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin-bottom: 24px; + padding-bottom: 12px; + border-bottom: 2px solid transparent; + border-image: linear-gradient(90deg, #667eea, #764ba2) 1; + letter-spacing: -0.03em; + + &.major-section { + font-size: 1.5em; + background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #d946ef 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin-bottom: 32px; + } +} + +// Elevated field groups with modern aesthetics +.field-group { + background: rgba(255, 255, 255, 0.7); + backdrop-filter: blur(20px); + border-radius: 16px; + padding: 24px; + margin-bottom: 20px; + border: 1px solid rgba(255, 255, 255, 0.2); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.04); + position: relative; + + &::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, #6366f1, #8b5cf6, #d946ef); + border-radius: 16px 16px 0 0; + } + + &.related-fields { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 20px; + align-items: start; + } +} + +// Ultra-modern form controls +.jsonforms-control { + margin-bottom: 24px; + + .mat-mdc-form-field { + width: 100%; + + .mat-mdc-text-field-wrapper { + border-radius: 12px; + background: rgba(255, 255, 255, 0.9); + backdrop-filter: blur(10px); + transition: all 0.3s ease; + + &:hover { + background: rgba(255, 255, 255, 0.95); + transform: translateY(-1px); + } + } + + &.mat-focused .mat-mdc-text-field-wrapper { + box-shadow: + 0 0 0 2px rgba(99, 102, 241, 0.2), + 0 8px 25px rgba(99, 102, 241, 0.15); + } + } + + label { + font-weight: 600; + color: #374151; + margin-bottom: 8px; + display: block; + font-size: 0.95em; + letter-spacing: -0.01em; + } +} + +// Modern grid layouts +.two-column-fields { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 24px; + margin-bottom: 24px; +} + +// Subtle dividers and spacers +.form-spacer { + height: 32px; +} + +.form-divider { + height: 1px; + background: linear-gradient(90deg, transparent, #e5e7eb, transparent); + margin: 40px 0; + position: relative; + + &::after { + content: ""; + position: absolute; + left: 50%; + top: -4px; + transform: translateX(-50%); + width: 8px; + height: 8px; + background: linear-gradient(135deg, #6366f1, #8b5cf6); + border-radius: 50%; + } +} + +// Futuristic array layouts +.array-layout { + display: flex; + flex-direction: column; + gap: 24px; + background: linear-gradient(145deg, #f8fafc, #f1f5f9); + border-radius: 20px; + padding: 24px; + border: 1px solid rgba(226, 232, 240, 0.6); + position: relative; + overflow: hidden; + + &::before { + content: ""; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 2px; + background: linear-gradient(90deg, transparent, #6366f1, transparent); + animation: shimmer 3s infinite; + } +} + +@keyframes shimmer { + 0% { + left: -100%; + } + 100% { + left: 100%; + } +} + +.array-layout > * { + flex: 1 1 auto; +} + +.array-layout-toolbar { + display: flex; + align-items: center; + padding-bottom: 16px; + border-bottom: 1px solid rgba(226, 232, 240, 0.6); + margin-bottom: 20px; +} + +.array-layout-title { + margin: 0; + font-weight: 700; + color: #1f2937; + font-size: 1.1em; + letter-spacing: -0.02em; +} + +.array-layout-toolbar > span { + flex: 1 1 auto; +} + +.array-item { + padding: 24px; + background: rgba(255, 255, 255, 0.9); + backdrop-filter: blur(20px); + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.3); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.06); + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); + + &:hover { + transform: translateY(-4px) scale(1.02); + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.1); + } +} + +// Modern toggle styling +.field-view-toggle { + margin-bottom: 32px; + display: block; + + .mat-slide-toggle-bar { + background: linear-gradient(135deg, #e5e7eb, #d1d5db); + } + + &.mat-checked .mat-slide-toggle-bar { + background: linear-gradient(135deg, #6366f1, #8b5cf6); + } +} + +// Enhanced error styling +::ng-deep .error-message-tooltip { + white-space: pre-line; + background: linear-gradient(135deg, #ef4444, #dc2626); + border-radius: 8px; +} + +// Modern input styling +.custom-input-box { + width: 100%; + + .mat-mdc-form-field { + .mat-mdc-text-field-wrapper { + background: rgba(255, 255, 255, 0.8); + backdrop-filter: blur(10px); + border-radius: 12px; + } + } +} + +// Futuristic quantity value boxes +.quantity-value-box { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 20px; + padding: 20px; + background: linear-gradient( + 145deg, + rgba(255, 255, 255, 0.9), + rgba(248, 250, 252, 0.9) + ); + backdrop-filter: blur(20px); + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.3); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.04); +} + +.quantity-value-element { + transition: transform 0.3s ease; + + &:hover { + transform: scale(1.02); + } +} + +// Modern tab styling +.mat-tab-content-renderer { + margin-top: 20px; +} + +// Cutting-edge step indicator +.step-indicator { + margin-bottom: 32px; + text-align: center; + + .step-title { + font-size: 2em; + font-weight: 900; + background: linear-gradient(135deg, #1f2937 0%, #4f46e5 50%, #7c3aed 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin-bottom: 12px; + letter-spacing: -0.04em; + } + + .step-description { + color: #6b7280; + font-size: 1em; + margin-bottom: 24px; + font-weight: 500; + } +} + +// Dark mode support +@media (prefers-color-scheme: dark) { + .form-section { + background: linear-gradient(145deg, #1f2937, #111827); + border-color: rgba(75, 85, 99, 0.3); + color: #f9fafb; + } + + .field-group { + background: rgba(17, 24, 39, 0.7); + border-color: rgba(75, 85, 99, 0.2); + } + + .jsonforms-control label { + color: #d1d5db; + } +} + +// Responsive modern design +@media (max-width: 768px) { + .form-section { + padding: 20px; + margin-bottom: 20px; + border-radius: 16px; + } + + .field-group.related-fields { + grid-template-columns: 1fr; + } + + .step-indicator .step-title { + font-size: 1.5em; + } +} + +// Micro-animations for enhanced UX +.jsonforms-control { + animation: fadeInUp 0.6s cubic-bezier(0.4, 0, 0.2, 1); +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/src/app/ingestor/ingestor-metadata-editor/ingestor-metadata-editor.component.spec.ts b/src/app/ingestor/ingestor-metadata-editor/ingestor-metadata-editor.component.spec.ts new file mode 100644 index 0000000000..035fc06c7c --- /dev/null +++ b/src/app/ingestor/ingestor-metadata-editor/ingestor-metadata-editor.component.spec.ts @@ -0,0 +1,52 @@ +import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing"; +import { IngestorMetadataEditorComponent } from "./ingestor-metadata-editor.component"; +import { NO_ERRORS_SCHEMA } from "@angular/core"; + +describe("IngestorMetadataEditorComponent", () => { + let component: IngestorMetadataEditorComponent; + let fixture: ComponentFixture; + + const mockSchema = { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + required: ["name"], + }; + + const mockData = { + name: "Test", + age: 25, + }; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + declarations: [IngestorMetadataEditorComponent], + }); + + TestBed.compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(IngestorMetadataEditorComponent); + component = fixture.componentInstance; + + component.data = mockData; + component.schema = mockSchema; + component.renderView = "all"; + + fixture.detectChanges(); + }); + + afterEach(() => { + if (fixture) { + fixture.destroy(); + } + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/ingestor/ingestor-metadata-editor/ingestor-metadata-editor.component.ts b/src/app/ingestor/ingestor-metadata-editor/ingestor-metadata-editor.component.ts new file mode 100644 index 0000000000..5192031d05 --- /dev/null +++ b/src/app/ingestor/ingestor-metadata-editor/ingestor-metadata-editor.component.ts @@ -0,0 +1,102 @@ +import { Component, EventEmitter, Output, Input, OnInit } from "@angular/core"; +import { JsonSchema } from "@jsonforms/core"; +import { + configuredRenderer, + IngestorMetadataEditorHelper, +} from "./ingestor-metadata-editor-helper"; + +export type renderView = "requiredOnly" | "all"; + +/* We need to jsonforms renderer here. If we change the view and we only use one instance, +it will produce errors when changing the schema. */ +@Component({ + selector: "app-metadata-editor", + styleUrls: ["./ingestor-metadata-editor.component.scss"], + template: ` + + + + `, + standalone: false, +}) +export class IngestorMetadataEditorComponent implements OnInit { + @Input() data: object; + @Input() schema: JsonSchema; + @Input() renderView: renderView; + + @Output() dataChange = new EventEmitter(); + @Output() errors = new EventEmitter(); + + visualData: object = {}; + reducedSchema: JsonSchema = {}; + editorInitialized = false; + + ngOnInit() { + this.updateVisualData(); + + this.reducedSchema = + IngestorMetadataEditorHelper.reduceToRequiredProperties(this.schema); + + this.editorInitialized = true; + } + + updateVisualData() { + // Do a deep clone + this.visualData = structuredClone(this.data); + // Update the data with the same keys as the schema, including nested properties + // This is necessary, otherwise the error checks will not work correctly + // Fill empty boolean with false to avoid undefined errors + const initializeVisualData = (schema: JsonSchema, target: any) => { + if (!schema.properties || Object.keys(schema.properties).length === 0) { + return; + } + Object.keys(schema.properties).forEach((key) => { + const property = schema.properties[key]; + if (property.type === "object") { + if (target[key] === undefined || target[key] === null) { + target[key] = {}; + } + initializeVisualData(property, target[key]); + } else if (property.type === "boolean") { + if (target[key] === undefined || target[key] === null) { + target[key] = false; + } + } + }); + }; + + if (this.schema !== undefined && this.data !== undefined) { + initializeVisualData(this.schema, this.visualData); + } + } + + get combinedRenderers() { + return configuredRenderer; + } + + onDataChange(event: any) { + this.dataChange.emit(event); + } + + onErrors(errors: any[]) { + this.errors.emit(errors); + } + + hasErrors(): boolean { + return this.errors.length > 0; + } +} diff --git a/src/app/ingestor/ingestor-page/helper/ingestor.component-helper.ts b/src/app/ingestor/ingestor-page/helper/ingestor.component-helper.ts new file mode 100644 index 0000000000..34ef263201 --- /dev/null +++ b/src/app/ingestor/ingestor-page/helper/ingestor.component-helper.ts @@ -0,0 +1,447 @@ +import { JsonSchema, JsonSchema7 } from "@jsonforms/core"; +import { CreateRawDatasetObsoleteDto } from "@scicatproject/scicat-sdk-ts-angular"; +import { isArray } from "mathjs"; +import { PostDatasetResponse } from "shared/sdk/models/ingestor/postDatasetResponse"; +import { UserInfo } from "shared/sdk/models/ingestor/userInfo"; + +export type IngestorMode = "transfer" | "creation"; + +export type IngestorEditorMode = "INGESTION" | "EDITOR" | "CREATION"; + +export interface IngestorAutodiscovery { + mailDomain: string; + description?: string; + facilityBackend: string; +} + +export interface ExtractionMethod { + name: string; + schema: string; // Base64 encoded JSON schema +} + +export interface APIInformation { + extractMetaDataRequested: boolean; + extractorMetaDataReady: boolean; + metaDataExtractionFailed: boolean; + extractorMetadataProgress: number; + extractorMetaDataStatus: string; + ingestionRequestErrorMessage: string; + ingestionDatasetLoading: boolean; +} + +export interface IngestionRequestInformation { + selectedPath: string; + selectedMethod: ExtractionMethod; + selectedResolvedDecodedSchema: JsonSchema; + scicatHeader: object; + userMetaData: { + organizational: object; + sample: object; + }; + extractorMetaData: { + instrument: object; + acquisition: object; + }; + + mergedMetaDataString: string; + + editorMode: IngestorEditorMode; + + ingestionRequest: PostDatasetResponse | null; + autoArchive: boolean; + // Custom metadata in creation mode + schemaUrl?: string; + selectedSchemaFileContent?: string; + customMetaData: object; +} + +export interface ScientificMetadata { + organizational: object; + sample: object; + acquisition: object; + instrument: object; +} + +export interface DialogDataObject { + createNewTransferData: IngestionRequestInformation; + userInfo: UserInfo; + backendURL: string; + onClickNext: (step: number) => void; + onStartUpload: () => Promise; +} + +export class IngestorHelper { + static createMetaDataString( + transferData: IngestionRequestInformation, + useCustomMetadata?: boolean, + ): string { + const space = 2; + const scicatMetadata: CreateRawDatasetObsoleteDto = { + ...(transferData.scicatHeader as CreateRawDatasetObsoleteDto), + }; + + if (useCustomMetadata) { + scicatMetadata.scientificMetadata = { ...transferData.customMetaData }; + } else { + // EM DATA INGESTOR MODE + scicatMetadata.scientificMetadata = { + ...{ + organizational: transferData.userMetaData["organizational"], + sample: transferData.userMetaData["sample"], + acquisition: transferData.extractorMetaData["acquisition"], + instrument: transferData.extractorMetaData["instrument"], + }, + }; + } + + return JSON.stringify(scicatMetadata, null, space); + } + + static saveConnectionsToLocalStorage = (connections: string[]) => { + // Remove duplicates + const uniqueConnections = Array.from(new Set(connections)); + const connectionsString = JSON.stringify(uniqueConnections); + localStorage.setItem("ingestorConnections", connectionsString); + }; + + static loadConnectionsFromLocalStorage = (): string[] => { + const connectionsString = localStorage.getItem("ingestorConnections"); + if (connectionsString) { + const connections = JSON.parse(connectionsString); + return connections; + } + return []; + }; + + static createEmptyRequestInformation = (): IngestionRequestInformation => { + return { + selectedPath: "", + selectedMethod: { name: "", schema: "" }, + selectedResolvedDecodedSchema: {}, + scicatHeader: {}, + userMetaData: { + organizational: {}, + sample: {}, + }, + extractorMetaData: { + instrument: {}, + acquisition: {}, + }, + customMetaData: {}, + mergedMetaDataString: "", + editorMode: "INGESTION", + ingestionRequest: null, + autoArchive: true, + + schemaUrl: "", + selectedSchemaFileContent: "", + }; + }; + + static createEmptyAPIInformation = (): APIInformation => { + return { + metaDataExtractionFailed: false, + extractMetaDataRequested: false, + extractorMetaDataReady: false, + extractorMetadataProgress: 0, + extractorMetaDataStatus: "", + ingestionRequestErrorMessage: "", + ingestionDatasetLoading: false, + }; + }; + + static mergeUserAndExtractorMetadata( + userMetadata: object, + extractorMetadata: object, + space: number, + ): string { + return JSON.stringify( + { ...userMetadata, ...extractorMetadata }, + null, + space, + ); + } +} + +export const isBase64 = (str: string) => { + try { + return btoa(atob(str)) === str; + } catch { + return false; + } +}; + +export const decodeBase64ToUTF8 = (base64: string) => { + const text = atob(base64); + const length = text.length; + const bytes = new Uint8Array(length); + for (let i = 0; i < length; i++) { + bytes[i] = text.charCodeAt(i); + } + const decoder = new TextDecoder(); // default is utf-8 + return decoder.decode(bytes); +}; + +export const getJsonSchemaFromDto = (sourceFolderEditable?: boolean) => { + // Currently there is no valid schema which can be used. So we create one from the dataset class. + // --string => string + // --mail => string with regex + // --readonly => string with readonly + // --fqdn => string with regex + // --skip => skip + // --dateTime => string with dateTime format + // --number => number + // --boolean => boolean + // --optional => optional + + // 0 => number + // -1 => skip number + // -2 => optional number + const emptyDatasetForSchema: CreateRawDatasetObsoleteDto = { + ownerGroup: "--string", + accessGroups: [], + isPublished: false, + pid: "--skip", + owner: "--string", + contactEmail: "--mail", + sourceFolder: sourceFolderEditable ? "--string" : "--string --readonly", + size: -1, // skip + numberOfFiles: -1, // skip + creationTime: "--dateTime", + type: "raw", + datasetName: "--string", + creationLocation: "--string", + + // Optional fields + description: "--string --optional", + license: "--string --optional", + keywords: [], + principalInvestigator: "--string", // skip [], + scientificMetadata: {}, + ownerEmail: "--mail --optional", + + instrumentGroup: "--optional", + orcidOfOwner: "--optional", + sourceFolderHost: "--fqdn --optional", + packedSize: -1, // skip + numberOfFilesArchived: -1, // skip + validationStatus: "--string --optional", + classification: "--string --optional", + comment: "--string --optional", + dataQualityMetrics: -2, // optional + startTime: "--dateTime --optional", + endTime: "--dateTime --optional", + dataFormat: "--string --optional", + runNumber: "--optional", + datasetlifecycle: undefined, + + proposalId: "--string --optional", + sampleId: "--string --optional", + instrumentId: "--string --optional", + inputDatasets: [], + usedSoftware: [], + jobLogData: "--string --optional", + }; + + const descriptionMatrix = { + createdBy: + "Indicate the user who created this record. This property is added and maintained by the system.", + updatedBy: + "Indicate the user who updated this record last. This property is added and maintained by the system.", + createdAt: + "Date and time when this record was created. This field is managed by mongoose with through the timestamp settings. The field should be a string containing a date in ISO 8601 format (2024-02-27T12:26:57.313Z).", + updatedAt: + "Date and time when this record was updated last. This field is managed by mongoose with through the timestamp settings. The field should be a string containing a date in ISO 8601 format (2024-02-27T12:26:57.313Z).", + ownerGroup: + "Defines the group which owns the data, and therefore has unrestricted access to this data. Usually a pgroup like p12151.", + accessGroups: + "Optional additional groups which have read access to the data. Users which are members in one of the groups listed here are allowed to access this data. The special group 'public' makes data available to all users.", + instrumentGroup: + "Optional additional groups which have read and write access to the data. Users which are members in one of the groups listed here are allowed to access this data.", + isPublished: "Flag is true when data are made publicly available.", + pid: "Persistent Identifier for datasets derived from UUIDv4 and prepended automatically by site specific PID prefix like 20.500.12345/.", + owner: + "Owner or custodian of the dataset, usually first name + last name. The string may contain a list of persons, which should then be separated by semicolons.", + ownerEmail: + "Email of the owner or custodian of the dataset. The string may contain a list of emails, which should then be separated by semicolons.", + orcidOfOwner: + "ORCID of the owner or custodian. The string may contain a list of ORCIDs, which should then be separated by semicolons.", + contactEmail: + "Email of the contact person for this dataset. The string may contain a list of emails, which should then be separated by semicolons.", + sourceFolder: + "Absolute file path on file server containing the files of this dataset, e.g. /some/path/to/sourcefolder. In case of a single file dataset, e.g. HDF5 data, it contains the path up to, but excluding the filename. Trailing slashes are removed.", + sourceFolderHost: + "DNS host name of file server hosting sourceFolder, optionally including a protocol e.g. [protocol://]fileserver1.example.com.", + size: "Total size of all source files contained in source folder on disk when unpacked.", + packedSize: + "Total size of all datablock package files created for this dataset.", + numberOfFiles: + "Total number of files in all OrigDatablocks for this dataset.", + numberOfFilesArchived: + "Total number of files in all Datablocks for this dataset.", + creationTime: + "Time when dataset became fully available on disk, i.e. all containing files have been written, or the dataset was created in SciCat. It is expected to be in ISO8601 format according to specifications for internet date/time format in RFC 3339, chapter 5.6.", + type: "Characterize type of dataset, either 'raw' or 'derived'. Autofilled when choosing the proper inherited models.", + validationStatus: + "Defines a level of trust, e.g. a measure of how much data was verified or used by other persons.", + keywords: + "Array of tags associated with the meaning or contents of this dataset. Values should ideally come from defined vocabularies, taxonomies, ontologies or knowledge graphs.", + description: "Free text explanation of contents of dataset.", + datasetName: + "A name for the dataset, given by the creator to carry some semantic meaning. Useful for display purposes e.g. instead of displaying the pid.", + classification: + "ACIA information about AUthenticity,COnfidentiality,INtegrity and AVailability requirements of dataset. E.g. AV(ailabilty)=medium could trigger the creation of a two tape copies.", + license: "Name of the license under which the data can be used.", + version: + "Version of the API used when the dataset was created or last updated. API version is defined in code for each release. Managed by the system.", + history: "List of objects containing old and new values.", + datasetlifecycle: + "Describes the current status of the dataset during its lifetime with respect to the storage handling systems.", + techniques: "Array of techniques information, with technique name and pid.", + relationships: + "Array of relationships with other datasets. It contains relationship type and destination dataset.", + sharedWith: + "List of additional users that the dataset has been shared with.", + scientificMetadata: "JSON object containing the scientific metadata.", + comment: + "Short comment provided by the user about a given dataset. This is additional to the description field.", + dataQualityMetrics: + "Data Quality Metrics given by the user to rate the dataset.", + principalInvestigator: + "First name and last name of principal investigator(s). If multiple PIs are present, use a semicolon separated list. This field is required if the dataset is a Raw dataset.", + startTime: + "Start time of data acquisition for the current dataset. It is expected to be in ISO8601 format according to specifications for internet date/time format in RFC 3339, chapter 5.6.", + endTime: + "End time of data acquisition for the current dataset. It is expected to be in ISO8601 format according to specifications for internet date/time format in RFC 3339, chapter 5.6.", + creationLocation: + "Unique location identifier where data was acquired. Usually in the form /Site-name/facility-name/instrumentOrBeamline-name.", + dataFormat: + "Defines the format of the data files in this dataset, e.g Nexus Version x.y.", + runNumber: + "Run number assigned by the system to the data acquisition for the current dataset.", + proposalIds: + "The ID of the proposal to which the dataset belongs to and it has been acquired under.", + sampleIds: + "Single ID or array of IDS of the samples used when collecting the data.", + instrumentIds: + "Id of the instrument or array of IDS of the instruments where the data contained in this dataset was created/acquired.", + inputDatasets: + "Array of input dataset identifiers used in producing the derived dataset. Ideally these are the global identifier to existing datasets inside this or federated data catalogs.", + usedSoftware: + "A list of links to software repositories which uniquely identifies the pieces of software, including versions, used for yielding the derived data.", + jobParameters: + "The creation process of the derived data will usually depend on input job parameters. The full structure of these input parameters are stored here.", + jobLogData: + "The output job logfile. Keep the size of this log data well below 15 MB.", + proposalId: "The ID of the proposal to which the dataset belongs.", + sampleId: "ID of the sample used when collecting the data.", + instrumentId: "ID of the instrument where the data was created.", + }; + + const schema = { + type: "object", + properties: {}, + required: [], + } as JsonSchema7; + + const regExMailFormat = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; + const regExISO8601 = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; + const regExFQDN = /^(?!:\/\/)([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(:\d+)?(\/.*)?$/; + const regExDatasetType = /raw|derived/; + + for (const key in emptyDatasetForSchema) { + if (Object.prototype.hasOwnProperty.call(emptyDatasetForSchema, key)) { + const value = (emptyDatasetForSchema as any)[key]; + + // Decide type and add to schema + if (typeof value === "string") { + if (value.includes("--skip")) { + continue; // Skip this field + } + schema.properties[key] = { type: "string" }; + + // Depending on the value, add regex or format + if (value.includes("--string")) { + schema.properties[key].type = "string"; + } + if (value.includes("--mail")) { + schema.properties[key].type = "string"; + schema.properties[key].pattern = regExMailFormat.source; + } + if (value.includes("--readonly")) { + schema.properties[key].type = "string"; + schema.properties[key].readOnly = true; + } + if (value.includes("--dateTime")) { + schema.properties[key].type = "string"; + schema.properties[key].format = "date-time"; + schema.properties[key].pattern = regExISO8601.source; + } + if (value.includes("--fqdn")) { + schema.properties[key].type = "string"; + schema.properties[key].pattern = regExFQDN.source; + } + if (value.includes("--optional")) { + schema.properties[key].type = "string"; + } + if (value.includes("--type")) { + schema.properties[key].type = "string"; + schema.properties[key].format = "date-time"; + schema.properties[key].pattern = regExDatasetType.source; + } + } else if (typeof value === "number") { + // If -1 skip + if (value === -1) { + continue; // Skip this field + } + schema.properties[key] = { type: "number" }; + } else if (typeof value === "boolean") { + schema.properties[key] = { type: "boolean" }; + } else if (Array.isArray(value)) { + schema.properties[key] = { type: "array" }; + schema.properties[key].items = { type: "string" }; + } else { + continue; // Skip unsupported types + } + + // Add the description from the description matrix to the schema + if (descriptionMatrix[key]) { + schema.properties[key].description = descriptionMatrix[key]; + } + + // Add to required fields if it is not optional + // When string without --optional or --skip, when number > 0, the rest always required + if (typeof value === "string") { + if ( + value.includes("--string") && + !value.includes("--optional") && + !value.includes("--skip") + ) { + schema.required.push(key); + } + } else if (typeof value === "number") { + if (value > 0) { + schema.required.push(key); + } + } else if (isArray(value)) { + // For arrays, if the array is not empty, add to required fields + if (value.length > 0) { + schema.required.push(key); + } + } else { + // For all other types, add to required fields + schema.required.push(key); + } + } + } + + // Sort the keys in the schema alphabetically + schema.properties = Object.fromEntries( + Object.entries(schema.properties).sort(([keyA], [keyB]) => + keyA.localeCompare(keyB), + ), + ); + + return schema; +}; diff --git a/src/app/ingestor/ingestor-page/helper/ingestor.metadata-sse-service.ts b/src/app/ingestor/ingestor-page/helper/ingestor.metadata-sse-service.ts new file mode 100644 index 0000000000..befedd69a7 --- /dev/null +++ b/src/app/ingestor/ingestor-page/helper/ingestor.metadata-sse-service.ts @@ -0,0 +1,118 @@ +import { Injectable } from "@angular/core"; +import { Subject } from "rxjs"; +import { decodeBase64ToUTF8, isBase64 } from "./ingestor.component-helper"; + +interface IngestorMetadataEvent { + message: string; + progress: number; + result: boolean; + resultMessage: string; + error: boolean; +} + +interface ProgressMessage { + result?: string; + err?: string; + std_err: string; + std_out: string; +} + +@Injectable({ + providedIn: "root", +}) +export class IngestorMetadataSSEService { + private eventSource: EventSource; + private messageSubject: Subject = + new Subject(); + + constructor() { + // Close previous connection if it exists + this.disconnect(); + } + + destroy(): void { + this.disconnect(); + } + + public connect(url: string, withCredentials = true): void { + // Close previous connection if it exists + this.disconnect(); + + this.eventSource = new EventSource(url, { withCredentials }); + + this.eventSource.onmessage = (event) => { + const messageData = event.data; + this.messageSubject.next({ + message: messageData, + progress: 0, + result: false, + resultMessage: "", + error: false, + }); + }; + + this.eventSource.addEventListener("progress", (event) => { + // Decode from base64 and parse JSON + // Check if event is a valid base64 string + + let eventData = event.data; + if (isBase64(event.data.replace(/"/g, ""))) { + eventData = decodeBase64ToUTF8(event.data.replace(/"/g, "")); + } + + const data = JSON.parse(eventData) as ProgressMessage; + + const progressMessage = data.std_out.toLowerCase(); + let progress = 0; + // Check if the string "progress" is present in the message + if (progressMessage.includes("progress")) { + // Find all occurrences of "progress" followed by a number, use the last one + const progressRegex = /progress[^\d]*(?[\d.,]+)/gi; + let match: RegExpExecArray | null; + let lastNumber = "0"; + while ((match = progressRegex.exec(progressMessage)) !== null) { + if (match.groups?.number) { + lastNumber = match.groups.number; + } + } + + progress = parseFloat(lastNumber.replace(",", ".")); + progress = isNaN(progress) ? 0 : progress; + } + + this.messageSubject.next({ + message: data.err ?? data.std_out, + progress: progress, + result: data.result !== undefined, + resultMessage: data.result !== undefined ? data.result : "", + error: data.err !== undefined, + }); + // Check if data contains result then close connection + if (data.result || data.err !== undefined) { + this.disconnect(); + } + }); + + this.eventSource.onerror = (error) => { + console.error("SSE Error:", error); + this.eventSource.close(); + this.messageSubject.next({ + message: "An error occurred while extracting metadata.", + progress: 0, + result: false, + resultMessage: "", + error: true, + }); + }; + } + + public getMessages(): Subject { + return this.messageSubject; + } + + public disconnect(): void { + if (this.eventSource) { + this.eventSource.close(); + } + } +} diff --git a/src/app/ingestor/ingestor-page/ingestor-creation.component.html b/src/app/ingestor/ingestor-page/ingestor-creation.component.html new file mode 100644 index 0000000000..fb4df12052 --- /dev/null +++ b/src/app/ingestor/ingestor-page/ingestor-creation.component.html @@ -0,0 +1,48 @@ +

+ + + Log in + + +

+ Before a dataset creation can be started, please + log in to SciCat. +

+ login + + +

+ +
+

+ + + Ingestor - Dataset creation mode + + + You can use the Ingestor to prepare SciCat datasets. The Ingestor + provides a simple editor to fill in metadata and scientific metadata and + then create a dataset envelope. The data upload must be performed + manually in this mode. + + +

+ +

+ + + New dataset + + + + + +

+
diff --git a/src/app/ingestor/ingestor-page/ingestor-creation.component.spec.ts b/src/app/ingestor/ingestor-page/ingestor-creation.component.spec.ts new file mode 100644 index 0000000000..843fa8c5d0 --- /dev/null +++ b/src/app/ingestor/ingestor-page/ingestor-creation.component.spec.ts @@ -0,0 +1,91 @@ +import { IngestorCreationComponent } from "./ingestor-creation.component"; +import { IngestorHelper } from "./helper/ingestor.component-helper"; +import * as fromActions from "state-management/actions/ingestor.actions"; +import { MatCardModule } from "@angular/material/card"; +import { MatListModule } from "@angular/material/list"; +import { MatIconModule } from "@angular/material/icon"; +import { MockActivatedRoute, MockUserApi } from "shared/MockStubs"; +import { Store, StoreModule } from "@ngrx/store"; +import { MockStore } from "@ngrx/store/testing"; +import { Router, ActivatedRoute } from "@angular/router"; +import { + ComponentFixture, + inject, + TestBed, + waitForAsync, +} from "@angular/core/testing"; +import { AppConfigService } from "app-config.service"; +import { UsersService } from "@scicatproject/scicat-sdk-ts-angular"; + +describe("IngestorCreationComponent", () => { + let component: IngestorCreationComponent; + let fixture: ComponentFixture; + let store: MockStore; + + const router = { + navigateByUrl: jasmine.createSpy("navigateByUrl"), + }; + const getConfig = () => ({ + ingestorEnabled: false, + }); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [IngestorCreationComponent], + imports: [ + MatCardModule, + MatListModule, + MatIconModule, + StoreModule.forRoot({}), + ], + }); + TestBed.overrideComponent(IngestorCreationComponent, { + set: { + providers: [ + { provide: Router, useValue: router }, + { + provide: AppConfigService, + useValue: { + getConfig, + }, + }, + { provide: ActivatedRoute, useClass: MockActivatedRoute }, + { provide: UsersService, useClass: MockUserApi }, + ], + }, + }); + TestBed.compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(IngestorCreationComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + beforeEach(inject([Store], (mockStore: MockStore) => { + store = mockStore; + })); + + afterEach(() => { + fixture.destroy(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should create empty transfer object with CREATION mode", () => { + spyOn(store, "dispatch"); + + component.onClickAddIngestion(); + + // Verify the actual object structure + const expectedObject = IngestorHelper.createEmptyRequestInformation(); + expectedObject.editorMode = "CREATION"; + + expect(store.dispatch).toHaveBeenCalledWith( + fromActions.resetIngestionObject({ ingestionObject: expectedObject }), + ); + }); +}); diff --git a/src/app/ingestor/ingestor-page/ingestor-creation.component.ts b/src/app/ingestor/ingestor-page/ingestor-creation.component.ts new file mode 100644 index 0000000000..3ca9dea365 --- /dev/null +++ b/src/app/ingestor/ingestor-page/ingestor-creation.component.ts @@ -0,0 +1,41 @@ +import { Component, inject, OnInit } from "@angular/core"; +import { Store } from "@ngrx/store"; +import { selectIsLoggedIn } from "state-management/selectors/user.selectors"; +import * as fromActions from "state-management/actions/ingestor.actions"; +import { IngestorHelper } from "./helper/ingestor.component-helper"; +import { MatDialog } from "@angular/material/dialog"; +import { IngestorCreationDialogBaseComponent } from "ingestor/ingestor-dialogs/creation-dialog/ingestor.creation-dialog-base.component"; + +@Component({ + selector: "ingestor-creation", + styleUrls: ["./ingestor.component.scss"], + templateUrl: "./ingestor-creation.component.html", + standalone: false, +}) +export class IngestorCreationComponent { + sciCatLoggedIn$ = this.store.select(selectIsLoggedIn); + readonly dialog = inject(MatDialog); + + constructor(private store: Store) {} + + onClickAddIngestion(): void { + // Clean the current ingestion object + const newTransferObject = IngestorHelper.createEmptyRequestInformation(); + newTransferObject.editorMode = "CREATION"; + + this.store.dispatch( + fromActions.resetIngestionObject({ ingestionObject: newTransferObject }), + ); + + this.dialog.closeAll(); + + let dialogRef = null; + dialogRef = this.dialog.open(IngestorCreationDialogBaseComponent, { + disableClose: true, + width: "75vw", + }); + + // Error if the dialog reference is not set + if (dialogRef === null) return; + } +} diff --git a/src/app/ingestor/ingestor-page/ingestor-transfer.component.html b/src/app/ingestor/ingestor-page/ingestor-transfer.component.html new file mode 100644 index 0000000000..2dd13318ac --- /dev/null +++ b/src/app/ingestor/ingestor-page/ingestor-transfer.component.html @@ -0,0 +1,251 @@ +

+ + + Log in + + +

+ Before a transfer can be started, please + log in to SciCat. +

+ login + + +

+ +
+

+ + + Control center + + +

+ +
+
+ +

No Backend connected

+ +

Please provide a valid Backend URL

+ + + + + +
+

Last used facility backends:

+ +
+
+ +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID + {{ element.transferId }} + Status + {{ element.status ?? "" }} + Message + {{ element.message ?? "" }} + + Progress + + + + Action + + +
+ + + +
+ + + + + + Backend URL + {{ connectedFacilityBackend }} + change + + + Connection Status + Connected + + + Version + {{ + versionInfo ? (versionInfo.version ?? "") : "" + }} + + + Health Status + {{ + healthInfo ? (healthInfo.status ?? "") : "" + }} + + + +
+

+
+
+ + +

+ +

+ + + New transfer + + + + + +

+
diff --git a/src/app/ingestor/ingestor-page/ingestor-transfer.component.spec.ts b/src/app/ingestor/ingestor-page/ingestor-transfer.component.spec.ts new file mode 100644 index 0000000000..adb6bd3b67 --- /dev/null +++ b/src/app/ingestor/ingestor-page/ingestor-transfer.component.spec.ts @@ -0,0 +1,71 @@ +import { IngestorTransferComponent } from "./ingestor-transfer.component"; +import { MatCardModule } from "@angular/material/card"; +import { MatListModule } from "@angular/material/list"; +import { MockActivatedRoute, MockUserApi } from "shared/MockStubs"; +import { Store, StoreModule } from "@ngrx/store"; +import { NO_ERRORS_SCHEMA } from "@angular/core"; +import { MockStore } from "@ngrx/store/testing"; +import { Router, ActivatedRoute } from "@angular/router"; +import { + ComponentFixture, + inject, + TestBed, + waitForAsync, +} from "@angular/core/testing"; +import { AppConfigService } from "app-config.service"; +import { UsersService } from "@scicatproject/scicat-sdk-ts-angular"; + +describe("IngestorTransferComponent", () => { + let component: IngestorTransferComponent; + let fixture: ComponentFixture; + let store: MockStore; + + const router = { + navigateByUrl: jasmine.createSpy("navigateByUrl"), + }; + + const getConfig = () => ({ + ingestorEnabled: true, + }); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + declarations: [IngestorTransferComponent], + imports: [MatCardModule, MatListModule, StoreModule.forRoot({})], + }); + TestBed.overrideComponent(IngestorTransferComponent, { + set: { + providers: [ + { provide: Router, useValue: router }, + { + provide: AppConfigService, + useValue: { + getConfig, + }, + }, + { provide: ActivatedRoute, useClass: MockActivatedRoute }, + { provide: UsersService, useClass: MockUserApi }, + ], + }, + }); + TestBed.compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(IngestorTransferComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + beforeEach(inject([Store], (mockStore: MockStore) => { + store = mockStore; + })); + + afterEach(() => { + fixture.destroy(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/ingestor/ingestor-page/ingestor-transfer.component.ts b/src/app/ingestor/ingestor-page/ingestor-transfer.component.ts new file mode 100644 index 0000000000..27f48677cd --- /dev/null +++ b/src/app/ingestor/ingestor-page/ingestor-transfer.component.ts @@ -0,0 +1,377 @@ +import { Component, inject, OnDestroy, OnInit } from "@angular/core"; +import { ActivatedRoute, Router } from "@angular/router"; +import { MatDialog } from "@angular/material/dialog"; +import { + IngestionRequestInformation, + IngestorAutodiscovery, + IngestorHelper, +} from "./helper/ingestor.component-helper"; +import { + UserInfo, + OtherHealthResponse, + OtherVersionResponse, + GetTransferResponse, +} from "shared/sdk/models/ingestor/models"; +import { PageChangeEvent } from "shared/modules/table/table.component"; +import { Store } from "@ngrx/store"; +import { + selectIsLoggedIn, + selectUserSettingsPageViewModel, +} from "state-management/selectors/user.selectors"; +import { fetchCurrentUserAction } from "state-management/actions/user.actions"; +import * as fromActions from "state-management/actions/ingestor.actions"; +import { + selectIngestorStatus, + selectIngestorAuth, + selectIngestorConnecting, + selectIngestorEndpoint, + selectIngestorTransferList, + selectIngestorTransferListRequestOptions, +} from "state-management/selectors/ingestor.selectors"; +import { IngestorCreationDialogBaseComponent } from "ingestor/ingestor-dialogs/creation-dialog/ingestor.creation-dialog-base.component"; +import { INGESTOR_API_ENDPOINTS_V1 } from "shared/sdk/apis/ingestor.service"; +import { Subscription } from "rxjs"; +import { IngestorConfirmationDialogComponent } from "ingestor/ingestor-dialogs/confirmation-dialog/ingestor.confirmation-dialog.component"; +import { IngestorTransferViewDialogComponent } from "ingestor/ingestor-dialogs/transfer-detail-view/ingestor.transfer-detail-view-dialog.component"; +import { fetchScicatTokenAction } from "state-management/actions/user.actions"; +import { AppConfigService } from "app-config.service"; + +@Component({ + selector: "ingestor-transfer", + templateUrl: "./ingestor-transfer.component.html", + styleUrls: ["./ingestor.component.scss"], + standalone: false, +}) +export class IngestorTransferComponent implements OnInit, OnDestroy { + private subscriptions: Subscription[] = []; + readonly dialog = inject(MatDialog); + appConfig = this.appConfigService.getConfig(); + + vm$ = this.store.select(selectUserSettingsPageViewModel); + sciCatLoggedIn$ = this.store.select(selectIsLoggedIn); + ingestorStatus$ = this.store.select(selectIngestorStatus); + ingestorAuthInfo$ = this.store.select(selectIngestorAuth); + ingestorConnecting$ = this.store.select(selectIngestorConnecting); + ingestorBackend$ = this.store.select(selectIngestorEndpoint); + transferList$ = this.store.select(selectIngestorTransferList); + selectIngestorTransferListRequestOptions$ = this.store.select( + selectIngestorTransferListRequestOptions, + ); + + sourceFolder = ""; + forwardFacilityBackend = ""; + selectedTab = 1; + + connectedFacilityBackend = ""; + connectingToFacilityBackend = true; + noRightsError = false; + + lastUsedFacilityBackends: string[] = []; + + transferDataInformation: GetTransferResponse = null; + transferDataPageSize = 100; + transferDataPageIndex = 0; + transferDataPageSizeOptions = [5, 10, 25, 100]; + displayedColumns: string[] = [ + "transferId", + "status", + "message", + "progress", + "actions", + ]; + + versionInfo: OtherVersionResponse = null; + userInfo: UserInfo | null = null; + scicatUserProfile: any = null; + authIsDisabled = false; + healthInfo: OtherHealthResponse = null; + tokenValue = ""; + + createNewTransferData: IngestionRequestInformation = + IngestorHelper.createEmptyRequestInformation(); + + constructor( + private route: ActivatedRoute, + private router: Router, + private store: Store, + public appConfigService: AppConfigService, + ) {} + + ngOnInit() { + this.lastUsedFacilityBackends = + IngestorHelper.loadConnectionsFromLocalStorage(); + + // Fetch the API token that the ingestor can authenticate to scicat as the user + this.subscriptions.push( + this.vm$.subscribe((settings) => { + this.scicatUserProfile = settings.profile; + this.tokenValue = settings.scicatToken; + + if (this.tokenValue === "") { + this.store.dispatch(fetchScicatTokenAction()); + } + }), + ); + + this.subscriptions.push( + this.ingestorBackend$.subscribe((ingestorBackend) => { + if (ingestorBackend !== null && ingestorBackend !== undefined) { + this.connectedFacilityBackend = ingestorBackend.facilityBackend; + } + }), + ); + + this.subscriptions.push( + this.transferList$.subscribe((transferList) => { + if (transferList !== null && transferList !== undefined) { + this.transferDataInformation = transferList; + } + }), + ); + + this.subscriptions.push( + this.selectIngestorTransferListRequestOptions$.subscribe( + (requestOptions) => { + if (requestOptions !== null && requestOptions !== undefined) { + this.transferDataPageIndex = requestOptions.page; + this.transferDataPageSize = requestOptions.pageNumber; + } + }, + ), + ); + + this.subscriptions.push( + this.ingestorConnecting$.subscribe((connecting) => { + this.connectingToFacilityBackend = connecting; + }), + ); + + this.subscriptions.push( + this.ingestorStatus$.subscribe((ingestorStatus) => { + if ( + ingestorStatus.validEndpoint !== null && + !ingestorStatus.validEndpoint + ) { + this.connectedFacilityBackend = ""; + this.lastUsedFacilityBackends = + IngestorHelper.loadConnectionsFromLocalStorage(); + this.onClickForwardToIngestorPage(); + } else if ( + ingestorStatus.versionResponse && + ingestorStatus.healthResponse + ) { + this.versionInfo = ingestorStatus.versionResponse; + this.healthInfo = ingestorStatus.healthResponse; + } + }), + ); + + this.subscriptions.push( + this.ingestorAuthInfo$.subscribe((authInfo) => { + if (authInfo) { + this.userInfo = authInfo.userInfoResponse; + this.authIsDisabled = authInfo.authIsDisabled; + + // Only refresh if the user is logged in or the auth is disabled + if (this.authIsDisabled || this.userInfo.logged_in) { + // Activate Transfer Tab when ingestor is ready for actions + this.selectedTab = 0; + this.doRefreshTransferList(); + } // In case of loosing the connection to the ingestor, the user is logged out + else if (this.userInfo == null || this.userInfo.logged_in === false) { + this.selectedTab = 1; + } + } + }), + ); + + this.loadIngestorConfiguration(); + this.store.dispatch(fetchCurrentUserAction()); + } + + ngOnDestroy(): void { + // Unsubscribe from all subscriptions + this.subscriptions.forEach((subscription) => subscription.unsubscribe()); + } + + async loadIngestorConfiguration(): Promise { + // Get the GET parameter 'backendUrl' from the URL + this.subscriptions.push( + this.route.queryParams.subscribe(async (params) => { + const backendUrl = params["backendUrl"]; + const discovery = params["discovery"]; + let facility = null; + if (discovery === "true") { + facility = await this.getFacilityByUserInfo(); + if (facility != null && !backendUrl) { + this.router.navigate([], { + relativeTo: this.route, + queryParams: { + backendUrl: facility.facilityBackend, + discovery: "true", + }, + queryParamsHandling: "merge", + }); + return; + } + } + + if (backendUrl) { + // backendUrl should not end with a slash + const facilityBackendUrlCleaned = backendUrl.replace(/\/$/, ""); + this.store.dispatch( + fromActions.setIngestorEndpoint({ + ingestorEndpoint: { + mailDomain: "", + facilityBackend: facilityBackendUrlCleaned, + description: facility?.description ?? "", + }, + }), + ); + + this.store.dispatch(fromActions.connectIngestor()); + } else { + this.connectingToFacilityBackend = false; + } + }), + ); + } + + onClickForwardToIngestorPage(nextFacilityBackend?: string) { + if (nextFacilityBackend) { + IngestorHelper.saveConnectionsToLocalStorage([ + ...this.lastUsedFacilityBackends, + nextFacilityBackend, + ]); + + this.router.navigate(["/ingestor"], { + queryParams: { backendUrl: nextFacilityBackend }, + }); + } else { + this.router.navigate(["/ingestor"]); + } + } + + onClickDisconnectIngestor() { + // Reset state of the ingestor component + this.store.dispatch(fromActions.resetIngestorComponent()); + // Remove the GET parameter 'backendUrl' from the URL + this.router.navigate(["/ingestor"]); + } + + // Helper functions + onClickSelectFacilityBackend(facilityBackend: string) { + this.forwardFacilityBackend = facilityBackend; + } + + onClickDeleteStoredFacilityBackend(facilityBackend: string) { + const filteredFacilityBackends = this.lastUsedFacilityBackends.filter( + (backend) => backend !== facilityBackend, + ); + + IngestorHelper.saveConnectionsToLocalStorage(filteredFacilityBackends); + this.lastUsedFacilityBackends = filteredFacilityBackends; + } + + onClickAddIngestion(): void { + // Clean the current ingestion object + this.store.dispatch(fromActions.resetIngestionObject({})); + this.dialog.closeAll(); + + let dialogRef = null; + dialogRef = this.dialog.open(IngestorCreationDialogBaseComponent, { + disableClose: true, + width: "75vw", + }); + + // Error if the dialog reference is not set + if (dialogRef === null) return; + } + + doRefreshTransferList(page?: number, pageNumber?: number): void { + this.store.dispatch( + fromActions.updateTransferList({ + transferId: undefined, + page: page ? page + 1 : undefined, + pageNumber: pageNumber, + }), + ); + } + + onCancelTransfer(transferId: string) { + const dialogRef = this.dialog.open(IngestorConfirmationDialogComponent, { + data: { + header: "Confirm deletion", + message: "Cancel the transfer and remove it from the list?", + }, + }); + + const dialogSub = dialogRef.afterClosed().subscribe(async (result) => { + if (result) { + this.store.dispatch( + fromActions.cancelTransfer({ + requestBody: { + transferId: transferId, + scicatToken: this.tokenValue, + }, + }), + ); + } + dialogSub.unsubscribe(); + }); + } + + onTransferPageChange(event: PageChangeEvent): void { + this.doRefreshTransferList(event.pageIndex, event.pageSize); + } + + openIngestorLogin(): void { + window.location.href = + this.connectedFacilityBackend + + "/" + + INGESTOR_API_ENDPOINTS_V1.AUTH.LOGIN; + } + + openIngestorLogout(): void { + window.location.href = + this.connectedFacilityBackend + + "/" + + INGESTOR_API_ENDPOINTS_V1.AUTH.LOGOUT; + } + + onOpenDetailView(transferId: string): void { + const dialogRef = this.dialog.open(IngestorTransferViewDialogComponent, { + data: { + transferId: transferId, + }, + }); + + const dialogSub = dialogRef.afterClosed().subscribe(() => { + this.doRefreshTransferList(); + dialogSub.unsubscribe(); + }); + } + + async getFacilityByUserInfo(): Promise { + if (this.scicatUserProfile && this.scicatUserProfile.email) { + const userEmail = this.scicatUserProfile.email; + const facility = userEmail.split("@")[1]?.toLowerCase(); + + const discoveryList = + this.appConfig.ingestorComponent.ingestorAutodiscoveryOptions; + + if (discoveryList) { + for (const discovery of discoveryList) { + const escapedDomain = discovery.mailDomain + .toLowerCase() + .replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const domainPattern = new RegExp(`^([\\w-]+\\.)*${escapedDomain}$`); // + if (domainPattern.test(facility)) { + return discovery; + } + } + } + } + return null; + } +} diff --git a/src/app/ingestor/ingestor-page/ingestor-wrapper.component.spec.ts b/src/app/ingestor/ingestor-page/ingestor-wrapper.component.spec.ts new file mode 100644 index 0000000000..561f34a145 --- /dev/null +++ b/src/app/ingestor/ingestor-page/ingestor-wrapper.component.spec.ts @@ -0,0 +1,59 @@ +import { IngestorWrapperComponent } from "./ingestor-wrapper.component"; +import { NO_ERRORS_SCHEMA } from "@angular/core"; +import { AppConfigInterface, AppConfigService } from "app-config.service"; +import { IngestorTransferComponent } from "./ingestor-transfer.component"; +import { IngestorCreationComponent } from "./ingestor-creation.component"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +describe("IngestorComponent", () => { + let component: IngestorWrapperComponent; + let fixture: ComponentFixture; + let appConfigService: jasmine.SpyObj; + + const getConfig = () => ({ + ingestorEnabled: false, + }); + + beforeEach(() => { + const appConfigServiceSpy = jasmine.createSpyObj("AppConfigService", [ + "getConfig", + ]); + + TestBed.configureTestingModule({ + declarations: [IngestorWrapperComponent], + providers: [{ provide: AppConfigService, useValue: appConfigServiceSpy }], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(IngestorWrapperComponent); + component = fixture.componentInstance; + appConfigService = TestBed.inject( + AppConfigService, + ) as jasmine.SpyObj; + }); + + afterEach(() => { + fixture.destroy(); + }); + + it("should create", () => { + appConfigService.getConfig.and.returnValue({ + ingestorComponent: { + ingestorEnabled: true, + }, + } as AppConfigInterface); + + expect(component).toBeTruthy(); + }); + + it("should load IngestorCreationComponent when config specifies ingestoEnabled as false", () => { + appConfigService.getConfig.and.returnValue({ + ingestorComponent: { + ingestorEnabled: false, + }, + } as AppConfigInterface); + + const componentInstance = component.getIngestorComponent(); + expect(componentInstance).toBe(IngestorCreationComponent); + }); +}); diff --git a/src/app/ingestor/ingestor-page/ingestor-wrapper.component.ts b/src/app/ingestor/ingestor-page/ingestor-wrapper.component.ts new file mode 100644 index 0000000000..457c642f89 --- /dev/null +++ b/src/app/ingestor/ingestor-page/ingestor-wrapper.component.ts @@ -0,0 +1,27 @@ +import { Component, OnInit } from "@angular/core"; +import { AppConfigService } from "app-config.service"; +import { IngestorMode } from "./helper/ingestor.component-helper"; +import { IngestorTransferComponent } from "./ingestor-transfer.component"; +import { IngestorCreationComponent } from "./ingestor-creation.component"; + +@Component({ + selector: "ingestor", + styleUrls: ["./ingestor.component.scss"], + template: ` +
+ +
+ `, + standalone: false, +}) +export class IngestorWrapperComponent { + appConfig = this.appConfigService.getConfig(); + ingestorMode: IngestorMode = "creation"; + + constructor(public appConfigService: AppConfigService) {} + getIngestorComponent() { + return this.appConfigService.getConfig().ingestorComponent?.ingestorEnabled + ? IngestorTransferComponent + : IngestorCreationComponent; + } +} diff --git a/src/app/ingestor/ingestor-page/ingestor.component.scss b/src/app/ingestor/ingestor-page/ingestor.component.scss new file mode 100644 index 0000000000..2cfb6329dd --- /dev/null +++ b/src/app/ingestor/ingestor-page/ingestor.component.scss @@ -0,0 +1,160 @@ +.ingestor-vertical-layout { + display: flex; + flex-direction: column; + gap: 1em; +} + +.ingestor-dialog-header { + display: flex; + flex-direction: column; +} + +.ingestor-mixed-header { + display: flex; + justify-content: space-between; + align-items: baseline; +} + +.ingestor-stepper-header { + flex: 1; + width: 95%; + align-self: center; +} + +.ingestor-close-button { + margin-left: auto; +} + +.form-full-width { + width: 100%; +} + +.metadata-preview { + height: 50vh !important; +} + +mat-card { + margin: 1em; + + .mat-mdc-card-content { + padding: 16px; + } + + .section-icon { + height: auto !important; + width: auto !important; + + mat-icon { + vertical-align: middle; + } + } +} + +.spinner-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; +} + +.spinner-text { + margin-top: 10px; + font-size: 16px; + text-align: center; +} + +.spacer { + flex: 1 1 auto; +} + +.user-info { + padding: 1em; +} + +.user-info .user-icon-container { + display: flex; + align-items: center; + margin-bottom: 0.5em; +} + +.user-info .user-icon-container p { + margin: auto 0; +} + +.divider-down { + margin-bottom: 1em; +} + +.code-editor pre.code { + white-space: pre-wrap; + counter-reset: listing; + line-height: 0.1; +} +.code-editor pre.code code { + counter-increment: listing; + display: flex; + line-height: 1; +} + +.code-editor pre.code code::before { + content: counter(listing) " "; + display: inline-block; + width: 2em; /* Breite für die Zeilennummern */ + text-align: right; /* Rechtsbündige Zeilennummern */ + margin-right: 0.5em; /* Abstand zwischen Zeilennummer und Code */ +} + +.transferPaginator { + margin-top: 1.5em; +} + +.ingestor-file-list .mat-list-item-hover:hover { + background-color: rgba(0, 0, 0, 0.04); +} + +.ingestor-file-list .mat-list-item-hover:active { + background-color: rgba(0, 0, 0, 0.08); +} + +.file-browser-view-toggle { + display: flex; + justify-content: flex-end; +} + +.green-text { + color: #4caf50; +} + +.confirm-message-ingestor { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.confirm-message-ingestor-icon { + font-size: 4em; + width: auto; + height: auto; +} + +.confirm-message-error { + color: red; +} + +.vertical-center-list-item { + display: flex; + align-items: center; + gap: 0.5em; +} + +.last-used-facility-backends { + color: grey; + margin-top: 1em; +} + +.block-button { + display: block; + margin-top: 8px; +} diff --git a/src/app/ingestor/ingestor.module.ts b/src/app/ingestor/ingestor.module.ts new file mode 100644 index 0000000000..7483f0f805 --- /dev/null +++ b/src/app/ingestor/ingestor.module.ts @@ -0,0 +1,113 @@ +import { NgModule } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { IngestorWrapperComponent } from "./ingestor-page/ingestor-wrapper.component"; +import { MatCardModule } from "@angular/material/card"; +import { RouterModule } from "@angular/router"; +import { IngestorMetadataEditorComponent } from "./ingestor-metadata-editor/ingestor-metadata-editor.component"; +import { MatButtonModule } from "@angular/material/button"; +import { MatFormFieldModule } from "@angular/material/form-field"; +import { MatInputModule } from "@angular/material/input"; +import { MatProgressSpinnerModule } from "@angular/material/progress-spinner"; +import { FormsModule } from "@angular/forms"; +import { MatListModule } from "@angular/material/list"; +import { MatIconModule } from "@angular/material/icon"; +import { MatTabsModule } from "@angular/material/tabs"; +import { MatTableModule } from "@angular/material/table"; +import { MatDialogModule } from "@angular/material/dialog"; +import { MatSelectModule } from "@angular/material/select"; +import { MatOptionModule } from "@angular/material/core"; +import { MatAutocompleteModule } from "@angular/material/autocomplete"; +import { JsonFormsModule } from "@jsonforms/angular"; +import { JsonFormsAngularMaterialModule } from "@jsonforms/angular-material"; +import { MatStepperModule } from "@angular/material/stepper"; +import { IngestorDialogStepperComponent } from "./ingestor-dialogs/dialog-mounting-components/ingestor.dialog-stepper.component"; +import { AnyOfRendererComponent } from "./ingestor-metadata-editor/customRenderer/any-of-renderer"; +import { MatRadioModule } from "@angular/material/radio"; +import { ArrayLayoutRendererCustom } from "./ingestor-metadata-editor/customRenderer/array-renderer"; +import { MatBadgeModule } from "@angular/material/badge"; +import { MatTooltipModule } from "@angular/material/tooltip"; +import { IngestorConfirmationDialogComponent } from "./ingestor-dialogs/confirmation-dialog/ingestor.confirmation-dialog.component"; +import { ExportTemplateHelperComponent } from "./ingestor-dialogs/dialog-mounting-components/ingestor.export-helper.component"; +import { MatPaginatorModule } from "@angular/material/paginator"; +import { CustomObjectControlRendererComponent } from "./ingestor-metadata-editor/customRenderer/object-group-renderer"; +import { IngestorFileBrowserComponent } from "./ingestor-dialogs/ingestor-file-browser/ingestor.file-browser.component"; +import { MatTreeModule } from "@angular/material/tree"; +import { OwnerGroupFieldComponent } from "./ingestor-metadata-editor/customRenderer/owner-group-field-renderer"; +import { + CustomLayoutChildrenRenderPropsPipe, + QuantityValueLayoutRendererComponent, +} from "./ingestor-metadata-editor/customRenderer/quantity-value-layout-renderer"; +import { MatMenuModule } from "@angular/material/menu"; +import { EffectsModule } from "@ngrx/effects"; +import { IngestorEffects } from "state-management/effects/ingestor.effects"; +import { StoreModule } from "@ngrx/store"; +import { ingestorReducer } from "state-management/reducers/ingestor.reducer"; +import { IngestorCreationDialogBaseComponent } from "./ingestor-dialogs/creation-dialog/ingestor.creation-dialog-base.component"; +import { IngestorConfirmTransferDialogPageComponent } from "./ingestor-dialogs/creation-dialog/creation-pages/ingestor.confirm-transfer-dialog-page.component"; +import { IngestorNewTransferDialogPageComponent } from "./ingestor-dialogs/creation-dialog/creation-pages/ingestor.new-transfer-dialog-page.component"; +import { IngestorUserMetadataDialogPageComponent } from "./ingestor-dialogs/creation-dialog/creation-pages/ingestor.user-metadata-dialog-page.component"; +import { IngestorExtractorMetadataDialogPageComponent } from "./ingestor-dialogs/creation-dialog/creation-pages/ingestor.extractor-metadata-dialog-page.component"; +import { IngestorTransferViewDialogComponent } from "./ingestor-dialogs/transfer-detail-view/ingestor.transfer-detail-view-dialog.component"; +import { IngestorNoRightsDialogPageComponent } from "./ingestor-dialogs/creation-dialog/creation-pages/ingestor.no-rights-dialog-page.component"; +import { IngestorTransferComponent } from "./ingestor-page/ingestor-transfer.component"; +import { IngestorCreationComponent } from "./ingestor-page/ingestor-creation.component"; +import { IngestorCustomMetadataDialogPageComponent } from "./ingestor-dialogs/creation-dialog/creation-pages/ingestor.custom-metadata-dialog-page.component"; + +@NgModule({ + declarations: [ + IngestorWrapperComponent, + IngestorTransferComponent, + IngestorCreationComponent, + IngestorMetadataEditorComponent, + IngestorConfirmTransferDialogPageComponent, + IngestorNewTransferDialogPageComponent, + IngestorUserMetadataDialogPageComponent, + IngestorExtractorMetadataDialogPageComponent, + IngestorCreationDialogBaseComponent, + IngestorDialogStepperComponent, + AnyOfRendererComponent, + ArrayLayoutRendererCustom, + CustomObjectControlRendererComponent, + IngestorConfirmationDialogComponent, + ExportTemplateHelperComponent, + IngestorFileBrowserComponent, + OwnerGroupFieldComponent, + QuantityValueLayoutRendererComponent, + CustomLayoutChildrenRenderPropsPipe, + IngestorTransferViewDialogComponent, + IngestorNoRightsDialogPageComponent, + IngestorCustomMetadataDialogPageComponent, + ], + imports: [ + CommonModule, + MatCardModule, + FormsModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule, + MatProgressSpinnerModule, + RouterModule, + MatListModule, + MatIconModule, + MatTabsModule, + MatTableModule, + MatDialogModule, + MatTooltipModule, + MatSelectModule, + MatOptionModule, + MatStepperModule, + MatRadioModule, + MatAutocompleteModule, + MatBadgeModule, + MatTreeModule, + JsonFormsModule, + JsonFormsAngularMaterialModule, + CommonModule, + MatPaginatorModule, + MatMenuModule, + EffectsModule.forFeature([IngestorEffects]), + StoreModule.forFeature("ingestor", ingestorReducer), + ], + exports: [IngestorMetadataEditorComponent, IngestorCreationComponent], +}) +export class IngestorModule {} diff --git a/src/app/shared/MockStubs.ts b/src/app/shared/MockStubs.ts index 2c82ad5190..4be9096106 100644 --- a/src/app/shared/MockStubs.ts +++ b/src/app/shared/MockStubs.ts @@ -24,6 +24,10 @@ import { OutputAttachmentV3Dto, } from "@scicatproject/scicat-sdk-ts-angular"; import { SDKToken } from "./services/auth/auth.service"; +import { IngestionRequestInformation } from "ingestor/ingestor-page/helper/ingestor.component-helper"; +import { MethodItem } from "./sdk/models/ingestor/methodItem"; +import { FolderNode } from "./sdk/models/ingestor/folderNode"; +import { APIInformation } from "ingestor/ingestor-page/helper/ingestor.component-helper"; export class MockUserApi { getCurrentId() { @@ -333,3 +337,97 @@ export const mockLogbook = createMock({}); export const mockPolicy = createMock({}); export const mockPublishedData = createMock({}); export const mockUser = createMock({}); + +export const mockIngestionRequestInformation = + createMock({ + selectedPath: "/test/path", + selectedMethod: { + name: "test-method", + schema: "base64-encoded-schema", + }, + selectedResolvedDecodedSchema: { + type: "object", + properties: { + organizational: { type: "object" }, + sample: { type: "object" }, + instrument: { type: "object" }, + acquisition: { type: "object" }, + }, + }, + scicatHeader: { + datasetName: "test-dataset", + sourceFolder: "/test/path", + type: "raw", + owner: "testuser", + }, + userMetaData: { + organizational: { + experimentId: "EXP-001", + }, + sample: { + sampleId: "SAMPLE-001", + }, + }, + extractorMetaData: { + instrument: { + name: "Test Instrument", + }, + acquisition: { + date: "2024-01-01", + }, + }, + customMetaData: {}, + mergedMetaDataString: "{}", + editorMode: "INGESTION", + ingestionRequest: null, + autoArchive: false, + }); + +export const mockMethodItem = createMock({ + name: "test-extraction-method", + schema: "eyJ0eXBlIjoib2JqZWN0IiwicHJvcGVydGllcyI6e319", +}); + +export const mockMethodItem2 = createMock({ + name: "another-method", + schema: "eyJwcm9wZXJ0aWVzIjp7Imluc3RydW1lbnQiOnt9fX0=", +}); + +export const mockMethodItems: MethodItem[] = [mockMethodItem, mockMethodItem2]; + +export const mockFolderNode = createMock({ + name: "test-folder", + path: "/test/path/test-folder", + children: true, + probablyDataset: false, +}); + +export const mockDatasetFolderNode = createMock({ + name: "dataset-folder", + path: "/test/path/dataset-folder", + children: false, + probablyDataset: true, +}); + +export const mockRootFolderNode = createMock({ + name: "", + path: "/", + children: true, + probablyDataset: false, +}); + +export const mockFolderNodes: FolderNode[] = [ + mockFolderNode, + mockDatasetFolderNode, + mockRootFolderNode, +]; + +export const mockAPIInformation = createMock({ + ingestionDatasetLoading: false, + extractorMetaDataReady: false, + extractMetaDataRequested: false, + metaDataExtractionFailed: false, + extractorMetadataProgress: 0, + extractorMetaDataStatus: "", + ingestionRequestErrorMessage: "", +}); diff --git a/src/app/shared/sdk/apis/ingestor.service.ts b/src/app/shared/sdk/apis/ingestor.service.ts new file mode 100644 index 0000000000..716c89a0ff --- /dev/null +++ b/src/app/shared/sdk/apis/ingestor.service.ts @@ -0,0 +1,183 @@ +import { Injectable } from "@angular/core"; +import { HttpClient, HttpParams, HttpHeaders } from "@angular/common/http"; +import { Observable } from "rxjs"; +import { switchMap, take } from "rxjs/operators"; +import { AppConfigService } from "app-config.service"; +import { + DeleteTransferRequest, + DeleteTransferResponse, + GetBrowseDatasetResponse, + GetExtractorResponse, + GetTransferResponse, + OtherHealthResponse, + OtherVersionResponse, + PostDatasetRequest, + PostDatasetResponse, + UserInfo, +} from "../models/ingestor/models"; +import { Store } from "@ngrx/store"; +import { selectIngestorEndpoint } from "state-management/selectors/ingestor.selectors"; + +export const INGESTOR_API_ENDPOINTS_V1 = { + AUTH: { + LOGIN: "login", + LOGOUT: "logout", + USERINFO: "userinfo", + }, + DATASET: "dataset", + DATASET_BROWSE: "dataset/browse", + TRANSFER: "transfer", + OTHER: { + VERSION: "version", + HEALTH: "health", + }, + EXTRACTOR: "extractor", + METADATA: "metadata", +}; + +@Injectable({ + providedIn: "root", +}) +export class Ingestor { + constructor( + private http: HttpClient, + public appConfigService: AppConfigService, + private store: Store, + ) {} + + private getRequestOptions() { + return { + withCredentials: true, + headers: new HttpHeaders({ + "Content-Type": "application/json; charset=utf-8", + }), + }; + } + + getVersion(): Observable { + return this.store.select(selectIngestorEndpoint).pipe( + take(1), + switchMap((ingestorEndpoint) => + this.http.get( + `${ingestorEndpoint.facilityBackend}/${INGESTOR_API_ENDPOINTS_V1.OTHER.VERSION}`, + this.getRequestOptions(), + ), + ), + ); + } + + getUserInfo(): Observable { + return this.store.select(selectIngestorEndpoint).pipe( + take(1), + switchMap((ingestorEndpoint) => + this.http.get( + `${ingestorEndpoint.facilityBackend}/${INGESTOR_API_ENDPOINTS_V1.AUTH.USERINFO}`, + this.getRequestOptions(), + ), + ), + ); + } + + getHealth(): Observable { + return this.store.select(selectIngestorEndpoint).pipe( + take(1), + switchMap((ingestorEndpoint) => + this.http.get( + `${ingestorEndpoint.facilityBackend}/${INGESTOR_API_ENDPOINTS_V1.OTHER.HEALTH}`, + this.getRequestOptions(), + ), + ), + ); + } + + cancelTransfer( + requestBody: DeleteTransferRequest, + ): Observable { + return this.store.select(selectIngestorEndpoint).pipe( + take(1), + switchMap((ingestorEndpoint) => + this.http.delete( + `${ingestorEndpoint.facilityBackend}/${INGESTOR_API_ENDPOINTS_V1.TRANSFER}`, + { ...this.getRequestOptions(), body: requestBody }, + ), + ), + ); + } + + getTransferList( + page: number, + pageSize: number, + transferId?: string, + ): Observable { + const params: any = { + page: page.toString(), + pageSize: pageSize.toString(), + }; + if (transferId) { + params.transferId = transferId; + } + + return this.store.select(selectIngestorEndpoint).pipe( + take(1), + switchMap((ingestorEndpoint) => + this.http.get( + `${ingestorEndpoint.facilityBackend}/${INGESTOR_API_ENDPOINTS_V1.TRANSFER}`, + { ...this.getRequestOptions(), params }, + ), + ), + ); + } + + startIngestion(payload: PostDatasetRequest): Observable { + return this.store.select(selectIngestorEndpoint).pipe( + take(1), + switchMap((ingestorEndpoint) => + this.http.post( + `${ingestorEndpoint.facilityBackend}/${INGESTOR_API_ENDPOINTS_V1.DATASET}`, + payload, + this.getRequestOptions(), + ), + ), + ); + } + + getExtractionMethods( + page: number, + pageSize: number, + ): Observable { + const params = new HttpParams() + .set("page", page.toString()) + .set("pageSize", pageSize.toString()); + + return this.store.select(selectIngestorEndpoint).pipe( + take(1), + switchMap((ingestorEndpoint) => + this.http.get( + `${ingestorEndpoint.facilityBackend}/${INGESTOR_API_ENDPOINTS_V1.EXTRACTOR}`, + { ...this.getRequestOptions(), params }, + ), + ), + ); + } + + getBrowseFilePath( + page: number, + pageSize: number, + path: string, + ): Observable { + const params = new HttpParams() + .set("page", page.toString()) + .set("pageSize", pageSize.toString()) + .set("path", path.toString()); + + return this.store.select(selectIngestorEndpoint).pipe( + take(1), + switchMap((ingestorEndpoint) => + this.http.get( + `${ingestorEndpoint.facilityBackend}/${INGESTOR_API_ENDPOINTS_V1.DATASET_BROWSE}`, + { ...this.getRequestOptions(), params }, + ), + ), + ); + } +} diff --git a/src/app/shared/sdk/models/ingestor/_generateModel.md b/src/app/shared/sdk/models/ingestor/_generateModel.md new file mode 100644 index 0000000000..daec444b95 --- /dev/null +++ b/src/app/shared/sdk/models/ingestor/_generateModel.md @@ -0,0 +1,59 @@ +# OpenAPI Generator - What is that? +The OpenAPI Generator is an open source tool that provides code generators to generate client libraries, server stubs and API documentation from an OpenAPI specification (e.g. *openapi.yaml* or *open-api.json*). +The tool works as follows: +* Input: It takes an OpenAPI specification file (e.g. *openapi.yaml* or *openapi.json*) that describes how the API works, including endpoints, parameters, response structures and data models. +* Generator selection: You choose a generator (e.g. for *TypeScript Angular*). +* Output: The generator automatically creates source code that can be used to communicate with the API or as a framework for the API implementation. + + +# How to install the OpenAPI Generator +## Installation via Docker +``` +docker pull openapitools/openapi-generator-cli +``` + + +# How do I use the OpenAPI Generator for TypeScript Angular? +## Prerequisites +* OpenAPI Generator has been installed +* openapi.yaml is available + +## Generation of Typescript Angular objects +### Using Docker +``` +docker run --rm -v ${PWD}:/local openapitools/openapi-generator-cli generate \ +-i /local/openapi.yaml \ +-g typescript-angular \ +-o /local/out +``` + +* -i: Path of the input file (openapi.yaml) +* -g: Generator type (*typescript-angular*) +* -o: target directory + +### Using the OpenAPI Generator CLI +``` +openapi-generator-cli generate \ + +-i openapi.yaml \ +-g typescript-angular \ +-o /local/out \ +``` + +Optional specify angular version: +``` +--additional-properties=ngVersion=16 +``` + +## Integration into an angular project +* Copy the generated files from the *./local/out/model* folder into your Angular project (model folder). + + +# openapi.yaml as input for OpenAPI Generator +OpenAPI.yaml contains the following things: +* Endpoints: Which URL routes are available and what they do. +* HTTP methods: GET, POST, PUT, DELETE etc. +* Parameters: Query parameters, body data, etc. +* Responses: status codes and return data (e.g. JSON objects) + + diff --git a/src/app/shared/sdk/models/ingestor/deleteTransferRequest.ts b/src/app/shared/sdk/models/ingestor/deleteTransferRequest.ts new file mode 100644 index 0000000000..6522519e02 --- /dev/null +++ b/src/app/shared/sdk/models/ingestor/deleteTransferRequest.ts @@ -0,0 +1,26 @@ +/** + * SciCat Ingestor API + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export interface DeleteTransferRequest { + /** + * id of the transfer that should be cancelled + */ + transferId: string; + /** + * if the ingestor is configured to use ExtGlobusService for transfer, this endpoint needs a SciCat token + */ + scicatToken?: string; + /** + * if the entry needs to be deleted or not, in addition to cancelling it (by default false) + */ + deleteTask?: boolean; +} + diff --git a/src/app/shared/sdk/models/ingestor/deleteTransferResponse.ts b/src/app/shared/sdk/models/ingestor/deleteTransferResponse.ts new file mode 100644 index 0000000000..67ad02709e --- /dev/null +++ b/src/app/shared/sdk/models/ingestor/deleteTransferResponse.ts @@ -0,0 +1,22 @@ +/** + * SciCat Ingestor API + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export interface DeleteTransferResponse { + /** + * Transfer id affected + */ + transferId: string; + /** + * New status of the transfer. + */ + status?: string; +} + diff --git a/src/app/shared/sdk/models/ingestor/folderNode.ts b/src/app/shared/sdk/models/ingestor/folderNode.ts new file mode 100644 index 0000000000..738a88c4c2 --- /dev/null +++ b/src/app/shared/sdk/models/ingestor/folderNode.ts @@ -0,0 +1,21 @@ +/** + * SciCat Ingestor API + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +/** + * a method item describes the method\'s name and schema + */ +export interface FolderNode { + name: string; + path: string; + children: boolean; + probablyDataset: boolean; +} + diff --git a/src/app/shared/sdk/models/ingestor/getBrowseDatasetResponse.ts b/src/app/shared/sdk/models/ingestor/getBrowseDatasetResponse.ts new file mode 100644 index 0000000000..18afb243e2 --- /dev/null +++ b/src/app/shared/sdk/models/ingestor/getBrowseDatasetResponse.ts @@ -0,0 +1,20 @@ +/** + * SciCat Ingestor API + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +import { FolderNode } from './folderNode'; + + +export interface GetBrowseDatasetResponse { + folders: Array; + /** + * Total number of folders. + */ + total: number; +} + diff --git a/src/app/shared/sdk/models/ingestor/getExtractorResponse.ts b/src/app/shared/sdk/models/ingestor/getExtractorResponse.ts new file mode 100644 index 0000000000..dcb0fe81c1 --- /dev/null +++ b/src/app/shared/sdk/models/ingestor/getExtractorResponse.ts @@ -0,0 +1,23 @@ +/** + * SciCat Ingestor API + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +import { MethodItem } from './methodItem'; + + +export interface GetExtractorResponse { + /** + * List of the metadata extraction method names configured in the ingestor + */ + methods: Array; + /** + * Total number of methods + */ + total: number; +} + diff --git a/src/app/shared/sdk/models/ingestor/getTransferResponse.ts b/src/app/shared/sdk/models/ingestor/getTransferResponse.ts new file mode 100644 index 0000000000..d4ced9554c --- /dev/null +++ b/src/app/shared/sdk/models/ingestor/getTransferResponse.ts @@ -0,0 +1,20 @@ +/** + * SciCat Ingestor API + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +import { TransferItem } from './transferItem'; + + +export interface GetTransferResponse { + transfers?: Array; + /** + * Total number of transfers. + */ + total?: number; +} + diff --git a/src/app/shared/sdk/models/ingestor/methodItem.ts b/src/app/shared/sdk/models/ingestor/methodItem.ts new file mode 100644 index 0000000000..a627161859 --- /dev/null +++ b/src/app/shared/sdk/models/ingestor/methodItem.ts @@ -0,0 +1,19 @@ +/** + * SciCat Ingestor API + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +/** + * a method item describes the method\'s name and schema + */ +export interface MethodItem { + name: string; + schema: string; +} + diff --git a/src/app/shared/sdk/models/ingestor/modelError.ts b/src/app/shared/sdk/models/ingestor/modelError.ts new file mode 100644 index 0000000000..0661161548 --- /dev/null +++ b/src/app/shared/sdk/models/ingestor/modelError.ts @@ -0,0 +1,16 @@ +/** + * SciCat Ingestor API + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export interface ModelError { + code: string; + message: string; +} + diff --git a/src/app/shared/sdk/models/ingestor/models.ts b/src/app/shared/sdk/models/ingestor/models.ts new file mode 100644 index 0000000000..9c2cc518a7 --- /dev/null +++ b/src/app/shared/sdk/models/ingestor/models.ts @@ -0,0 +1,17 @@ +export * from './deleteTransferRequest'; +export * from './deleteTransferResponse'; +export * from './folderNode'; +export * from './getBrowseDatasetResponse'; +export * from './getExtractorResponse'; +export * from './getTransferResponse'; +export * from './methodItem'; +export * from './modelError'; +export * from './oidcCallbackOk'; +export * from './oidcCallbackOkOAuth2Token'; +export * from './oidcCallbackOkUserInfo'; +export * from './otherHealthResponse'; +export * from './otherVersionResponse'; +export * from './postDatasetRequest'; +export * from './postDatasetResponse'; +export * from './transferItem'; +export * from './userInfo'; diff --git a/src/app/shared/sdk/models/ingestor/oidcCallbackOk.ts b/src/app/shared/sdk/models/ingestor/oidcCallbackOk.ts new file mode 100644 index 0000000000..9dbbd2a8e1 --- /dev/null +++ b/src/app/shared/sdk/models/ingestor/oidcCallbackOk.ts @@ -0,0 +1,18 @@ +/** + * SciCat Ingestor API + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +import { OidcCallbackOkUserInfo } from './oidcCallbackOkUserInfo'; +import { OidcCallbackOkOAuth2Token } from './oidcCallbackOkOAuth2Token'; + + +export interface OidcCallbackOk { + OAuth2Token: OidcCallbackOkOAuth2Token; + UserInfo: OidcCallbackOkUserInfo; +} + diff --git a/src/app/shared/sdk/models/ingestor/oidcCallbackOkOAuth2Token.ts b/src/app/shared/sdk/models/ingestor/oidcCallbackOkOAuth2Token.ts new file mode 100644 index 0000000000..5c4258d153 --- /dev/null +++ b/src/app/shared/sdk/models/ingestor/oidcCallbackOkOAuth2Token.ts @@ -0,0 +1,22 @@ +/** + * SciCat Ingestor API + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +/** + * Oauth2 Token object + */ +export interface OidcCallbackOkOAuth2Token { + access_token: string; + token_type?: string; + refresh_token?: string; + expiry?: string; + expires_in?: number; +} + diff --git a/src/app/shared/sdk/models/ingestor/oidcCallbackOkUserInfo.ts b/src/app/shared/sdk/models/ingestor/oidcCallbackOkUserInfo.ts new file mode 100644 index 0000000000..c9326e6812 --- /dev/null +++ b/src/app/shared/sdk/models/ingestor/oidcCallbackOkUserInfo.ts @@ -0,0 +1,24 @@ +/** + * SciCat Ingestor API + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +/** + * OIDC UserInfo object + */ +export interface OidcCallbackOkUserInfo { + /** + * subject of user + */ + sub: string; + profile: string; + email: string; + email_verified: boolean; +} + diff --git a/src/app/shared/sdk/models/ingestor/otherHealthResponse.ts b/src/app/shared/sdk/models/ingestor/otherHealthResponse.ts new file mode 100644 index 0000000000..73e42ab54b --- /dev/null +++ b/src/app/shared/sdk/models/ingestor/otherHealthResponse.ts @@ -0,0 +1,19 @@ +/** + * SciCat Ingestor API + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export interface OtherHealthResponse { + /** + * Status of the ingestor. + */ + status: string; + errors?: { [key: string]: string; }; +} + diff --git a/src/app/shared/sdk/models/ingestor/otherVersionResponse.ts b/src/app/shared/sdk/models/ingestor/otherVersionResponse.ts new file mode 100644 index 0000000000..ca66142d50 --- /dev/null +++ b/src/app/shared/sdk/models/ingestor/otherVersionResponse.ts @@ -0,0 +1,18 @@ +/** + * SciCat Ingestor API + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export interface OtherVersionResponse { + /** + * Version of the ingestor. + */ + version?: string; +} + diff --git a/src/app/shared/sdk/models/ingestor/postDatasetRequest.ts b/src/app/shared/sdk/models/ingestor/postDatasetRequest.ts new file mode 100644 index 0000000000..2a710a26ec --- /dev/null +++ b/src/app/shared/sdk/models/ingestor/postDatasetRequest.ts @@ -0,0 +1,26 @@ +/** + * SciCat Ingestor API + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export interface PostDatasetRequest { + /** + * The metadata of the dataset. + */ + metaData: string; + /** + * the scicat token for acting on behalf of the user + */ + userToken: string; + /** + * whether to autoarchive the dataset. Default is TRUE + */ + autoArchive?: boolean; +} + diff --git a/src/app/shared/sdk/models/ingestor/postDatasetResponse.ts b/src/app/shared/sdk/models/ingestor/postDatasetResponse.ts new file mode 100644 index 0000000000..9eb8f838f1 --- /dev/null +++ b/src/app/shared/sdk/models/ingestor/postDatasetResponse.ts @@ -0,0 +1,22 @@ +/** + * SciCat Ingestor API + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export interface PostDatasetResponse { + /** + * The unique transfer id of the dataset transfer job. + */ + transferId: string; + /** + * The status of the transfer. Can be used to send a message back to the ui. + */ + status?: string; +} + diff --git a/src/app/shared/sdk/models/ingestor/transferItem.ts b/src/app/shared/sdk/models/ingestor/transferItem.ts new file mode 100644 index 0000000000..12b08c2def --- /dev/null +++ b/src/app/shared/sdk/models/ingestor/transferItem.ts @@ -0,0 +1,33 @@ +/** + * SciCat Ingestor API + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export interface TransferItem { + transferId: string; + status: TransferItem.StatusEnum; + message?: string; + bytesTransferred?: number; + bytesTotal?: number; + filesTransferred?: number; + filesTotal?: number; +} +export namespace TransferItem { + export type StatusEnum = 'waiting' | 'transferring' | 'finished' | 'failed' | 'cancelled' | 'invalid status'; + export const StatusEnum = { + Waiting: 'waiting' as StatusEnum, + Transferring: 'transferring' as StatusEnum, + Finished: 'finished' as StatusEnum, + Failed: 'failed' as StatusEnum, + Cancelled: 'cancelled' as StatusEnum, + InvalidStatus: 'invalid status' as StatusEnum + }; +} + + diff --git a/src/app/shared/sdk/models/ingestor/userInfo.ts b/src/app/shared/sdk/models/ingestor/userInfo.ts new file mode 100644 index 0000000000..9bb3df47c8 --- /dev/null +++ b/src/app/shared/sdk/models/ingestor/userInfo.ts @@ -0,0 +1,24 @@ +/** + * SciCat Ingestor API + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export interface UserInfo { + logged_in: boolean; + subject?: string; + profile?: string; + email?: string; + roles?: Array; + preferred_username?: string; + name?: string; + family_name?: string; + given_name?: string; + expires_at?: string; +} + diff --git a/src/app/state-management/actions/datasets.actions.ts b/src/app/state-management/actions/datasets.actions.ts index 554bf7e4e8..550f250b14 100644 --- a/src/app/state-management/actions/datasets.actions.ts +++ b/src/app/state-management/actions/datasets.actions.ts @@ -151,6 +151,10 @@ export const updatePropertyAction = createAction( "[Dataset] Update Property", props<{ pid: string; property: Record }>(), ); +export const updateScientificMetadataCompleteAction = createAction( + "[Dataset] Update Property Complete", + props<{ pid: string }>(), +); export const updatePropertyCompleteAction = createAction( "[Dataset] Update Property Complete", ); diff --git a/src/app/state-management/actions/ingestor.actions.spec.ts b/src/app/state-management/actions/ingestor.actions.spec.ts new file mode 100644 index 0000000000..c54808202e --- /dev/null +++ b/src/app/state-management/actions/ingestor.actions.spec.ts @@ -0,0 +1,405 @@ +import * as fromActions from "./ingestor.actions"; +import { + mockIngestionRequestInformation, + mockAPIInformation, + mockMethodItems, + mockFolderNode, +} from "shared/MockStubs"; + +describe("Ingestor Actions", () => { + describe("setIngestorEndpoint", () => { + it("should create an action with endpoint", () => { + const action = fromActions.setIngestorEndpoint({ + ingestorEndpoint: { + mailDomain: "", + description: "", + facilityBackend: "http://localhost:3000", + }, + }); + + expect(action.type).toBe("[Ingestor] Set ingestor endpoint"); + expect(action.ingestorEndpoint).toEqual({ + mailDomain: "", + description: "", + facilityBackend: "http://localhost:3000", + }); + }); + }); + + describe("connectIngestor", () => { + it("should create an action", () => { + const action = fromActions.connectIngestor(); + + expect(action.type).toBe("[Ingestor] Connect Ingestor"); + }); + }); + + describe("connectIngestorSuccess", () => { + it("should create an action with connection details", () => { + const versionResponse = { version: "1.0.0" }; + const userInfoResponse = { logged_in: true }; + const healthResponse = { status: "ok" }; + + const action = fromActions.connectIngestorSuccess({ + versionResponse, + userInfoResponse, + authIsDisabled: false, + healthResponse, + }); + + expect(action.type).toBe("[Ingestor] Completed Ingestor Connection"); + expect(action.versionResponse).toEqual(versionResponse); + expect(action.userInfoResponse).toEqual(userInfoResponse); + expect(action.authIsDisabled).toBe(false); + expect(action.healthResponse).toEqual(healthResponse); + }); + }); + + describe("connectIngestorFailure", () => { + it("should create an action with error", () => { + const error = new Error("Connection failed"); + const action = fromActions.connectIngestorFailure({ err: error }); + + expect(action.type).toBe("[Ingestor] Failed to connect to Ingestor "); + expect(action.err).toEqual(error); + }); + }); + + describe("startConnectingIngestor", () => { + it("should create an action", () => { + const action = fromActions.startConnectingIngestor(); + + expect(action.type).toBe("[Ingestor] Start connecting Ingestor"); + }); + }); + + describe("stopConnectingIngestor", () => { + it("should create an action", () => { + const action = fromActions.stopConnectingIngestor(); + + expect(action.type).toBe("[Ingestor] Stop connecting Ingestor"); + }); + }); + + describe("updateTransferList", () => { + it("should create an action with all parameters", () => { + const action = fromActions.updateTransferList({ + transferId: "transfer-123", + page: 1, + pageNumber: 50, + }); + + expect(action.type).toBe("[Ingestor] Update transfer list"); + expect(action.transferId).toBe("transfer-123"); + expect(action.page).toBe(1); + expect(action.pageNumber).toBe(50); + }); + + it("should create an action with optional parameters", () => { + const action = fromActions.updateTransferList({}); + + expect(action.type).toBe("[Ingestor] Update transfer list"); + expect(action.transferId).toBeUndefined(); + expect(action.page).toBeUndefined(); + }); + }); + + describe("updateTransferListSuccess", () => { + it("should create an action with transfer list", () => { + const transferList = { transfers: [], total: 0 }; + const action = fromActions.updateTransferListSuccess({ + transferList, + page: 1, + pageNumber: 50, + }); + + expect(action.type).toBe("[Ingestor] Update Transfer List Success"); + expect(action.transferList).toEqual(transferList); + expect(action.page).toBe(1); + expect(action.pageNumber).toBe(50); + }); + }); + + describe("updateTransferListDetailSuccess", () => { + it("should create an action with detail view", () => { + const transferListDetailView = { transfers: [], total: 0 }; + const action = fromActions.updateTransferListDetailSuccess({ + transferListDetailView, + }); + + expect(action.type).toBe( + "[Ingestor] Update Transfer List Detail Success", + ); + expect(action.transferListDetailView).toEqual(transferListDetailView); + }); + }); + + describe("updateTransferListFailure", () => { + it("should create an action with error", () => { + const error = new Error("Update failed"); + const action = fromActions.updateTransferListFailure({ err: error }); + + expect(action.type).toBe("[Ingestor] Update Transfer List Failure"); + expect(action.err).toEqual(error); + }); + }); + + describe("resetIngestionObject", () => { + it("should create an action with ingestion object", () => { + const action = fromActions.resetIngestionObject({ + ingestionObject: mockIngestionRequestInformation, + }); + + expect(action.type).toBe("[Ingestor] Reset Ingestion Object"); + expect(action.ingestionObject).toEqual(mockIngestionRequestInformation); + }); + + it("should create an action without ingestion object", () => { + const action = fromActions.resetIngestionObject({}); + + expect(action.type).toBe("[Ingestor] Reset Ingestion Object"); + expect(action.ingestionObject).toBeUndefined(); + }); + }); + + describe("updateIngestionObject", () => { + it("should create an action with ingestion object", () => { + const action = fromActions.updateIngestionObject({ + ingestionObject: mockIngestionRequestInformation, + }); + + expect(action.type).toBe("[Ingestor] Update Ingestion Object"); + expect(action.ingestionObject).toEqual(mockIngestionRequestInformation); + }); + }); + + describe("updateIngestionObjectFromThirdParty", () => { + it("should create an action with ingestion object", () => { + const action = fromActions.updateIngestionObjectFromThirdParty({ + ingestionObject: mockIngestionRequestInformation, + }); + + expect(action.type).toBe( + "[Ingestor] Update Ingestion Object from Third Party", + ); + expect(action.ingestionObject).toEqual(mockIngestionRequestInformation); + }); + }); + + describe("updateIngestionObjectAPIInformation", () => { + it("should create an action with API information", () => { + const action = fromActions.updateIngestionObjectAPIInformation({ + ingestionObjectApiInformation: mockAPIInformation, + }); + + expect(action.type).toBe( + "[Ingestor] Update Ingestion Object API Information", + ); + expect(action.ingestionObjectApiInformation).toEqual(mockAPIInformation); + }); + }); + + describe("resetIngestionObjectFromThirdPartyFlag", () => { + it("should create an action", () => { + const action = fromActions.resetIngestionObjectFromThirdPartyFlag(); + + expect(action.type).toBe( + "[Ingestor] Reset Ingestion Object from Third Party Flag", + ); + }); + }); + + describe("getExtractionMethods", () => { + it("should create an action with pagination", () => { + const action = fromActions.getExtractionMethods({ + page: 1, + pageNumber: 50, + }); + + expect(action.type).toBe("[Ingestor] Get Extraction Methods"); + expect(action.page).toBe(1); + expect(action.pageNumber).toBe(50); + }); + }); + + describe("getExtractionMethodsSuccess", () => { + it("should create an action with extraction methods", () => { + const extractionMethods = { methods: mockMethodItems, total: 2 }; + const action = fromActions.getExtractionMethodsSuccess({ + extractionMethods, + }); + + expect(action.type).toBe("[Ingestor] Get Extraction Methods Success"); + expect(action.extractionMethods).toEqual(extractionMethods); + }); + }); + + describe("getExtractionMethodsFailure", () => { + it("should create an action with error", () => { + const error = new Error("Failed to get methods"); + const action = fromActions.getExtractionMethodsFailure({ err: error }); + + expect(action.type).toBe("[Ingestor] Get Extraction Methods Failure"); + expect(action.err).toEqual(error); + }); + }); + + describe("getBrowseFilePath", () => { + it("should create an action with path and pagination", () => { + const action = fromActions.getBrowseFilePath({ + path: "/test/path", + page: 1, + pageNumber: 50, + }); + + expect(action.type).toBe("[Ingestor] Get Browse File Path"); + expect(action.path).toBe("/test/path"); + expect(action.page).toBe(1); + expect(action.pageNumber).toBe(50); + }); + }); + + describe("getBrowseFilePathSuccess", () => { + it("should create an action with browser node", () => { + const node = { folders: [mockFolderNode], total: 1 }; + const action = fromActions.getBrowseFilePathSuccess({ + ingestorBrowserActiveNode: node, + }); + + expect(action.type).toBe("[Ingestor] Get Browse File Path Success"); + expect(action.ingestorBrowserActiveNode).toEqual(node); + }); + }); + + describe("getBrowseFilePathFailure", () => { + it("should create an action with error", () => { + const error = new Error("Browse failed"); + const action = fromActions.getBrowseFilePathFailure({ err: error }); + + expect(action.type).toBe("[Ingestor] Get Browse File Path Failure"); + expect(action.err).toEqual(error); + }); + }); + + describe("ingestDataset", () => { + it("should create an action with ingestion dataset", () => { + const ingestionDataset = { + metaData: "{}", + userToken: "token-123", + autoArchive: true, + }; + const action = fromActions.ingestDataset({ ingestionDataset }); + + expect(action.type).toBe("[Ingestor] Ingest Dataset"); + expect(action.ingestionDataset).toEqual(ingestionDataset); + }); + }); + + describe("setIngestDatasetLoading", () => { + it("should create an action with loading state", () => { + const action = fromActions.setIngestDatasetLoading({ + ingestionDatasetLoading: true, + }); + + expect(action.type).toBe("[Ingestor] Set Ingest Dataset Loading"); + expect(action.ingestionDatasetLoading).toBe(true); + }); + }); + + describe("ingestDatasetSuccess", () => { + it("should create an action with response", () => { + const response = { transferId: "transfer-123", status: "success" }; + const action = fromActions.ingestDatasetSuccess({ response }); + + expect(action.type).toBe("[Ingestor] Ingest Dataset Success"); + expect(action.response).toEqual(response); + }); + }); + + describe("ingestDatasetFailure", () => { + it("should create an action with error", () => { + const error = new Error("Ingestion failed"); + const action = fromActions.ingestDatasetFailure({ err: error }); + + expect(action.type).toBe("[Ingestor] Ingest Dataset Failure"); + expect(action.err).toEqual(error); + }); + }); + + describe("resetIngestDataset", () => { + it("should create an action", () => { + const action = fromActions.resetIngestDataset(); + + expect(action.type).toBe("[Ingestor] Reset Ingest Dataset"); + }); + }); + + describe("cancelTransfer", () => { + it("should create an action with request body", () => { + const requestBody = { transferId: "transfer-123" }; + const action = fromActions.cancelTransfer({ requestBody }); + + expect(action.type).toBe("[Ingestor] Cancel Transfer"); + expect(action.requestBody).toEqual(requestBody); + }); + }); + + describe("setRenderViewFromThirdParty", () => { + it("should create an action with render view", () => { + const action = fromActions.setRenderViewFromThirdParty({ + renderView: "requiredOnly", + }); + + expect(action.type).toBe("[Ingestor] Set Render View"); + expect(action.renderView).toBe("requiredOnly"); + }); + }); + + describe("resetIngestorComponent", () => { + it("should create an action", () => { + const action = fromActions.resetIngestorComponent(); + + expect(action.type).toBe("[Ingestor] Reset Ingestor Component"); + }); + }); + + describe("setNoRightsError", () => { + it("should create an action with error flag and error", () => { + const error = new Error("No rights"); + const action = fromActions.setNoRightsError({ + noRightsError: true, + err: error, + }); + + expect(action.type).toBe("[Ingestor] Set No Rights Error"); + expect(action.noRightsError).toBe(true); + expect(action.err).toEqual(error); + }); + }); + + describe("createDatasetAction", () => { + it("should create an action with dataset", () => { + const dataset = { + datasetName: "Test Dataset", + type: "raw", + } as any; + const action = fromActions.createDatasetAction({ dataset }); + + expect(action.type).toBe("[Ingestor] Create Dataset"); + expect(action.dataset).toEqual(dataset); + }); + }); + + describe("createDatasetSuccess", () => { + it("should create an action with created dataset", () => { + const dataset = { + pid: "dataset-123", + datasetName: "Test Dataset", + } as any; + const action = fromActions.createDatasetSuccess({ dataset }); + + expect(action.type).toBe("[Ingestor] Create Dataset Success"); + expect(action.dataset).toEqual(dataset); + }); + }); +}); diff --git a/src/app/state-management/actions/ingestor.actions.ts b/src/app/state-management/actions/ingestor.actions.ts new file mode 100644 index 0000000000..9806a3d11d --- /dev/null +++ b/src/app/state-management/actions/ingestor.actions.ts @@ -0,0 +1,185 @@ +import { createAction, props } from "@ngrx/store"; +import { + APIInformation, + IngestionRequestInformation, + IngestorAutodiscovery, +} from "ingestor/ingestor-page/helper/ingestor.component-helper"; +import { + GetBrowseDatasetResponse, + GetExtractorResponse, + GetTransferResponse, + OtherHealthResponse, + OtherVersionResponse, + PostDatasetRequest, + PostDatasetResponse, + DeleteTransferRequest, + UserInfo, +} from "shared/sdk/models/ingestor/models"; + +import { renderView } from "ingestor/ingestor-metadata-editor/ingestor-metadata-editor.component"; +import { + DatasetsControllerCreateV3Request, + OutputDatasetObsoleteDto, +} from "@scicatproject/scicat-sdk-ts-angular"; + +export const setIngestorEndpoint = createAction( + "[Ingestor] Set ingestor endpoint", + props<{ ingestorEndpoint: IngestorAutodiscovery }>(), +); + +export const connectIngestor = createAction("[Ingestor] Connect Ingestor"); + +export const connectIngestorSuccess = createAction( + "[Ingestor] Completed Ingestor Connection", + props<{ + versionResponse: OtherVersionResponse; + userInfoResponse: UserInfo; + authIsDisabled: boolean; + healthResponse: OtherHealthResponse; + }>(), +); + +export const connectIngestorFailure = createAction( + "[Ingestor] Failed to connect to Ingestor ", + props<{ err: Error }>(), +); + +export const startConnectingIngestor = createAction( + "[Ingestor] Start connecting Ingestor", +); + +export const stopConnectingIngestor = createAction( + "[Ingestor] Stop connecting Ingestor", +); + +export const updateTransferList = createAction( + "[Ingestor] Update transfer list", + props<{ transferId?: string; page?: number; pageNumber?: number }>(), +); + +export const updateTransferListSuccess = createAction( + "[Ingestor] Update Transfer List Success", + props<{ + transferList: GetTransferResponse; + page: number; + pageNumber: number; + }>(), +); + +export const updateTransferListDetailSuccess = createAction( + "[Ingestor] Update Transfer List Detail Success", + props<{ transferListDetailView: GetTransferResponse }>(), +); + +export const updateTransferListFailure = createAction( + "[Ingestor] Update Transfer List Failure", + props<{ err: Error }>(), +); + +/* Optional pass an ingestionObject to set custom properties */ +export const resetIngestionObject = createAction( + "[Ingestor] Reset Ingestion Object", + props<{ ingestionObject?: IngestionRequestInformation }>(), +); + +export const updateIngestionObject = createAction( + "[Ingestor] Update Ingestion Object", + props<{ ingestionObject: IngestionRequestInformation }>(), +); + +export const updateIngestionObjectFromThirdParty = createAction( + "[Ingestor] Update Ingestion Object from Third Party", + props<{ ingestionObject: IngestionRequestInformation }>(), +); + +export const updateIngestionObjectAPIInformation = createAction( + "[Ingestor] Update Ingestion Object API Information", + props<{ ingestionObjectApiInformation: APIInformation }>(), +); + +export const resetIngestionObjectFromThirdPartyFlag = createAction( + "[Ingestor] Reset Ingestion Object from Third Party Flag", +); + +export const getExtractionMethods = createAction( + "[Ingestor] Get Extraction Methods", + props<{ page: number; pageNumber: number }>(), +); + +export const getExtractionMethodsSuccess = createAction( + "[Ingestor] Get Extraction Methods Success", + props<{ extractionMethods: GetExtractorResponse }>(), +); + +export const getExtractionMethodsFailure = createAction( + "[Ingestor] Get Extraction Methods Failure", + props<{ err: Error }>(), +); + +export const getBrowseFilePath = createAction( + "[Ingestor] Get Browse File Path", + props<{ path: string; page: number; pageNumber: number }>(), +); + +export const getBrowseFilePathSuccess = createAction( + "[Ingestor] Get Browse File Path Success", + props<{ ingestorBrowserActiveNode: GetBrowseDatasetResponse }>(), +); + +export const getBrowseFilePathFailure = createAction( + "[Ingestor] Get Browse File Path Failure", + props<{ err: Error }>(), +); + +export const ingestDataset = createAction( + "[Ingestor] Ingest Dataset", + props<{ ingestionDataset: PostDatasetRequest }>(), +); + +export const setIngestDatasetLoading = createAction( + "[Ingestor] Set Ingest Dataset Loading", + props<{ ingestionDatasetLoading: boolean }>(), +); + +export const ingestDatasetSuccess = createAction( + "[Ingestor] Ingest Dataset Success", + props<{ response: PostDatasetResponse }>(), +); + +export const ingestDatasetFailure = createAction( + "[Ingestor] Ingest Dataset Failure", + props<{ err: Error }>(), +); + +export const resetIngestDataset = createAction( + "[Ingestor] Reset Ingest Dataset", +); + +export const cancelTransfer = createAction( + "[Ingestor] Cancel Transfer", + props<{ requestBody: DeleteTransferRequest }>(), +); + +export const setRenderViewFromThirdParty = createAction( + "[Ingestor] Set Render View", + props<{ renderView: renderView }>(), +); + +export const resetIngestorComponent = createAction( + "[Ingestor] Reset Ingestor Component", +); + +export const setNoRightsError = createAction( + "[Ingestor] Set No Rights Error", + props<{ noRightsError: boolean; err: Error }>(), +); + +export const createDatasetAction = createAction( + "[Ingestor] Create Dataset", + props<{ dataset: DatasetsControllerCreateV3Request }>(), +); + +export const createDatasetSuccess = createAction( + "[Ingestor] Create Dataset Success", + props<{ dataset: OutputDatasetObsoleteDto }>(), +); diff --git a/src/app/state-management/effects/ingestor.effects.spec.ts b/src/app/state-management/effects/ingestor.effects.spec.ts new file mode 100644 index 0000000000..1c0ba723cc --- /dev/null +++ b/src/app/state-management/effects/ingestor.effects.spec.ts @@ -0,0 +1,500 @@ +import { TestBed } from "@angular/core/testing"; +import { provideMockActions } from "@ngrx/effects/testing"; +import { Observable, of, throwError } from "rxjs"; +import { IngestorEffects } from "state-management/effects/ingestor.effects"; +import { Ingestor } from "shared/sdk/apis/ingestor.service"; +import { DatasetsService } from "@scicatproject/scicat-sdk-ts-angular"; +import { provideMockStore, MockStore } from "@ngrx/store/testing"; +import * as fromActions from "state-management/actions/ingestor.actions"; +import { showMessageAction } from "state-management/actions/user.actions"; +import { MessageType } from "state-management/models"; +import { HttpErrorResponse } from "@angular/common/http"; +import { hot, cold } from "jasmine-marbles"; + +describe("IngestorEffects", () => { + let actions$: Observable; + let effects: IngestorEffects; + let ingestorService: jasmine.SpyObj; + let datasetsService: jasmine.SpyObj; + let store: MockStore; + + const mockVersionResponse = { version: "1.0.0" }; + const mockHealthResponse = { status: "ok" }; + const mockUserInfoResponse = { logged_in: true }; + + beforeEach(() => { + const ingestorSpy = jasmine.createSpyObj("Ingestor", [ + "getVersion", + "getHealth", + "getUserInfo", + "getTransferList", + "getExtractionMethods", + "getBrowseFilePath", + "startIngestion", + "cancelTransfer", + ]); + + const datasetsSpy = jasmine.createSpyObj("DatasetsService", [ + "datasetsControllerCreateV3", + ]); + + TestBed.configureTestingModule({ + providers: [ + IngestorEffects, + provideMockActions(() => actions$), + provideMockStore({ + initialState: { + ingestor: { + transferListRequestOptions: { page: 1, pageNumber: 50 }, + }, + }, + }), + { provide: Ingestor, useValue: ingestorSpy }, + { provide: DatasetsService, useValue: datasetsSpy }, + ], + }); + + effects = TestBed.inject(IngestorEffects); + ingestorService = TestBed.inject(Ingestor) as jasmine.SpyObj; + datasetsService = TestBed.inject( + DatasetsService, + ) as jasmine.SpyObj; + store = TestBed.inject(MockStore); + }); + + describe("connectToIngestor$", () => { + it("should dispatch connectIngestorSuccess on successful connection", (done) => { + ingestorService.getVersion.and.returnValue(of(mockVersionResponse)); + ingestorService.getHealth.and.returnValue(of(mockHealthResponse)); + ingestorService.getUserInfo.and.returnValue(of(mockUserInfoResponse)); + + actions$ = of(fromActions.connectIngestor()); + + effects.connectToIngestor$.subscribe((action) => { + expect(action).toEqual( + fromActions.connectIngestorSuccess({ + versionResponse: mockVersionResponse, + healthResponse: mockHealthResponse, + userInfoResponse: mockUserInfoResponse, + authIsDisabled: false, + }), + ); + done(); + }); + }); + + it("should dispatch connectIngestorSuccess with authIsDisabled when getUserInfo fails with disabled message", (done) => { + const error = new Error("Auth is disabled"); + ingestorService.getVersion.and.returnValue(of(mockVersionResponse)); + ingestorService.getHealth.and.returnValue(of(mockHealthResponse)); + ingestorService.getUserInfo.and.returnValue(throwError(() => error)); + + actions$ = of(fromActions.connectIngestor()); + + effects.connectToIngestor$.subscribe((action) => { + expect(action).toEqual( + fromActions.connectIngestorSuccess({ + versionResponse: mockVersionResponse, + healthResponse: mockHealthResponse, + userInfoResponse: null, + authIsDisabled: true, + }), + ); + done(); + }); + }); + + it("should dispatch connectIngestorFailure on connection failure", (done) => { + const error = new Error("Connection failed"); + ingestorService.getVersion.and.returnValue(throwError(() => error)); + + actions$ = of(fromActions.connectIngestor()); + + effects.connectToIngestor$.subscribe((action) => { + expect(action.type).toBe(fromActions.connectIngestorFailure.type); + done(); + }); + }); + + it("should dispatch setNoRightsError when session expired", (done) => { + const error = { error: { error: "login session has expired" } }; + ingestorService.getVersion.and.returnValue(throwError(() => error)); + + actions$ = of(fromActions.connectIngestor()); + + effects.connectToIngestor$.subscribe((action) => { + expect(action.type).toBe(fromActions.setNoRightsError.type); + done(); + }); + }); + }); + + describe("connectToIngestorSuccess$", () => { + it("should dispatch showMessageAction with success message", (done) => { + actions$ = of( + fromActions.connectIngestorSuccess({ + versionResponse: mockVersionResponse, + healthResponse: mockHealthResponse, + userInfoResponse: mockUserInfoResponse, + authIsDisabled: false, + }), + ); + + effects.connectToIngestorSuccess$.subscribe((action) => { + expect(action.type).toBe(showMessageAction.type); + expect(action.message.type).toBe(MessageType.Success); + expect(action.message.content).toContain("1.0.0"); + done(); + }); + }); + }); + + describe("connectToIngestorFailure$", () => { + it("should dispatch showMessageAction with error message", (done) => { + const error = new Error("Connection failed"); + actions$ = of(fromActions.connectIngestorFailure({ err: error })); + + effects.connectToIngestorFailure$.subscribe((action) => { + expect(action.type).toBe(showMessageAction.type); + expect(action.message.type).toBe(MessageType.Error); + expect(action.message.content).toContain("Failed to connect"); + done(); + }); + }); + }); + + describe("updateTransferList$", () => { + it("should dispatch updateTransferListSuccess on success", (done) => { + const transferList = { transfers: [], total: 0 }; + ingestorService.getTransferList.and.returnValue(of(transferList)); + + actions$ = of(fromActions.updateTransferList({})); + + effects.updateTransferList$.subscribe((action) => { + expect(action).toEqual( + fromActions.updateTransferListSuccess({ + transferList, + page: 1, + pageNumber: 50, + }), + ); + done(); + }); + }); + + it("should dispatch updateTransferListDetailSuccess when transferId provided", (done) => { + const transferList = { transfers: [], total: 0 }; + ingestorService.getTransferList.and.returnValue(of(transferList)); + + actions$ = of(fromActions.updateTransferList({ transferId: "123" })); + + effects.updateTransferList$.subscribe((action) => { + expect(action).toEqual( + fromActions.updateTransferListDetailSuccess({ + transferListDetailView: transferList, + }), + ); + done(); + }); + }); + + it("should dispatch updateTransferListFailure on error", (done) => { + const error = new Error("Failed to fetch"); + ingestorService.getTransferList.and.returnValue(throwError(() => error)); + + actions$ = of(fromActions.updateTransferList({})); + + effects.updateTransferList$.subscribe((action) => { + expect(action.type).toBe(fromActions.updateTransferListFailure.type); + done(); + }); + }); + }); + + describe("getExtractionMethods$", () => { + it("should dispatch getExtractionMethodsSuccess on success", (done) => { + const methods = { methods: [], total: 0 }; + ingestorService.getExtractionMethods.and.returnValue(of(methods)); + + actions$ = of( + fromActions.getExtractionMethods({ page: 1, pageNumber: 50 }), + ); + + effects.getExtractionMethods$.subscribe((action) => { + expect(action).toEqual( + fromActions.getExtractionMethodsSuccess({ + extractionMethods: methods, + }), + ); + done(); + }); + }); + + it("should dispatch setNoRightsError when session expired", (done) => { + const error = { error: { error: "login session has expired" } }; + ingestorService.getExtractionMethods.and.returnValue( + throwError(() => error), + ); + + actions$ = of( + fromActions.getExtractionMethods({ page: 1, pageNumber: 50 }), + ); + + effects.getExtractionMethods$.subscribe((action) => { + expect(action.type).toBe(fromActions.setNoRightsError.type); + done(); + }); + }); + + it("should dispatch getExtractionMethodsFailure on error", (done) => { + const error = new Error("Failed"); + ingestorService.getExtractionMethods.and.returnValue( + throwError(() => error), + ); + + actions$ = of( + fromActions.getExtractionMethods({ page: 1, pageNumber: 50 }), + ); + + effects.getExtractionMethods$.subscribe((action) => { + expect(action.type).toBe(fromActions.getExtractionMethodsFailure.type); + done(); + }); + }); + }); + + describe("getBrowseFilePath$", () => { + it("should dispatch getBrowseFilePathSuccess on success", (done) => { + const browseResult = { folders: [], total: 0 }; + ingestorService.getBrowseFilePath.and.returnValue(of(browseResult)); + + actions$ = of( + fromActions.getBrowseFilePath({ + path: "/test", + page: 1, + pageNumber: 50, + }), + ); + + effects.getBrowseFilePath$.subscribe((action) => { + expect(action).toEqual( + fromActions.getBrowseFilePathSuccess({ + ingestorBrowserActiveNode: browseResult, + }), + ); + done(); + }); + }); + + it("should dispatch getBrowseFilePathFailure on error", (done) => { + const error = new Error("Browse failed"); + ingestorService.getBrowseFilePath.and.returnValue( + throwError(() => error), + ); + + actions$ = of( + fromActions.getBrowseFilePath({ + path: "/test", + page: 1, + pageNumber: 50, + }), + ); + + effects.getBrowseFilePath$.subscribe((action) => { + expect(action.type).toBe(fromActions.getBrowseFilePathFailure.type); + done(); + }); + }); + }); + + describe("setLoadingBeforeIngestion$", () => { + it("should dispatch setIngestDatasetLoading", (done) => { + const ingestionDataset = { + metaData: "{}", + userToken: "token", + autoArchive: false, + }; + actions$ = of(fromActions.ingestDataset({ ingestionDataset })); + + effects.setLoadingBeforeIngestion$.subscribe((action) => { + expect(action).toEqual( + fromActions.setIngestDatasetLoading({ + ingestionDatasetLoading: true, + }), + ); + done(); + }); + }); + }); + + describe("ingestDataset$", () => { + it("should dispatch success actions on successful ingestion", (done) => { + const response = { transferId: "123", status: "success" }; + ingestorService.startIngestion.and.returnValue(of(response)); + + const ingestionDataset = { + metaData: "{}", + userToken: "token", + autoArchive: false, + }; + actions$ = of(fromActions.ingestDataset({ ingestionDataset })); + + const actions: any[] = []; + effects.ingestDataset$.subscribe((action) => { + actions.push(action); + if (actions.length === 3) { + expect(actions[0].type).toBe(fromActions.ingestDatasetSuccess.type); + expect(actions[1].type).toBe(fromActions.updateTransferList.type); + expect(actions[2].type).toBe( + fromActions.setIngestDatasetLoading.type, + ); + done(); + } + }); + }); + + it("should dispatch failure actions on error", (done) => { + const error = new Error("Ingestion failed"); + ingestorService.startIngestion.and.returnValue(throwError(() => error)); + + const ingestionDataset = { + metaData: "{}", + userToken: "token", + autoArchive: false, + }; + actions$ = of(fromActions.ingestDataset({ ingestionDataset })); + + const actions: any[] = []; + effects.ingestDataset$.subscribe((action) => { + actions.push(action); + if (actions.length === 2) { + expect(actions[0].type).toBe(fromActions.ingestDatasetFailure.type); + expect(actions[1].type).toBe( + fromActions.setIngestDatasetLoading.type, + ); + done(); + } + }); + }); + }); + + describe("ingestDatasetSuccess$", () => { + it("should dispatch showMessageAction with success message", (done) => { + const response = { transferId: "123", status: "success" }; + actions$ = of(fromActions.ingestDatasetSuccess({ response })); + + effects.ingestDatasetSuccess$.subscribe((action) => { + expect(action.type).toBe(showMessageAction.type); + expect(action.message.type).toBe(MessageType.Success); + expect(action.message.content).toContain("123"); + done(); + }); + }); + }); + + describe("ingestDatasetFailure$", () => { + it("should dispatch showMessageAction with error message", (done) => { + const error = new Error("Failed"); + actions$ = of(fromActions.ingestDatasetFailure({ err: error })); + + effects.ingestDatasetFailure$.subscribe((action) => { + expect(action.type).toBe(showMessageAction.type); + expect(action.message.type).toBe(MessageType.Error); + expect(action.message.content).toContain("Failed to ingest"); + done(); + }); + }); + }); + + describe("cancelTransfer$", () => { + it("should dispatch success actions on successful cancel", (done) => { + const deleteResponse = { transferId: "123" }; + ingestorService.cancelTransfer.and.returnValue(of(deleteResponse)); + + const requestBody = { transferId: "123" }; + actions$ = of(fromActions.cancelTransfer({ requestBody })); + + const actions: any[] = []; + effects.cancelTransfer$.subscribe((action) => { + actions.push(action); + if (actions.length === 2) { + expect(actions[0].type).toBe(showMessageAction.type); + expect((actions[0] as any).message.type).toBe(MessageType.Success); + expect(actions[1].type).toBe(fromActions.updateTransferList.type); + done(); + } + }); + }); + + it("should dispatch showMessageAction with error on failure", (done) => { + const error = new Error("Cancel failed"); + ingestorService.cancelTransfer.and.returnValue(throwError(() => error)); + + const requestBody = { transferId: "123" }; + actions$ = of(fromActions.cancelTransfer({ requestBody })); + + effects.cancelTransfer$.subscribe((action) => { + expect(action.type).toBe(showMessageAction.type); + expect((action as any).message.type).toBe(MessageType.Error); + done(); + }); + }); + }); + + describe("setRenderView$", () => { + it("should dispatch showMessageAction", (done) => { + actions$ = of( + fromActions.setRenderViewFromThirdParty({ renderView: "requiredOnly" }), + ); + + effects.setRenderView$.subscribe((action) => { + expect(action.type).toBe(showMessageAction.type); + expect(action.message.content).toContain("requiredOnly"); + done(); + }); + }); + }); + + describe("createDataset$", () => { + it("should dispatch success actions on successful creation", (done) => { + const dataset = { pid: "123", datasetName: "Test" } as any; + datasetsService.datasetsControllerCreateV3.and.returnValue(of(dataset)); + + actions$ = of(fromActions.createDatasetAction({ dataset })); + + const actions: any[] = []; + effects.createDataset$.subscribe((action) => { + actions.push(action); + if (actions.length === 2) { + expect(actions[0].type).toBe(fromActions.createDatasetSuccess.type); + expect(actions[1].type).toBe( + fromActions.setIngestDatasetLoading.type, + ); + done(); + } + }); + }); + + it("should dispatch failure actions on error", (done) => { + const error = new Error("Create failed"); + datasetsService.datasetsControllerCreateV3.and.returnValue( + throwError(() => error), + ); + + const dataset = { datasetName: "Test" } as any; + actions$ = of(fromActions.createDatasetAction({ dataset })); + + const actions: any[] = []; + effects.createDataset$.subscribe((action) => { + actions.push(action); + if (actions.length === 2) { + expect(actions[0].type).toBe(fromActions.ingestDatasetFailure.type); + expect(actions[1].type).toBe( + fromActions.setIngestDatasetLoading.type, + ); + done(); + } + }); + }); + }); +}); diff --git a/src/app/state-management/effects/ingestor.effects.ts b/src/app/state-management/effects/ingestor.effects.ts new file mode 100644 index 0000000000..a017473271 --- /dev/null +++ b/src/app/state-management/effects/ingestor.effects.ts @@ -0,0 +1,369 @@ +import { Injectable } from "@angular/core"; +import { Actions, createEffect, ofType } from "@ngrx/effects"; +import { from, of } from "rxjs"; +import { + catchError, + map, + mergeMap, + switchMap, + takeUntil, +} from "rxjs/operators"; // Import finalize +import * as fromActions from "state-management/actions/ingestor.actions"; +import { Ingestor } from "shared/sdk/apis/ingestor.service"; +import { + OtherHealthResponse, + OtherVersionResponse, + UserInfo, +} from "shared/sdk/models/ingestor/models"; +import { MessageType } from "state-management/models"; +import { HttpErrorResponse } from "@angular/common/http"; +import { showMessageAction } from "state-management/actions/user.actions"; +import { Store } from "@ngrx/store"; +import { selectIngestorTransferListRequestOptions } from "state-management/selectors/ingestor.selectors"; +import { concatLatestFrom } from "@ngrx/operators"; +import { DatasetsService } from "@scicatproject/scicat-sdk-ts-angular"; + +@Injectable() +export class IngestorEffects { + connectToIngestor$ = createEffect(() => { + return this.actions$.pipe( + ofType(fromActions.connectIngestor), + switchMap(() => { + return this.ingestor.getVersion().pipe( + switchMap((versionResponse) => + this.ingestor.getHealth().pipe( + switchMap((healthResponse) => + this.ingestor.getUserInfo().pipe( + map((userInfoResponse) => + fromActions.connectIngestorSuccess({ + versionResponse: versionResponse as OtherVersionResponse, + healthResponse: healthResponse as OtherHealthResponse, + userInfoResponse: userInfoResponse as UserInfo, + authIsDisabled: false, + }), + ), + catchError((err) => { + const errorMessage = + err instanceof HttpErrorResponse + ? (err.error?.message ?? err.error ?? err.message) + : err.message; + + if (errorMessage.includes("disabled")) { + return of( + fromActions.connectIngestorSuccess({ + versionResponse: + versionResponse as OtherVersionResponse, + healthResponse: healthResponse as OtherHealthResponse, + userInfoResponse: null, // no UserInfo available + authIsDisabled: true, + }), + ); + } + return of(fromActions.connectIngestorFailure({ err })); + }), + ), + ), + ), + ), + catchError((err) => { + if (err.error?.error?.includes("login session has expired")) { + return of( + fromActions.setNoRightsError({ noRightsError: true, err: err }), + ); + } + + return of(fromActions.connectIngestorFailure({ err })); + }), + takeUntil( + this.actions$.pipe(ofType(fromActions.resetIngestorComponent)), + ), + ); + }), + ); + }); + + connectToIngestorSuccess$ = createEffect(() => { + return this.actions$.pipe( + ofType(fromActions.connectIngestorSuccess), + switchMap(({ versionResponse }) => { + const message = { + type: MessageType.Success, + content: + "Successfully connected to ingestor version " + + versionResponse.version, + duration: 5000, + }; + return of(showMessageAction({ message })); + }), + ); + }); + + connectToIngestorFailure$ = createEffect(() => { + return this.actions$.pipe( + ofType(fromActions.connectIngestorFailure), + switchMap(({ err }) => { + const errorMessage = + err instanceof HttpErrorResponse + ? (err.error?.message ?? err.message ?? "Unknown error") + : err.message || "Unknown error"; + const message = { + type: MessageType.Error, + content: + "Failed to connect to the ingestor service: " + + errorMessage + + " Are you sure service is running?", + duration: 5000, + }; + return of(showMessageAction({ message })); + }), + ); + }); + + updateTransferList$ = createEffect(() => { + return this.actions$.pipe( + ofType(fromActions.updateTransferList), + concatLatestFrom(() => + this.store.select(selectIngestorTransferListRequestOptions), + ), + switchMap(([action, requestOptions]) => { + const { transferId, page, pageNumber } = action; + const { page: pageRO, pageNumber: pageNumberRO } = requestOptions; + + return this.ingestor + .getTransferList( + page ?? pageRO, + pageNumber ?? pageNumberRO, + transferId, + ) + .pipe( + map((transferList) => { + if (transferId) { + return fromActions.updateTransferListDetailSuccess({ + transferListDetailView: transferList, + }); + } + + return fromActions.updateTransferListSuccess({ + transferList, + page: page ?? pageRO, + pageNumber: pageNumber ?? pageNumberRO, + }); + }), + catchError((err) => + of(fromActions.updateTransferListFailure({ err })), + ), + ); + }), + ); + }); + + getExtractionMethods$ = createEffect(() => { + return this.actions$.pipe( + ofType(fromActions.getExtractionMethods), + switchMap(({ page, pageNumber }) => + this.ingestor.getExtractionMethods(page, pageNumber).pipe( + map((extractionMethods) => + fromActions.getExtractionMethodsSuccess({ extractionMethods }), + ), + catchError((err) => { + if (err.error?.error?.includes("login session has expired")) { + return of( + fromActions.setNoRightsError({ noRightsError: true, err: err }), + ); + } + + return of(fromActions.getExtractionMethodsFailure({ err })); + }), + ), + ), + ); + }); + + getBrowseFilePath$ = createEffect(() => { + return this.actions$.pipe( + ofType(fromActions.getBrowseFilePath), + switchMap(({ path, page, pageNumber }) => + this.ingestor.getBrowseFilePath(page, pageNumber, path).pipe( + map((ingestorBrowserActiveNode) => + fromActions.getBrowseFilePathSuccess({ ingestorBrowserActiveNode }), + ), + catchError((err) => { + if (err.error?.error?.includes("login session has expired")) { + return of( + fromActions.setNoRightsError({ noRightsError: true, err: err }), + ); + } + + return of(fromActions.getBrowseFilePathFailure({ err })); + }), + ), + ), + ); + }); + + setLoadingBeforeIngestion$ = createEffect(() => { + return this.actions$.pipe( + ofType(fromActions.ingestDataset), + map(() => + fromActions.setIngestDatasetLoading({ ingestionDatasetLoading: true }), + ), + ); + }); + + ingestDataset$ = createEffect(() => { + return this.actions$.pipe( + ofType(fromActions.ingestDataset), + switchMap(({ ingestionDataset }) => + this.ingestor.startIngestion(ingestionDataset).pipe( + mergeMap((response) => + from([ + fromActions.ingestDatasetSuccess({ response }), + fromActions.updateTransferList({}), + fromActions.setIngestDatasetLoading({ + ingestionDatasetLoading: false, + }), + ]), + ), + catchError((err) => + from([ + ...(err.error?.error?.includes("login session has expired") + ? [ + fromActions.setNoRightsError({ + noRightsError: true, + err, + }), + ] + : [fromActions.ingestDatasetFailure({ err })]), + fromActions.setIngestDatasetLoading({ + ingestionDatasetLoading: false, + }), + ]), + ), + ), + ), + ); + }); + + ingestDatasetSuccess$ = createEffect(() => { + return this.actions$.pipe( + ofType(fromActions.ingestDatasetSuccess), + switchMap(({ response }) => { + const message = { + type: MessageType.Success, + content: + "Request ingestion dataset successfully: " + response.transferId, + duration: 5000, + }; + return of(showMessageAction({ message })); + }), + ); + }); + + ingestDatasetFailure$ = createEffect(() => { + return this.actions$.pipe( + ofType(fromActions.ingestDatasetFailure), + switchMap(({ err }) => { + const errorMessage = + err instanceof HttpErrorResponse + ? (err.error?.message ?? + err.error ?? + err.message ?? + "Unknown error") + : err.message || "Unknown error"; + const message = { + type: MessageType.Error, + content: "Failed to ingest dataset: " + errorMessage, + duration: 5000, + }; + return of(showMessageAction({ message })); + }), + ); + }); + + cancelTransfer$ = createEffect(() => { + return this.actions$.pipe( + ofType(fromActions.cancelTransfer), + switchMap(({ requestBody }) => + this.ingestor.cancelTransfer(requestBody).pipe( + mergeMap(() => + from([ + showMessageAction({ + message: { + type: MessageType.Success, + content: + "Successfully cancelled transfer: " + + requestBody.transferId, + duration: 5000, + }, + }), + fromActions.updateTransferList({}), + ]), + ), + catchError((err) => { + const message = { + type: MessageType.Error, + content: + "Failed to cancel transfer: " + (err.error ?? err.message), + duration: 5000, + }; + return of(showMessageAction({ message })); + }), + ), + ), + ); + }); + + setRenderView$ = createEffect(() => { + return this.actions$.pipe( + ofType(fromActions.setRenderViewFromThirdParty), + switchMap(({ renderView }) => { + const message = { + type: MessageType.Success, + content: "Render view set to: " + renderView, + duration: 5000, + }; + return of(showMessageAction({ message })); + }), + ); + }); + + createDataset$ = createEffect(() => { + return this.actions$.pipe( + ofType(fromActions.createDatasetAction), + mergeMap(({ dataset }) => + this.datasetsService.datasetsControllerCreateV3(dataset).pipe( + mergeMap((response) => + from([ + fromActions.createDatasetSuccess({ dataset: response }), + fromActions.setIngestDatasetLoading({ + ingestionDatasetLoading: false, + }), + ]), + ), + catchError((err) => + from([ + ...(err.error?.error?.includes("login session has expired") + ? [ + fromActions.setNoRightsError({ + noRightsError: true, + err, + }), + ] + : [fromActions.ingestDatasetFailure({ err })]), + fromActions.setIngestDatasetLoading({ + ingestionDatasetLoading: false, + }), + ]), + ), + ), + ), + ); + }); + + constructor( + private actions$: Actions, + private ingestor: Ingestor, + private datasetsService: DatasetsService, + private store: Store, + ) {} +} diff --git a/src/app/state-management/models/index.ts b/src/app/state-management/models/index.ts index 686aeb7f10..7abfce5121 100644 --- a/src/app/state-management/models/index.ts +++ b/src/app/state-management/models/index.ts @@ -2,6 +2,7 @@ import { FilterConfig, ConditionConfig, } from "state-management/state/user.store"; +import { IngestorAutodiscovery } from "ingestor/ingestor-page/helper/ingestor.component-helper"; export interface Settings { tapeCopies: string; @@ -31,6 +32,12 @@ export interface DatasetDetailComponentConfig { enableCustomizedComponent: boolean; customization: CustomizationItem[]; } + +export interface IngestorComponentConfig { + ingestorEnabled: boolean; + ingestorAutodiscoveryOptions?: IngestorAutodiscovery[]; +} + export enum DatasetViewFieldType { TEXT = "text", DATE = "date", @@ -92,6 +99,7 @@ export interface ListSettings { export enum MessageType { Success = "success", Error = "error", + Info = "info", } export interface Message { diff --git a/src/app/state-management/reducers/ingestor.reducer.spec.ts b/src/app/state-management/reducers/ingestor.reducer.spec.ts new file mode 100644 index 0000000000..36b0f6acda --- /dev/null +++ b/src/app/state-management/reducers/ingestor.reducer.spec.ts @@ -0,0 +1,380 @@ +import { ingestorReducer } from "./ingestor.reducer"; +import * as fromActions from "state-management/actions/ingestor.actions"; +import { initialIngestorState } from "state-management/state/ingestor.store"; +import { + mockIngestionRequestInformation, + mockMethodItems, +} from "shared/MockStubs"; + +describe("IngestorReducer", () => { + describe("undefined action", () => { + it("should return the default state", () => { + const action = { type: "NOOP" } as any; + const result = ingestorReducer(undefined, action); + + expect(result).toEqual(initialIngestorState); + }); + }); + + describe("setRenderViewFromThirdParty", () => { + it("should set render view and update flag", () => { + const action = fromActions.setRenderViewFromThirdParty({ + renderView: "requiredOnly", + }); + const result = ingestorReducer(undefined, action); + + expect(result.renderView).toBe("requiredOnly"); + expect(result.updateEditorFromThirdParty).toBe(true); + }); + }); + + describe("getBrowseFilePathSuccess", () => { + it("should set active node on success", () => { + const mockNode = { + folders: [ + { + name: "test", + path: "/test", + children: true, + probablyDataset: false, + }, + ], + total: 1, + }; + const action = fromActions.getBrowseFilePathSuccess({ + ingestorBrowserActiveNode: mockNode, + }); + const result = ingestorReducer(undefined, action); + + expect(result.ingestorBrowserActiveNode).toEqual(mockNode); + }); + }); + + describe("getBrowseFilePathFailure", () => { + it("should set error and clear active node on failure", () => { + const error = new Error("Failed to browse"); + const action = fromActions.getBrowseFilePathFailure({ err: error }); + const result = ingestorReducer(undefined, action); + + expect(result.ingestorBrowserActiveNode).toBeNull(); + expect(result.error).toBe(JSON.stringify(error)); + }); + }); + + describe("getExtractionMethodsSuccess", () => { + it("should set extraction methods on success", () => { + const mockMethods = { methods: mockMethodItems, total: 2 }; + const action = fromActions.getExtractionMethodsSuccess({ + extractionMethods: mockMethods, + }); + const result = ingestorReducer(undefined, action); + + expect(result.ingestorExtractionMethods).toEqual(mockMethods); + }); + }); + + describe("getExtractionMethodsFailure", () => { + it("should set error and clear extraction methods on failure", () => { + const error = new Error("Failed to get methods"); + const action = fromActions.getExtractionMethodsFailure({ err: error }); + const result = ingestorReducer(undefined, action); + + expect(result.ingestorExtractionMethods).toBeNull(); + expect(result.error).toBe(JSON.stringify(error)); + }); + }); + + describe("setIngestorEndpoint", () => { + it("should set ingestor endpoint", () => { + const endpoint = { + mailDomain: "", + description: "", + facilityBackend: "http://localhost:3000", + }; + const action = fromActions.setIngestorEndpoint({ + ingestorEndpoint: endpoint, + }); + const result = ingestorReducer(undefined, action); + + expect(result.ingestorEndpoint).toEqual(endpoint); + }); + }); + + describe("connectIngestor", () => { + it("should set connecting flag to true", () => { + const action = fromActions.connectIngestor(); + const result = ingestorReducer(undefined, action); + + expect(result.connectingBackend).toBe(true); + }); + }); + + describe("connectIngestorSuccess", () => { + it("should set connection status on success", () => { + const versionResponse = { version: "1.0.0" }; + const healthResponse = { status: "ok" }; + const userInfoResponse = { logged_in: true }; + const authIsDisabled = false; + + const action = fromActions.connectIngestorSuccess({ + versionResponse, + userInfoResponse, + authIsDisabled, + healthResponse, + }); + const result = ingestorReducer(undefined, action); + + expect(result.connectingBackend).toBe(false); + expect(result.ingestorStatus.validEndpoint).toBe(true); + expect(result.ingestorStatus.versionResponse).toEqual(versionResponse); + expect(result.ingestorStatus.healthResponse).toEqual(healthResponse); + expect(result.ingestorAuth.userInfoResponse).toEqual(userInfoResponse); + expect(result.ingestorAuth.authIsDisabled).toBe(false); + }); + }); + + describe("connectIngestorFailure", () => { + it("should set error and invalid endpoint on failure", () => { + const error = new Error("Connection failed"); + const action = fromActions.connectIngestorFailure({ err: error }); + const result = ingestorReducer(undefined, action); + + expect(result.connectingBackend).toBe(false); + expect(result.ingestorStatus.validEndpoint).toBe(false); + expect(result.error).toBe(JSON.stringify(error)); + }); + }); + + describe("updateTransferListSuccess", () => { + it("should update transfer list with pagination", () => { + const transferList = { transfers: [], total: 0 }; + const page = 1; + const pageNumber = 50; + + const action = fromActions.updateTransferListSuccess({ + transferList, + page, + pageNumber, + }); + const result = ingestorReducer(undefined, action); + + expect(result.ingestorTransferList).toEqual(transferList); + expect(result.transferListRequestOptions.page).toBe(page); + expect(result.transferListRequestOptions.pageNumber).toBe(pageNumber); + }); + }); + + describe("updateTransferListDetailSuccess", () => { + it("should update transfer detail list", () => { + const transferListDetailView = { transfers: [] }; + const action = fromActions.updateTransferListDetailSuccess({ + transferListDetailView, + }); + const result = ingestorReducer(undefined, action); + + expect(result.ingestorTransferListDetailView).toEqual( + transferListDetailView, + ); + }); + }); + + describe("updateIngestionObject", () => { + it("should update ingestion object", () => { + const action = fromActions.updateIngestionObject({ + ingestionObject: mockIngestionRequestInformation, + }); + const result = ingestorReducer(undefined, action); + + expect(result.ingestionObject).toEqual(mockIngestionRequestInformation); + }); + }); + + describe("updateIngestionObjectAPIInformation", () => { + it("should update API information", () => { + const apiInfo = { + ingestionDatasetLoading: true, + extractorMetaDataReady: true, + extractMetaDataRequested: false, + metaDataExtractionFailed: false, + extractorMetadataProgress: 0, + extractorMetaDataStatus: "", + ingestionRequestErrorMessage: "", + }; + const action = fromActions.updateIngestionObjectAPIInformation({ + ingestionObjectApiInformation: apiInfo, + }); + const result = ingestorReducer(undefined, action); + + expect(result.ingestionObjectApiInformation.ingestionDatasetLoading).toBe( + true, + ); + expect(result.ingestionObjectApiInformation.extractorMetaDataReady).toBe( + true, + ); + }); + }); + + describe("resetIngestionObject", () => { + it("should reset ingestion object to provided value", () => { + const customObject = { + ...mockIngestionRequestInformation, + selectedPath: "/custom", + }; + const action = fromActions.resetIngestionObject({ + ingestionObject: customObject, + }); + const result = ingestorReducer(undefined, action); + + expect(result.ingestionObject.selectedPath).toBe("/custom"); + }); + + it("should reset to empty when no object provided", () => { + const action = fromActions.resetIngestionObject({ + ingestionObject: null, + }); + const result = ingestorReducer(undefined, action); + + expect(result.ingestionObject).toBeDefined(); + }); + }); + + describe("updateIngestionObjectFromThirdParty", () => { + it("should update object and set third party flag", () => { + const action = fromActions.updateIngestionObjectFromThirdParty({ + ingestionObject: mockIngestionRequestInformation, + }); + const result = ingestorReducer(undefined, action); + + expect(result.ingestionObject).toEqual(mockIngestionRequestInformation); + expect(result.updateEditorFromThirdParty).toBe(true); + }); + }); + + describe("resetIngestionObjectFromThirdPartyFlag", () => { + it("should reset third party update flag", () => { + // First set the flag to true + const setFlagAction = fromActions.setRenderViewFromThirdParty({ + renderView: "all", + }); + ingestorReducer(undefined, setFlagAction); + + // Then reset it + const action = fromActions.resetIngestionObjectFromThirdPartyFlag(); + const result = ingestorReducer(undefined, action); + + expect(result.updateEditorFromThirdParty).toBe(false); + }); + }); + + describe("ingestDatasetSuccess", () => { + it("should set ingestion response on success", () => { + const response = { transferId: "123", status: "success" }; + const action = fromActions.ingestDatasetSuccess({ response }); + const result = ingestorReducer(undefined, action); + + expect(result.ingestionObject.ingestionRequest).toEqual(response); + expect( + result.ingestionObjectApiInformation.ingestionRequestErrorMessage, + ).toBe(""); + }); + }); + + describe("createDatasetSuccess", () => { + it("should set dataset creation response", () => { + const dataset = { + pid: "dataset-123", + datasetName: "Test Dataset", + } as any; + const action = fromActions.createDatasetSuccess({ dataset }); + const result = ingestorReducer(undefined, action); + + expect(result.ingestionObject.ingestionRequest.transferId).toBe( + "dataset-123", + ); + expect(result.ingestionObject.ingestionRequest.status).toBe( + "Test Dataset", + ); + }); + }); + + describe("ingestDatasetFailure", () => { + it("should set error message on failure", () => { + const error = new Error("Bad request"); + const action = fromActions.ingestDatasetFailure({ err: error }); + const result = ingestorReducer(undefined, action); + + expect( + result.ingestionObjectApiInformation.ingestionRequestErrorMessage, + ).toBeDefined(); + expect(result.error).toBe(JSON.stringify(error)); + }); + }); + + describe("resetIngestDataset", () => { + it("should clear ingestion request and errors", () => { + // First set up state with a request and error + const successAction = fromActions.ingestDatasetSuccess({ + response: { transferId: "123", status: "done" }, + }); + ingestorReducer(undefined, successAction); + + // Then reset + const resetAction = fromActions.resetIngestDataset(); + const result = ingestorReducer(undefined, resetAction); + + expect(result.ingestionObject.ingestionRequest).toBeNull(); + expect( + result.ingestionObjectApiInformation.ingestionRequestErrorMessage, + ).toBe(""); + }); + }); + + describe("resetIngestorComponent", () => { + it("should reset entire state to initial", () => { + // Build up some modified state + const setEndpointAction = fromActions.setIngestorEndpoint({ + ingestorEndpoint: { + mailDomain: "", + description: "", + facilityBackend: "http://test.com", + }, + }); + const connectAction = fromActions.connectIngestor(); + ingestorReducer(undefined, setEndpointAction); + ingestorReducer(undefined, connectAction); + + // Then reset everything + const resetAction = fromActions.resetIngestorComponent(); + const result = ingestorReducer(undefined, resetAction); + + expect(result).toEqual(initialIngestorState); + }); + }); + + describe("setNoRightsError", () => { + it("should set no rights error and update auth status", () => { + const error = new Error("No rights"); + const action = fromActions.setNoRightsError({ + noRightsError: true, + err: error, + }); + const result = ingestorReducer(undefined, action); + + expect(result.noRightsError).toBe(true); + expect(result.ingestorAuth.userInfoResponse.logged_in).toBe(false); + }); + }); + + describe("setIngestDatasetLoading", () => { + it("should set loading state", () => { + const action = fromActions.setIngestDatasetLoading({ + ingestionDatasetLoading: true, + }); + const result = ingestorReducer(undefined, action); + + expect(result.ingestionObjectApiInformation.ingestionDatasetLoading).toBe( + true, + ); + }); + }); +}); diff --git a/src/app/state-management/reducers/ingestor.reducer.ts b/src/app/state-management/reducers/ingestor.reducer.ts new file mode 100644 index 0000000000..920558209c --- /dev/null +++ b/src/app/state-management/reducers/ingestor.reducer.ts @@ -0,0 +1,248 @@ +import { createReducer, Action, on } from "@ngrx/store"; +import { IngestorHelper } from "ingestor/ingestor-page/helper/ingestor.component-helper"; +import * as fromActions from "state-management/actions/ingestor.actions"; +import { + IngestorState, + initialIngestorState, +} from "state-management/state/ingestor.store"; + +export const ingestorReducer = (state: undefined, action: Action) => { + if (action.type.indexOf("[Ingestor]") !== -1) { + console.log("Action came in! " + action.type); + } + return reducer(state, action); +}; +const reducer = createReducer( + initialIngestorState, + on( + fromActions.setRenderViewFromThirdParty, + (state, { renderView }): IngestorState => ({ + ...state, + renderView: renderView, + updateEditorFromThirdParty: true, + }), + ), + on( + fromActions.getBrowseFilePathSuccess, + (state, { ingestorBrowserActiveNode }): IngestorState => ({ + ...state, + ingestorBrowserActiveNode: ingestorBrowserActiveNode, + }), + ), + on( + fromActions.getBrowseFilePathFailure, + (state, { err }): IngestorState => ({ + ...state, + ingestorBrowserActiveNode: null, + error: JSON.stringify(err), + }), + ), + on( + fromActions.getExtractionMethodsSuccess, + (state, { extractionMethods }): IngestorState => ({ + ...state, + ingestorExtractionMethods: extractionMethods, + }), + ), + on( + fromActions.getExtractionMethodsFailure, + (state, { err }): IngestorState => ({ + ...state, + ingestorExtractionMethods: null, + error: JSON.stringify(err), + }), + ), + on( + fromActions.setIngestorEndpoint, + (state, { ingestorEndpoint }): IngestorState => ({ + ...state, + ingestorEndpoint, + }), + ), + on( + fromActions.connectIngestor, + (state): IngestorState => ({ + ...state, + connectingBackend: true, + }), + ), + on( + fromActions.connectIngestorSuccess, + ( + state, + { versionResponse, userInfoResponse, authIsDisabled, healthResponse }, + ): IngestorState => ({ + ...state, + connectingBackend: false, + ingestorStatus: { + versionResponse, + healthResponse, + validEndpoint: true, + }, + ingestorAuth: { + userInfoResponse, + authIsDisabled, + }, + }), + ), + on( + fromActions.connectIngestorFailure, + (state, { err }): IngestorState => ({ + ...state, + connectingBackend: false, + ingestorStatus: { + ...state.ingestorStatus, + validEndpoint: false, + }, + error: JSON.stringify(err), + }), + ), + on( + fromActions.updateTransferListSuccess, + (state, { transferList, page, pageNumber }): IngestorState => ({ + ...state, + ingestorTransferList: transferList, + transferListRequestOptions: { + page, + pageNumber, + }, + }), + ), + on( + fromActions.updateTransferListDetailSuccess, + (state, { transferListDetailView }): IngestorState => ({ + ...state, + ingestorTransferListDetailView: transferListDetailView, + }), + ), + on( + fromActions.updateIngestionObject, + (state, { ingestionObject }): IngestorState => ({ + ...state, + ingestionObject: { + ...ingestionObject, + }, + }), + ), + on( + fromActions.updateIngestionObjectAPIInformation, + (state, { ingestionObjectApiInformation }): IngestorState => ({ + ...state, + ingestionObjectApiInformation: { + ...state.ingestionObjectApiInformation, + ...ingestionObjectApiInformation, + }, + }), + ), + on( + fromActions.resetIngestionObject, + (state, { ingestionObject }): IngestorState => ({ + ...state, + ingestionObject: + ingestionObject ?? IngestorHelper.createEmptyRequestInformation(), + ingestionObjectApiInformation: IngestorHelper.createEmptyAPIInformation(), + }), + ), + on( + fromActions.updateIngestionObjectFromThirdParty, + (state, { ingestionObject }): IngestorState => ({ + ...state, + ingestionObject, + updateEditorFromThirdParty: true, + }), + ), + on( + fromActions.resetIngestionObjectFromThirdPartyFlag, + (state): IngestorState => ({ + ...state, + updateEditorFromThirdParty: false, + }), + ), + on( + fromActions.ingestDatasetSuccess, + (state, { response }): IngestorState => ({ + ...state, + ingestionObjectApiInformation: { + ...state.ingestionObjectApiInformation, + ingestionRequestErrorMessage: "", + }, + ingestionObject: { + ...state.ingestionObject, + ingestionRequest: response, + }, + }), + ), + on( + fromActions.createDatasetSuccess, + (state, { dataset }): IngestorState => ({ + ...state, + ingestionObjectApiInformation: { + ...state.ingestionObjectApiInformation, + ingestionRequestErrorMessage: "", + }, + ingestionObject: { + ...state.ingestionObject, + ingestionRequest: { + transferId: dataset.pid, + status: dataset.datasetName, + }, + }, + }), + ), + on( + fromActions.ingestDatasetFailure, + (state, { err }): IngestorState => ({ + ...state, + ingestionObjectApiInformation: { + ...state.ingestionObjectApiInformation, + ingestionRequestErrorMessage: + (err as any).error ?? + (err as any).error?.error ?? + err.message ?? + JSON.stringify(err), + }, + error: JSON.stringify(err), + }), + ), + on( + fromActions.resetIngestDataset, + (state): IngestorState => ({ + ...state, + ingestionObject: { + ...state.ingestionObject, + ingestionRequest: null, + }, + ingestionObjectApiInformation: { + ...state.ingestionObjectApiInformation, + ingestionRequestErrorMessage: "", + }, + }), + ), + on( + fromActions.resetIngestorComponent, + (): IngestorState => initialIngestorState, + ), + on( + fromActions.setNoRightsError, + (state, { noRightsError }): IngestorState => ({ + ...state, + noRightsError, + ingestorAuth: { + ...state.ingestorAuth, + userInfoResponse: { + logged_in: false, + }, + }, + }), + ), + on( + fromActions.setIngestDatasetLoading, + (state, { ingestionDatasetLoading }): IngestorState => ({ + ...state, + ingestionObjectApiInformation: { + ...state.ingestionObjectApiInformation, + ingestionDatasetLoading, + }, + }), + ), +); diff --git a/src/app/state-management/selectors/ingestor.selectors.spec.ts b/src/app/state-management/selectors/ingestor.selectors.spec.ts new file mode 100644 index 0000000000..e20c6cdb48 --- /dev/null +++ b/src/app/state-management/selectors/ingestor.selectors.spec.ts @@ -0,0 +1,146 @@ +import * as fromSelectors from "./ingestor.selectors"; +import { IngestorState } from "state-management/state/ingestor.store"; +import { + mockIngestionRequestInformation, + mockMethodItems, + mockFolderNodes, +} from "shared/MockStubs"; + +describe("Ingestor Selectors", () => { + const mockIngestorState: IngestorState = { + ingestorEndpoint: { + mailDomain: "", + description: "example facility", + facilityBackend: "http://localhost:3000", + }, + ingestorStatus: { + versionResponse: { version: "1.0.0" }, + healthResponse: { status: "ok" }, + validEndpoint: true, + }, + ingestorAuth: { + userInfoResponse: null, + authIsDisabled: false, + }, + error: "Test error", + connectingBackend: false, + ingestorTransferList: { transfers: [] }, + ingestorTransferListDetailView: { transfers: [] }, + ingestionObject: mockIngestionRequestInformation, + ingestorExtractionMethods: { methods: mockMethodItems, total: 2 }, + ingestorBrowserActiveNode: { folders: mockFolderNodes, total: 3 }, + renderView: "all", + transferListRequestOptions: { page: 1, pageNumber: 5 }, + updateEditorFromThirdParty: false, + noRightsError: false, + ingestionObjectApiInformation: { + ingestionDatasetLoading: true, + extractorMetaDataReady: false, + extractMetaDataRequested: false, + metaDataExtractionFailed: false, + extractorMetaDataStatus: "", + extractorMetadataProgress: 0, + }, + } as any; + + const mockState = { + ingestor: mockIngestorState, + }; + + it("should select the ingestor state", () => { + const result = fromSelectors.selectIngestorState(mockState); + expect(result).toEqual(mockIngestorState); + }); + + it("should select ingestorEndpoint", () => { + const result = fromSelectors.selectIngestorEndpoint(mockState); + expect(result).toEqual({ + mailDomain: "", + description: "example facility", + facilityBackend: "http://localhost:3000", + }); + }); + + it("should select ingestorStatus", () => { + const result = fromSelectors.selectIngestorStatus(mockState); + expect(result).toEqual({ + versionResponse: { version: "1.0.0" }, + healthResponse: { status: "ok" }, + validEndpoint: true, + }); + }); + + it("should select ingestorAuth", () => { + const result = fromSelectors.selectIngestorAuth(mockState); + expect(result).toEqual({ + userInfoResponse: null, + authIsDisabled: false, + }); + }); + + it("should select ingestorError", () => { + const result = fromSelectors.selectIngestorError(mockState); + expect(result).toBe("Test error"); + }); + + it("should select ingestorConnecting", () => { + const result = fromSelectors.selectIngestorConnecting(mockState); + expect(result).toBe(false); + }); + + it("should select ingestorTransferList", () => { + const result = fromSelectors.selectIngestorTransferList(mockState); + expect(result).toEqual({ transfers: [] }); + }); + + it("should select ingestorTransferDetailList", () => { + const result = fromSelectors.selectIngestorTransferDetailList(mockState); + expect(result).toEqual({ transfers: [] }); + }); + + it("should select ingestionObject", () => { + const result = fromSelectors.selectIngestionObject(mockState); + expect(result).toEqual(mockIngestionRequestInformation); + }); + + it("should select ingestorExtractionMethods", () => { + const result = fromSelectors.selectIngestorExtractionMethods(mockState); + expect(result).toEqual({ methods: mockMethodItems, total: 2 }); + }); + + it("should select ingestorBrowserActiveNode", () => { + const result = fromSelectors.selectIngestorBrowserActiveNode(mockState); + expect(result).toEqual({ folders: mockFolderNodes, total: 3 }); + }); + + it("should select ingestorRenderView", () => { + const result = fromSelectors.selectIngestorRenderView(mockState); + expect(result).toBe("all"); + }); + + it("should select ingestorTransferListRequestOptions", () => { + const result = + fromSelectors.selectIngestorTransferListRequestOptions(mockState); + expect(result).toEqual({ page: 1, pageNumber: 5 }); + }); + + it("should select updateEditorFromThirdParty", () => { + const result = fromSelectors.selectUpdateEditorFromThirdParty(mockState); + expect(result).toBe(false); + }); + + it("should select noRightsError", () => { + const result = fromSelectors.selectNoRightsError(mockState); + expect(result).toBe(false); + }); + + it("should select isIngestDatasetLoading", () => { + const result = fromSelectors.selectIsIngestDatasetLoading(mockState); + expect(result).toBe(true); + }); + + it("should select ingestionObjectAPIInformation", () => { + const result = fromSelectors.selectIngestionObjectAPIInformation(mockState); + expect(result).toEqual(mockIngestorState.ingestionObjectApiInformation); + }); +}); diff --git a/src/app/state-management/selectors/ingestor.selectors.ts b/src/app/state-management/selectors/ingestor.selectors.ts new file mode 100644 index 0000000000..10c523799b --- /dev/null +++ b/src/app/state-management/selectors/ingestor.selectors.ts @@ -0,0 +1,85 @@ +import { createFeatureSelector, createSelector } from "@ngrx/store"; +import { IngestorState } from "state-management/state/ingestor.store"; + +export const selectIngestorState = + createFeatureSelector("ingestor"); + +export const selectIngestorEndpoint = createSelector( + selectIngestorState, + (state) => state.ingestorEndpoint, +); + +export const selectIngestorStatus = createSelector( + selectIngestorState, + (state) => state.ingestorStatus, +); + +export const selectIngestorAuth = createSelector( + selectIngestorState, + (state) => state.ingestorAuth, +); + +export const selectIngestorError = createSelector( + selectIngestorState, + (state) => state.error, +); + +export const selectIngestorConnecting = createSelector( + selectIngestorState, + (state) => state.connectingBackend, +); + +export const selectIngestorTransferList = createSelector( + selectIngestorState, + (state) => state.ingestorTransferList, +); + +export const selectIngestorTransferDetailList = createSelector( + selectIngestorState, + (state) => state.ingestorTransferListDetailView, +); + +export const selectIngestionObject = createSelector( + selectIngestorState, + (state) => state.ingestionObject, +); + +export const selectIngestorExtractionMethods = createSelector( + selectIngestorState, + (state) => state.ingestorExtractionMethods, +); + +export const selectIngestorBrowserActiveNode = createSelector( + selectIngestorState, + (state) => state.ingestorBrowserActiveNode, +); + +export const selectIngestorRenderView = createSelector( + selectIngestorState, + (state) => state.renderView, +); + +export const selectIngestorTransferListRequestOptions = createSelector( + selectIngestorState, + (state) => state.transferListRequestOptions, +); + +export const selectUpdateEditorFromThirdParty = createSelector( + selectIngestorState, + (state) => state.updateEditorFromThirdParty, +); + +export const selectNoRightsError = createSelector( + selectIngestorState, + (state) => state.noRightsError, +); + +export const selectIsIngestDatasetLoading = createSelector( + selectIngestorState, + (state) => state.ingestionObjectApiInformation.ingestionDatasetLoading, +); + +export const selectIngestionObjectAPIInformation = createSelector( + selectIngestorState, + (state) => state.ingestionObjectApiInformation, +); diff --git a/src/app/state-management/state/ingestor.store.ts b/src/app/state-management/state/ingestor.store.ts new file mode 100644 index 0000000000..77b5edc724 --- /dev/null +++ b/src/app/state-management/state/ingestor.store.ts @@ -0,0 +1,78 @@ +import { renderView } from "ingestor/ingestor-metadata-editor/ingestor-metadata-editor.component"; +import { + APIInformation, + IngestorAutodiscovery, + IngestionRequestInformation, + IngestorHelper, +} from "ingestor/ingestor-page/helper/ingestor.component-helper"; +import { + GetBrowseDatasetResponse, + GetExtractorResponse, + GetTransferResponse, + OtherHealthResponse, + OtherVersionResponse, + UserInfo, +} from "shared/sdk/models/ingestor/models"; + +interface IngestorAuthentication { + userInfoResponse: UserInfo | null; + authIsDisabled: boolean; +} + +interface IngestorStatus { + versionResponse: OtherVersionResponse | null; + healthResponse: OtherHealthResponse | null; + validEndpoint: boolean | null; +} + +export interface IngestorState { + ingestorStatus: IngestorStatus; + ingestorAuth: IngestorAuthentication | null; + ingestorEndpoint: IngestorAutodiscovery | null; + ingestorTransferList: GetTransferResponse | null; + ingestorTransferListDetailView: GetTransferResponse | null; + transferListRequestOptions: { + page: number; + pageNumber: number; + }; + ingestorExtractionMethods: GetExtractorResponse | null; + + ingestionObject: IngestionRequestInformation; + ingestionObjectApiInformation: APIInformation; + + ingestorBrowserActiveNode: GetBrowseDatasetResponse | null; + renderView: renderView; + updateEditorFromThirdParty: boolean; + connectingBackend: boolean; + noRightsError: boolean; + error: any | null; +} +export const initialIngestorState: IngestorState = { + ingestorStatus: { + versionResponse: null, + healthResponse: null, + validEndpoint: null, + }, + ingestorAuth: null, + ingestorEndpoint: { + mailDomain: "", + description: "", + facilityBackend: "", + }, + ingestorTransferList: null, + ingestorTransferListDetailView: null, + transferListRequestOptions: { + page: 0, + pageNumber: 100, + }, + ingestorExtractionMethods: null, + ingestionObject: IngestorHelper.createEmptyRequestInformation(), + ingestionObjectApiInformation: IngestorHelper.createEmptyAPIInformation(), + + ingestorBrowserActiveNode: null, + connectingBackend: false, + updateEditorFromThirdParty: false, + noRightsError: false, + error: null, + renderView: "all", +}; diff --git a/src/assets/config.json b/src/assets/config.json index 95b4bb98a2..64d542c87b 100644 --- a/src/assets/config.json +++ b/src/assets/config.json @@ -28,7 +28,17 @@ "jsonMetadataEnabled": true, "jupyterHubUrl": "https://jupyterhub.esss.lu.se/", "landingPage": "doi.ess.eu/detail/", - "lbBaseURL": "http://127.0.0.1:3000", + "lbBaseURL": "http://127.0.0.0:3000", + "ingestorComponent": { + "ingestorEnabled": false, + "ingestorAutodiscoveryOptions": [ + { + "mailDomain": "university.ch", + "description": "University/facility of Choice", + "facilityBackend": "http://localhost:8888" + } + ] + }, "logbookEnabled": true, "loginFormEnabled": true, "metadataPreviewEnabled": true, diff --git a/src/styles.scss b/src/styles.scss index b21134000f..6342ad4ab3 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -31,6 +31,7 @@ @use "./app/shared/modules/shared-table/shared-table-theme" as shared-table; @use "./app/shared/modules/table/table-theme" as table; @use "./app/users/user-settings/user-settings-theme" as user-settings; +@use "./app/ingestor/ingestor-theme" as ingestor; /* You can add global styles to this file, and also import other style files */ @use "assets/styles/titillium-web.scss"; @@ -250,6 +251,7 @@ $theme: map.merge( @include shared-table.theme($theme); @include table.theme($theme); @include user-settings.theme($theme); +@include ingestor.theme($theme); @include mat.button-density(0); @include mat.icon-button-density(0);