diff --git a/config/config.example.yml b/config/config.example.yml index 5fa2e74cbb..57a3a21316 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -350,6 +350,8 @@ item: # Rounded to the nearest size in the list of selectable sizes on the # settings menu. See pageSizeOptions in 'pagination-component-options.model.ts'. pageSize: 5 + # Show the bitstream access status label on the item page + showAccessStatuses: false # Community Page Config community: diff --git a/cypress/e2e/admin-sidebar.cy.ts b/cypress/e2e/admin-sidebar.cy.ts index 318bf2b27e..91e99b8f97 100644 --- a/cypress/e2e/admin-sidebar.cy.ts +++ b/cypress/e2e/admin-sidebar.cy.ts @@ -1,5 +1,4 @@ import { testA11y } from 'cypress/support/utils'; -import { Options } from 'cypress-axe'; describe('Admin Sidebar', () => { beforeEach(() => { @@ -16,13 +15,6 @@ describe('Admin Sidebar', () => { cy.get('ds-expandable-admin-sidebar-section').click({ multiple: true }); // Analyze for accessibility - testA11y('ds-admin-sidebar', - { - rules: { - // Currently all expandable sections have nested interactive elements - // See https://github.com/DSpace/dspace-angular/issues/2178 - 'nested-interactive': { enabled: false }, - }, - } as Options); + testA11y('ds-admin-sidebar'); }); }); diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json index 51237b5e95..e7b70a29b7 100644 --- a/cypress/tsconfig.json +++ b/cypress/tsconfig.json @@ -1,10 +1,16 @@ { "extends": "../tsconfig.json", "include": [ - "**/*.ts" + "**/*.ts", + "../cypress.config.ts" ], "compilerOptions": { "sourceMap": false, + "typeRoots": [ + "../node_modules", + "../node_modules/@types", + "../src/typings.d.ts" + ], "types": [ "cypress", "cypress-axe", diff --git a/package-lock.json b/package-lock.json index 27a9b18050..99415eeb75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "@ngrx/store": "^18.1.1", "@ngx-translate/core": "^16.0.3", "@nicky-lenaers/ngx-scroll-to": "^14.0.0", + "altcha": "^0.9.0", "angulartics2": "^12.2.0", "axios": "^1.7.9", "bootstrap": "^5.3", @@ -67,6 +68,7 @@ "ng2-file-upload": "7.0.1", "ng2-nouislider": "^2.0.0", "ngx-infinite-scroll": "^18.0.0", + "ngx-matomo-client": "^6.4.1", "ngx-pagination": "6.0.3", "ngx-skeleton-loader": "^9.0.0", "ngx-ui-switch": "^15.0.0", @@ -164,6 +166,12 @@ "version": "0.0.0", "dev": true }, + "node_modules/@altcha/crypto": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@altcha/crypto/-/crypto-0.0.1.tgz", + "integrity": "sha512-qZMdnoD3lAyvfSUMNtC2adRi666Pxdcw9zqfMU5qBOaJWqpN9K+eqQGWqeiKDMqL0SF+EytNG4kR/Pr/99GJ6g==", + "license": "MIT" + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -8895,6 +8903,31 @@ "ajv": "^8.8.2" } }, + "node_modules/altcha": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/altcha/-/altcha-0.9.0.tgz", + "integrity": "sha512-W83eEYpBw5lg37O9c/rtBpp0AaW3+6uiMHifSW8VKFRs2afps16UMO6B93Kaqbr/xA9KNSPEW3q0PwwA01+Ugg==", + "license": "MIT", + "dependencies": { + "@altcha/crypto": "^0.0.1" + }, + "optionalDependencies": { + "@rollup/rollup-linux-x64-gnu": "4.18.0" + } + }, + "node_modules/altcha/node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.18.0.tgz", + "integrity": "sha512-xuglR2rBVHA5UsI8h8UbX4VJ470PtGCf5Vpswh7p2ukaqBGFTnsfzxUBetoWBWymHMxbIG0Cmx7Y9qDZzr648w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/angulartics2": { "version": "12.2.1", "resolved": "https://registry.npmjs.org/angulartics2/-/angulartics2-12.2.1.tgz", @@ -17590,6 +17623,18 @@ "@angular/forms": ">=10.0.0" } }, + "node_modules/ngx-matomo-client": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/ngx-matomo-client/-/ngx-matomo-client-6.4.1.tgz", + "integrity": "sha512-GRriCGW0ULCg9oSZw3ule+o9esELVVJTJ0Z99/zYKGjlyrrHLn5a1e0GSdgICubo59gP1cg9NwsOC0BH7oio9A==", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": "^17.0.0 || ^18.0.0", + "@angular/core": "^17.0.0 || ^18.0.0" + } + }, "node_modules/ngx-pagination": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/ngx-pagination/-/ngx-pagination-6.0.3.tgz", diff --git a/package.json b/package.json index c289171423..7d20579887 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,7 @@ "@ngrx/store": "^18.1.1", "@ngx-translate/core": "^16.0.3", "@nicky-lenaers/ngx-scroll-to": "^14.0.0", + "altcha": "^0.9.0", "angulartics2": "^12.2.0", "axios": "^1.7.9", "bootstrap": "^5.3", @@ -149,6 +150,7 @@ "ng2-file-upload": "7.0.1", "ng2-nouislider": "^2.0.0", "ngx-infinite-scroll": "^18.0.0", + "ngx-matomo-client": "^6.4.1", "ngx-pagination": "6.0.3", "ngx-skeleton-loader": "^9.0.0", "ngx-ui-switch": "^15.0.0", diff --git a/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.html b/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.html index eefc372ac8..e07396c5e0 100644 --- a/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.html +++ b/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.html @@ -128,6 +128,22 @@ } + +
+ +
+ +
+
+
+
+ {{ 'ldn-service-usesActorEmailId-description' | translate }} +
+
+
+ @if (areControlsInitialized) { diff --git a/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.ts b/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.ts index 07c0453be9..ad3ed35439 100644 --- a/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.ts +++ b/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.ts @@ -125,6 +125,7 @@ export class LdnServiceFormComponent implements OnInit, OnDestroy { score: ['', [Validators.required, Validators.pattern('^0*(\.[0-9]+)?$|^1(\.0+)?$')]], inboundPattern: [''], constraintPattern: [''], enabled: [''], + usesActorEmailId: [''], type: LDN_SERVICE.value, }); } @@ -178,7 +179,8 @@ export class LdnServiceFormComponent implements OnInit, OnDestroy { return rest; }); - const values = { ...this.formModel.value, enabled: true }; + const values = { ...this.formModel.value, enabled: true, + usesActorEmailId: this.formModel.get('usesActorEmailId').value }; const ldnServiceData = this.ldnServicesService.create(values); @@ -237,6 +239,7 @@ export class LdnServiceFormComponent implements OnInit, OnDestroy { ldnUrl: this.ldnService.ldnUrl, type: this.ldnService.type, enabled: this.ldnService.enabled, + usesActorEmailId: this.ldnService.usesActorEmailId, lowerIp: this.ldnService.lowerIp, upperIp: this.ldnService.upperIp, }); @@ -384,6 +387,32 @@ export class LdnServiceFormComponent implements OnInit, OnDestroy { ); } + /** + * Toggles the usesActorEmailId field of the LDN service by sending a patch request + */ + toggleUsesActorEmailId() { + const newStatus = !this.formModel.get('usesActorEmailId').value; + if (!this.isNewService) { + const patchOperation: Operation = { + op: 'replace', + path: '/usesActorEmailId', + value: newStatus, + }; + + this.ldnServicesService.patch(this.ldnService, [patchOperation]).pipe( + getFirstCompletedRemoteData(), + ).subscribe( + () => { + this.formModel.get('usesActorEmailId').setValue(newStatus); + this.cdRef.detectChanges(); + }, + ); + } else { + this.formModel.get('usesActorEmailId').setValue(newStatus); + this.cdRef.detectChanges(); + } + } + /** * Closes the modal */ diff --git a/src/app/admin/admin-ldn-services/ldn-service-serviceMock/ldnServicesRD$-mock.ts b/src/app/admin/admin-ldn-services/ldn-service-serviceMock/ldnServicesRD$-mock.ts index 8494b67dab..bd732ea37e 100644 --- a/src/app/admin/admin-ldn-services/ldn-service-serviceMock/ldnServicesRD$-mock.ts +++ b/src/app/admin/admin-ldn-services/ldn-service-serviceMock/ldnServicesRD$-mock.ts @@ -12,6 +12,7 @@ import { LdnService } from '../ldn-services-model/ldn-services.model'; export const mockLdnService: LdnService = { uuid: '1', enabled: false, + usesActorEmailId: false, score: 0, id: 1, lowerIp: '192.0.2.146', @@ -49,6 +50,7 @@ export const mockLdnServiceRD$ = createSuccessfulRemoteDataObject$(mockLdnServic export const mockLdnServices: LdnService[] = [{ uuid: '1', enabled: false, + usesActorEmailId: false, score: 0, id: 1, lowerIp: '192.0.2.146', @@ -81,6 +83,7 @@ export const mockLdnServices: LdnService[] = [{ }, { uuid: '2', enabled: false, + usesActorEmailId: false, score: 0, id: 2, lowerIp: '192.0.2.146', diff --git a/src/app/admin/admin-ldn-services/ldn-services-model/ldn-services.model.ts b/src/app/admin/admin-ldn-services/ldn-services-model/ldn-services.model.ts index 1497b618f0..5aed22ffb9 100644 --- a/src/app/admin/admin-ldn-services/ldn-services-model/ldn-services.model.ts +++ b/src/app/admin/admin-ldn-services/ldn-services-model/ldn-services.model.ts @@ -52,6 +52,9 @@ export class LdnService extends CacheableObject { @autoserialize enabled: boolean; + @autoserialize + usesActorEmailId: boolean; + @autoserialize ldnUrl: string; diff --git a/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.html b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.html index b04e7132f1..b1d88de988 100644 --- a/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.html +++ b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.html @@ -1 +1 @@ - + diff --git a/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.spec.ts b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.spec.ts index ce8e846900..847910f447 100644 --- a/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.spec.ts +++ b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.spec.ts @@ -6,8 +6,9 @@ import { waitForAsync, } from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; +import { MockComponent } from 'ng-mocks'; -import { PublicationClaimComponent } from '../../../notifications/suggestion-targets/publication-claim/publication-claim.component'; +import { SuggestionSourcesComponent } from '../../../notifications/suggestions/sources/suggestion-sources.component'; import { AdminNotificationsPublicationClaimPageComponent } from './admin-notifications-publication-claim-page.component'; describe('AdminNotificationsPublicationClaimPageComponent', () => { @@ -20,17 +21,10 @@ describe('AdminNotificationsPublicationClaimPageComponent', () => { CommonModule, TranslateModule.forRoot(), AdminNotificationsPublicationClaimPageComponent, - ], - providers: [ - AdminNotificationsPublicationClaimPageComponent, + MockComponent(SuggestionSourcesComponent), ], schemas: [NO_ERRORS_SCHEMA], - }).overrideComponent(AdminNotificationsPublicationClaimPageComponent, { - remove: { - imports: [PublicationClaimComponent], - }, - }) - .compileComponents(); + }).compileComponents(); })); beforeEach(() => { diff --git a/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.ts b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.ts index 24af9350ee..2e92125a56 100644 --- a/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.ts +++ b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.ts @@ -1,14 +1,12 @@ import { Component } from '@angular/core'; -import { PublicationClaimComponent } from '../../../notifications/suggestion-targets/publication-claim/publication-claim.component'; +import { SuggestionSourcesComponent } from '../../../notifications/suggestions/sources/suggestion-sources.component'; @Component({ selector: 'ds-admin-notifications-publication-claim-page', templateUrl: './admin-notifications-publication-claim-page.component.html', styleUrls: ['./admin-notifications-publication-claim-page.component.scss'], - imports: [ - PublicationClaimComponent, - ], + imports: [ SuggestionSourcesComponent ], standalone: true, }) export class AdminNotificationsPublicationClaimPageComponent { diff --git a/src/app/admin/admin-notifications/admin-notifications-routes.ts b/src/app/admin/admin-notifications/admin-notifications-routes.ts index 43cfc2945a..309910d6bb 100644 --- a/src/app/admin/admin-notifications/admin-notifications-routes.ts +++ b/src/app/admin/admin-notifications/admin-notifications-routes.ts @@ -2,7 +2,8 @@ import { Route } from '@angular/router'; import { authenticatedGuard } from '../../core/auth/authenticated.guard'; import { i18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver'; -import { qualityAssuranceBreadcrumbResolver } from '../../core/breadcrumbs/quality-assurance-breadcrumb.resolver'; +import { sourcesBreadcrumbResolver } from '../../core/breadcrumbs/sources-breadcrumb.resolver'; +import { PublicationClaimComponent } from '../../notifications/suggestions/targets/publication-claim/publication-claim.component'; import { AdminNotificationsPublicationClaimPageResolver } from '../../quality-assurance-notifications-pages/notifications-suggestion-targets-page/notifications-suggestion-targets-page-resolver.service'; import { QualityAssuranceEventsPageComponent } from '../../quality-assurance-notifications-pages/quality-assurance-events-page/quality-assurance-events-page.component'; import { qualityAssuranceEventsPageResolver } from '../../quality-assurance-notifications-pages/quality-assurance-events-page/quality-assurance-events-page.resolver'; @@ -33,13 +34,28 @@ export const ROUTES: Route[] = [ showBreadcrumbsFluid: false, }, }, + { + canActivate: [ authenticatedGuard ], + path: `${PUBLICATION_CLAIMS_PATH}/:sourceId`, + pathMatch: 'full', + component: PublicationClaimComponent, + resolve: { + breadcrumb: sourcesBreadcrumbResolver, + openaireQualityAssuranceEventsParams: AdminNotificationsPublicationClaimPageResolver, + }, + data: { + title: 'admin.notifications.publicationclaim.page.title', + breadcrumbKey: 'admin.notifications.publicationclaim', + showBreadcrumbsFluid: false, + }, + }, { canActivate: [authenticatedGuard], path: `${QUALITY_ASSURANCE_EDIT_PATH}/:sourceId`, component: QualityAssuranceTopicsPageComponent, pathMatch: 'full', resolve: { - breadcrumb: qualityAssuranceBreadcrumbResolver, + breadcrumb: sourcesBreadcrumbResolver, openaireQualityAssuranceTopicsParams: QualityAssuranceTopicsPageResolver, }, data: { @@ -85,7 +101,7 @@ export const ROUTES: Route[] = [ component: QualityAssuranceEventsPageComponent, pathMatch: 'full', resolve: { - breadcrumb: qualityAssuranceBreadcrumbResolver, + breadcrumb: sourcesBreadcrumbResolver, openaireQualityAssuranceEventsParams: qualityAssuranceEventsPageResolver, }, data: { diff --git a/src/app/admin/admin-reports/filtered-items/filtered-items-export-csv/filtered-items-export-csv.component.html b/src/app/admin/admin-reports/filtered-items/filtered-items-export-csv/filtered-items-export-csv.component.html new file mode 100644 index 0000000000..a8f5463ce1 --- /dev/null +++ b/src/app/admin/admin-reports/filtered-items/filtered-items-export-csv/filtered-items-export-csv.component.html @@ -0,0 +1,8 @@ +@if (shouldShowButton$ | async) { + +} \ No newline at end of file diff --git a/src/app/admin/admin-reports/filtered-items/filtered-items-export-csv/filtered-items-export-csv.component.scss b/src/app/admin/admin-reports/filtered-items/filtered-items-export-csv/filtered-items-export-csv.component.scss new file mode 100644 index 0000000000..4b0ab3c44a --- /dev/null +++ b/src/app/admin/admin-reports/filtered-items/filtered-items-export-csv/filtered-items-export-csv.component.scss @@ -0,0 +1,4 @@ +.export-button { + background: var(--ds-admin-sidebar-bg); + border-color: var(--ds-admin-sidebar-bg); +} \ No newline at end of file diff --git a/src/app/admin/admin-reports/filtered-items/filtered-items-export-csv/filtered-items-export-csv.component.spec.ts b/src/app/admin/admin-reports/filtered-items/filtered-items-export-csv/filtered-items-export-csv.component.spec.ts new file mode 100644 index 0000000000..d9627dff70 --- /dev/null +++ b/src/app/admin/admin-reports/filtered-items/filtered-items-export-csv/filtered-items-export-csv.component.spec.ts @@ -0,0 +1,194 @@ +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + FormControl, + FormGroup, +} from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { Router } from '@angular/router'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; + +import { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service'; +import { ScriptDataService } from '../../../../core/data/processes/script-data.service'; +import { getProcessDetailRoute } from '../../../../process-page/process-page-routing.paths'; +import { Process } from '../../../../process-page/processes/process.model'; +import { Script } from '../../../../process-page/scripts/script.model'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { + createFailedRemoteDataObject$, + createSuccessfulRemoteDataObject$, +} from '../../../../shared/remote-data.utils'; +import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub'; +import { FiltersComponent } from '../../filters-section/filters-section.component'; +import { OptionVO } from '../option-vo.model'; +import { QueryPredicate } from '../query-predicate.model'; +import { FilteredItemsExportCsvComponent } from './filtered-items-export-csv.component'; + +describe('FilteredItemsExportCsvComponent', () => { + let component: FilteredItemsExportCsvComponent; + let fixture: ComponentFixture; + + let scriptDataService: ScriptDataService; + let authorizationDataService: AuthorizationDataService; + let notificationsService; + let router; + + const script = Object.assign(new Script(), { id: 'metadata-export-filtered-items-report', name: 'metadata-export-filtered-items-report' }); + const process = Object.assign(new Process(), { processId: 5, scriptName: 'metadata-export-filtered-items-report' }); + + const params = new FormGroup({ + collections: new FormControl([OptionVO.collection('1', 'coll1')]), + queryPredicates: new FormControl([QueryPredicate.of('name', 'equals', 'coll1')]), + filters: new FormControl([FiltersComponent.getFilter('is_item')]), + }); + + const emptyParams = new FormGroup({ + collections: new FormControl([]), + queryPredicates: new FormControl([]), + filters: new FormControl([]), + }); + + function initBeforeEachAsync() { + scriptDataService = jasmine.createSpyObj('scriptDataService', { + findById: createSuccessfulRemoteDataObject$(script), + invoke: createSuccessfulRemoteDataObject$(process), + }); + authorizationDataService = jasmine.createSpyObj('authorizationService', { + isAuthorized: observableOf(true), + }); + + notificationsService = new NotificationsServiceStub(); + + router = jasmine.createSpyObj('authorizationService', ['navigateByUrl']); + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), NgbModule, FilteredItemsExportCsvComponent], + providers: [ + { provide: ScriptDataService, useValue: scriptDataService }, + { provide: AuthorizationDataService, useValue: authorizationDataService }, + { provide: NotificationsService, useValue: notificationsService }, + { provide: Router, useValue: router }, + ], + }).compileComponents(); + } + + function initBeforeEach() { + fixture = TestBed.createComponent(FilteredItemsExportCsvComponent); + component = fixture.componentInstance; + component.reportParams = params; + fixture.detectChanges(); + } + + describe('init', () => { + describe('comp', () => { + beforeEach(waitForAsync(() => { + initBeforeEachAsync(); + })); + beforeEach(() => { + initBeforeEach(); + }); + it('should init the comp', () => { + expect(component).toBeTruthy(); + }); + }); + describe('when the user is an admin and the metadata-export-filtered-items-report script is present ', () => { + beforeEach(waitForAsync(() => { + initBeforeEachAsync(); + })); + beforeEach(() => { + initBeforeEach(); + }); + it('should add the button', () => { + const debugElement = fixture.debugElement.query(By.css('button.export-button')); + expect(debugElement).toBeDefined(); + }); + }); + describe('when the user is not an admin', () => { + beforeEach(waitForAsync(() => { + initBeforeEachAsync(); + (authorizationDataService.isAuthorized as jasmine.Spy).and.returnValue(observableOf(false)); + })); + beforeEach(() => { + initBeforeEach(); + }); + it('should not add the button', () => { + const debugElement = fixture.debugElement.query(By.css('button.export-button')); + expect(debugElement).toBeNull(); + }); + }); + describe('when the metadata-export-filtered-items-report script is not present', () => { + beforeEach(waitForAsync(() => { + initBeforeEachAsync(); + (scriptDataService.findById as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$('Not found', 404)); + })); + beforeEach(() => { + initBeforeEach(); + }); + it('should should not add the button', () => { + const debugElement = fixture.debugElement.query(By.css('button.export-button')); + expect(debugElement).toBeNull(); + }); + }); + }); + describe('export', () => { + beforeEach(waitForAsync(() => { + initBeforeEachAsync(); + })); + beforeEach(() => { + initBeforeEach(); + }); + it('should call the invoke script method with the correct parameters', () => { + // Parameterized export + component.export(); + expect(scriptDataService.invoke).toHaveBeenCalledWith('metadata-export-filtered-items-report', + [ + { name: '-c', value: params.value.collections[0].id }, + { name: '-qp', value: QueryPredicate.toString(params.value.queryPredicates[0]) }, + { name: '-f', value: FiltersComponent.toQueryString(params.value.filters) }, + ], []); + + fixture.detectChanges(); + + // Non-parameterized export + component.reportParams = emptyParams; + fixture.detectChanges(); + component.export(); + expect(scriptDataService.invoke).toHaveBeenCalledWith('metadata-export-filtered-items-report', [], []); + + }); + it('should show a success message when the script was invoked successfully and redirect to the corresponding process page', () => { + component.export(); + + expect(notificationsService.success).toHaveBeenCalled(); + expect(router.navigateByUrl).toHaveBeenCalledWith(getProcessDetailRoute(process.processId)); + }); + it('should show an error message when the script was not invoked successfully and stay on the current page', () => { + (scriptDataService.invoke as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$('Error', 500)); + + component.export(); + + expect(notificationsService.error).toHaveBeenCalled(); + expect(router.navigateByUrl).not.toHaveBeenCalled(); + }); + }); + describe('clicking the button', () => { + beforeEach(waitForAsync(() => { + initBeforeEachAsync(); + })); + beforeEach(() => { + initBeforeEach(); + }); + it('should trigger the export function', () => { + spyOn(component, 'export'); + + const debugElement = fixture.debugElement.query(By.css('button.export-button')); + debugElement.triggerEventHandler('click', null); + + expect(component.export).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/admin/admin-reports/filtered-items/filtered-items-export-csv/filtered-items-export-csv.component.ts b/src/app/admin/admin-reports/filtered-items/filtered-items-export-csv/filtered-items-export-csv.component.ts new file mode 100644 index 0000000000..50a0ca32b7 --- /dev/null +++ b/src/app/admin/admin-reports/filtered-items/filtered-items-export-csv/filtered-items-export-csv.component.ts @@ -0,0 +1,123 @@ +import { AsyncPipe } from '@angular/common'; +import { + Component, + Input, + OnInit, +} from '@angular/core'; +import { FormGroup } from '@angular/forms'; +import { Router } from '@angular/router'; +import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { + combineLatest as observableCombineLatest, + Observable, +} from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../../../../core/data/feature-authorization/feature-id'; +import { ScriptDataService } from '../../../../core/data/processes/script-data.service'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { getFirstCompletedRemoteData } from '../../../../core/shared/operators'; +import { getProcessDetailRoute } from '../../../../process-page/process-page-routing.paths'; +import { Process } from '../../../../process-page/processes/process.model'; +import { hasValue } from '../../../../shared/empty.util'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { FiltersComponent } from '../../filters-section/filters-section.component'; +import { OptionVO } from '../option-vo.model'; +import { QueryPredicate } from '../query-predicate.model'; + +@Component({ + selector: 'ds-filtered-items-export-csv', + styleUrls: ['./filtered-items-export-csv.component.scss'], + templateUrl: './filtered-items-export-csv.component.html', + standalone: true, + imports: [NgbTooltipModule, AsyncPipe, TranslateModule], +}) +/** + * Display a button to export the MetadataQuery (aka Filtered Items) Report results as csv + */ +export class FilteredItemsExportCsvComponent implements OnInit { + + /** + * The current configuration of the search + */ + @Input() reportParams: FormGroup; + + /** + * Observable used to determine whether the button should be shown + */ + shouldShowButton$: Observable; + + /** + * The message key used for the tooltip of the button + */ + tooltipMsg = 'metadata-export-filtered-items.tooltip'; + + constructor(private scriptDataService: ScriptDataService, + private authorizationDataService: AuthorizationDataService, + private notificationsService: NotificationsService, + private translateService: TranslateService, + private router: Router, + ) { + } + + static csvExportEnabled(scriptDataService: ScriptDataService, authorizationDataService: AuthorizationDataService): Observable { + const scriptExists$ = scriptDataService.findById('metadata-export-filtered-items-report').pipe( + getFirstCompletedRemoteData(), + map((rd) => rd.isSuccess && hasValue(rd.payload)), + ); + + const isAuthorized$ = authorizationDataService.isAuthorized(FeatureID.AdministratorOf); + + return observableCombineLatest([scriptExists$, isAuthorized$]).pipe( + map(([scriptExists, isAuthorized]: [boolean, boolean]) => scriptExists && isAuthorized), + ); + } + + ngOnInit(): void { + this.shouldShowButton$ = FilteredItemsExportCsvComponent.csvExportEnabled(this.scriptDataService, this.authorizationDataService); + } + + /** + * Start the export of the items based on the selected parameters + */ + export() { + const parameters = []; + const colls = this.reportParams.value.collections || []; + for (let i = 0; i < colls.length; i++) { + if (colls[i]) { + parameters.push({ name: '-c', value: OptionVO.toString(colls[i]) }); + } + } + + const preds = this.reportParams.value.queryPredicates || []; + for (let i = 0; i < preds.length; i++) { + const field = preds[i].field; + const op = preds[i].operator; + if (field && op) { + parameters.push({ name: '-qp', value: QueryPredicate.toString(preds[i]) }); + } + } + + const filters = FiltersComponent.toQueryString(this.reportParams.value.filters) || []; + if (filters.length > 0) { + parameters.push({ name: '-f', value: filters }); + } + + this.scriptDataService.invoke('metadata-export-filtered-items-report', parameters, []).pipe( + getFirstCompletedRemoteData(), + ).subscribe((rd: RemoteData) => { + if (rd.hasSucceeded) { + this.notificationsService.success(this.translateService.get('metadata-export-filtered-items.submit.success')); + this.router.navigateByUrl(getProcessDetailRoute(rd.payload.processId)); + } else { + this.notificationsService.error(this.translateService.get('metadata-export-filtered-items.submit.error')); + } + }); + } + +} diff --git a/src/app/admin/admin-reports/filtered-items/filtered-items.component.html b/src/app/admin/admin-reports/filtered-items/filtered-items.component.html index 6b67a12769..dd3f45c216 100644 --- a/src/app/admin/admin-reports/filtered-items/filtered-items.component.html +++ b/src/app/admin/admin-reports/filtered-items/filtered-items.component.html @@ -11,11 +11,16 @@ {{'admin.reports.items.section.collectionSelector' | translate}} - + @if (loadingCollections$ | async) { + + } + @if ((loadingCollections$ | async) !== true) { + + }
@@ -132,6 +137,10 @@
+ @if (csvExportEnabled$ | async) { + +
{{ 'metadata-export-filtered-items.columns.warning' | translate }}
+ }
@@ -186,9 +195,9 @@
- +
+ +
diff --git a/src/app/admin/admin-reports/filtered-items/filtered-items.component.scss b/src/app/admin/admin-reports/filtered-items/filtered-items.component.scss index 73ce5275e5..15e8b54bc7 100644 --- a/src/app/admin/admin-reports/filtered-items/filtered-items.component.scss +++ b/src/app/admin/admin-reports/filtered-items/filtered-items.component.scss @@ -1,3 +1,10 @@ .num { text-align: center; } + +.warning { + color: red; + font-style: italic; + text-align: center; + width: 100%; +} \ No newline at end of file diff --git a/src/app/admin/admin-reports/filtered-items/filtered-items.component.ts b/src/app/admin/admin-reports/filtered-items/filtered-items.component.ts index 7782b0e416..1daea4168e 100644 --- a/src/app/admin/admin-reports/filtered-items/filtered-items.component.ts +++ b/src/app/admin/admin-reports/filtered-items/filtered-items.component.ts @@ -20,13 +20,16 @@ import { TranslateService, } from '@ngx-translate/core'; import { + BehaviorSubject, map, Observable, } from 'rxjs'; import { CollectionDataService } from 'src/app/core/data/collection-data.service'; import { CommunityDataService } from 'src/app/core/data/community-data.service'; +import { AuthorizationDataService } from 'src/app/core/data/feature-authorization/authorization-data.service'; import { MetadataFieldDataService } from 'src/app/core/data/metadata-field-data.service'; import { MetadataSchemaDataService } from 'src/app/core/data/metadata-schema-data.service'; +import { ScriptDataService } from 'src/app/core/data/processes/script-data.service'; import { RestRequestMethod } from 'src/app/core/data/rest-request-method'; import { DspaceRestService } from 'src/app/core/dspace-rest/dspace-rest.service'; import { RawRestResponse } from 'src/app/core/dspace-rest/raw-rest-response.model'; @@ -36,10 +39,12 @@ import { Collection } from 'src/app/core/shared/collection.model'; import { Community } from 'src/app/core/shared/community.model'; import { getFirstSucceededRemoteListPayload } from 'src/app/core/shared/operators'; import { isEmpty } from 'src/app/shared/empty.util'; +import { ThemedLoadingComponent } from 'src/app/shared/loading/themed-loading.component'; import { environment } from 'src/environments/environment'; import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; import { FiltersComponent } from '../filters-section/filters-section.component'; +import { FilteredItemsExportCsvComponent } from './filtered-items-export-csv/filtered-items-export-csv.component'; import { FilteredItem, FilteredItems, @@ -62,12 +67,19 @@ import { QueryPredicate } from './query-predicate.model'; AsyncPipe, FiltersComponent, BtnDisabledDirective, + FilteredItemsExportCsvComponent, + ThemedLoadingComponent, ], standalone: true, }) export class FilteredItemsComponent implements OnInit { collections: OptionVO[]; + /** + * A Boolean representing if loading the list of collections is pending + */ + loadingCollections$: BehaviorSubject = new BehaviorSubject(false); + presetQueries: PresetQuery[]; metadataFields: OptionVO[]; metadataFieldsWithAny: OptionVO[]; @@ -79,6 +91,10 @@ export class FilteredItemsComponent implements OnInit { results: FilteredItems = new FilteredItems(); results$: Observable; @ViewChild('acc') accordionComponent: NgbAccordion; + /** + * Observable used to determine whether CSV export is enabled + */ + csvExportEnabled$: Observable; constructor( private communityService: CommunityDataService, @@ -86,6 +102,8 @@ export class FilteredItemsComponent implements OnInit { private metadataSchemaService: MetadataSchemaDataService, private metadataFieldService: MetadataFieldDataService, private translateService: TranslateService, + private scriptDataService: ScriptDataService, + private authorizationDataService: AuthorizationDataService, private formBuilder: FormBuilder, private restService: DspaceRestService) {} @@ -100,6 +118,8 @@ export class FilteredItemsComponent implements OnInit { new QueryPredicate().toFormGroup(this.formBuilder), ]; + this.csvExportEnabled$ = FilteredItemsExportCsvComponent.csvExportEnabled(this.scriptDataService, this.authorizationDataService); + this.queryForm = this.formBuilder.group({ collections: this.formBuilder.control([''], []), presetQuery: this.formBuilder.control('new', []), @@ -111,6 +131,7 @@ export class FilteredItemsComponent implements OnInit { } loadCollections(): void { + this.loadingCollections$.next(true); this.collections = []; const wholeRepo$ = this.translateService.stream('admin.reports.items.wholeRepo'); this.collections.push(OptionVO.collectionLoc('', wholeRepo$)); @@ -132,6 +153,7 @@ export class FilteredItemsComponent implements OnInit { const collVO = OptionVO.collection(collection.uuid, '–' + collection.name); this.collections.push(collVO); }); + this.loadingCollections$.next(false); }, ); }); @@ -167,10 +189,10 @@ export class FilteredItemsComponent implements OnInit { QueryPredicate.of('dc.description.provenance', QueryPredicate.DOES_NOT_MATCH, '^.*No\. of bitstreams(.|\r|\n|\r\n)*\.(PDF|pdf|DOC|doc|PPT|ppt|DOCX|docx|PPTX|pptx).*$'), ]), PresetQuery.of('q9', 'admin.reports.items.preset.hasEmptyMetadata', [ - QueryPredicate.of('*', QueryPredicate.MATCHES, '^\s*$'), + QueryPredicate.of('*', QueryPredicate.MATCHES, '^\\s*$'), ]), PresetQuery.of('q10', 'admin.reports.items.preset.hasUnbreakingDataInDescription', [ - QueryPredicate.of('dc.description.*', QueryPredicate.MATCHES, '^.*[^\s]{50,}.*$'), + QueryPredicate.of('dc.description.*', QueryPredicate.MATCHES, '^.*(\\S){50,}.*$'), ]), PresetQuery.of('q12', 'admin.reports.items.preset.hasXmlEntityInMetadata', [ QueryPredicate.of('*', QueryPredicate.MATCHES, '^.*&#.*$'), @@ -344,13 +366,8 @@ export class FilteredItemsComponent implements OnInit { const preds = this.queryForm.value.queryPredicates; for (let i = 0; i < preds.length; i++) { - const field = preds[i].field; - const op = preds[i].operator; - const value = preds[i].value; - params += `&queryPredicates=${field}:${op}`; - if (value) { - params += `:${value}`; - } + const pred = encodeURIComponent(QueryPredicate.toString(preds[i])); + params += `&queryPredicates=${pred}`; } const filters = FiltersComponent.toQueryString(this.queryForm.value.filters); diff --git a/src/app/admin/admin-reports/filtered-items/option-vo.model.ts b/src/app/admin/admin-reports/filtered-items/option-vo.model.ts index b26a42a8d8..a598fb9a3b 100644 --- a/src/app/admin/admin-reports/filtered-items/option-vo.model.ts +++ b/src/app/admin/admin-reports/filtered-items/option-vo.model.ts @@ -46,6 +46,16 @@ export class OptionVO { subscriber.next(value); subscriber.complete(); }); - } + + static toString(obj: any): string { + if (obj) { + if (obj instanceof OptionVO && obj.id) { + return obj.id; + } + return obj as string; + } + return ''; + } + } diff --git a/src/app/admin/admin-reports/filtered-items/query-predicate.model.ts b/src/app/admin/admin-reports/filtered-items/query-predicate.model.ts index 1c12d72e27..1c91bfa744 100644 --- a/src/app/admin/admin-reports/filtered-items/query-predicate.model.ts +++ b/src/app/admin/admin-reports/filtered-items/query-predicate.model.ts @@ -29,6 +29,13 @@ export class QueryPredicate { return pred; } + static toString(pred: QueryPredicate): string { + if (pred.value) { + return `${pred.field}:${pred.operator}:${pred.value}`; + } + return `${pred.field}:${pred.operator}`; + } + toFormGroup(formBuilder: FormBuilder): FormGroup { return formBuilder.group({ field: new FormControl(this.field), diff --git a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts index 3eb8d9caf7..7a6b58dea2 100644 --- a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts +++ b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts @@ -10,7 +10,6 @@ import { TranslateModule } from '@ngx-translate/core'; import { Observable } from 'rxjs'; import { AuthService } from '../../../../../core/auth/auth.service'; -import { AccessStatusDataService } from '../../../../../core/data/access-status-data.service'; import { BitstreamDataService } from '../../../../../core/data/bitstream-data.service'; import { AuthorizationDataService } from '../../../../../core/data/feature-authorization/authorization-data.service'; import { RemoteData } from '../../../../../core/data/remote-data'; @@ -22,7 +21,6 @@ import { ViewMode } from '../../../../../core/shared/view-mode.model'; import { mockTruncatableService } from '../../../../../shared/mocks/mock-trucatable.service'; import { getMockThemeService } from '../../../../../shared/mocks/theme-service.mock'; import { CollectionElementLinkType } from '../../../../../shared/object-collection/collection-element-link.type'; -import { AccessStatusObject } from '../../../../../shared/object-collection/shared/badges/access-status-badge/access-status.model'; import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model'; import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/remote-data.utils'; import { AuthServiceStub } from '../../../../../shared/testing/auth-service.stub'; @@ -44,12 +42,6 @@ describe('ItemAdminSearchResultGridElementComponent', () => { }, }; - const mockAccessStatusDataService = { - findAccessStatusFor(item: Item): Observable> { - return createSuccessfulRemoteDataObject$(new AccessStatusObject()); - }, - }; - const mockThemeService = getMockThemeService(); function init() { @@ -74,7 +66,6 @@ describe('ItemAdminSearchResultGridElementComponent', () => { { provide: TruncatableService, useValue: mockTruncatableService }, { provide: BitstreamDataService, useValue: mockBitstreamDataService }, { provide: ThemeService, useValue: mockThemeService }, - { provide: AccessStatusDataService, useValue: mockAccessStatusDataService }, { provide: AuthService, useClass: AuthServiceStub }, { provide: FileService, useClass: FileServiceStub }, { provide: AuthorizationDataService, useClass: AuthorizationDataServiceStub }, diff --git a/src/app/app-routes.ts b/src/app/app-routes.ts index 25324a66be..937947c048 100644 --- a/src/app/app-routes.ts +++ b/src/app/app-routes.ts @@ -261,6 +261,20 @@ export const APP_ROUTES: Route[] = [ .then((m) => m.ROUTES), canActivate: [authenticatedGuard], }, + { + path: 'external-login/:token', + loadChildren: () => import('./external-login-page/external-login-routes').then((m) => m.ROUTES), + }, + { + path: 'review-account/:token', + loadChildren: () => import('./external-login-review-account-info-page/external-login-review-account-info-page-routes') + .then((m) => m.ROUTES), + }, + { + path: 'email-confirmation', + loadChildren: () => import('./external-login-email-confirmation-page/external-login-email-confirmation-page-routes') + .then((m) => m.ROUTES), + }, { path: '**', pathMatch: 'full', component: ThemedPageNotFoundComponent }, ], }, diff --git a/src/app/app-routing-paths.ts b/src/app/app-routing-paths.ts index 7d202f16e9..4ef99559a8 100644 --- a/src/app/app-routing-paths.ts +++ b/src/app/app-routing-paths.ts @@ -35,6 +35,41 @@ export function getBitstreamRequestACopyRoute(item, bitstream): { routerLink: st }; } +/** + * Get a bitstream download route with an access token (to provide direct access to a user) added as a query parameter + * @param bitstream the bitstream to download + * @param accessToken the access token, which should match an access_token in the requestitem table + */ +export function getBitstreamDownloadWithAccessTokenRoute(bitstream, accessToken): { routerLink: string, queryParams: any } { + const url = new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString(); + const options = { + routerLink: url, + queryParams: {}, + }; + // Only add the access token if it is not empty, otherwise keep valid empty query parameters + if (hasValue(accessToken)) { + options.queryParams = { accessToken: accessToken }; + } + return options; +} +/** + * Get an access token request route for a user to access approved bitstreams using a supplied access token + * @param item_uuid item UUID + * @param accessToken access token (generated by backend) + */ +export function getAccessTokenRequestRoute(item_uuid, accessToken): { routerLink: string, queryParams: any } { + const url = new URLCombiner(getItemModuleRoute(), item_uuid, getAccessByTokenModulePath()).toString(); + const options = { + routerLink: url, + queryParams: { + accessToken: (hasValue(accessToken) ? accessToken : undefined), + }, + }; + return options; +} + +export const COAR_NOTIFY_SUPPORT = 'coar-notify-support'; + export const HOME_PAGE_PATH = 'home'; export function getHomePageRoute() { @@ -128,6 +163,11 @@ export function getRequestCopyModulePath() { return `/${REQUEST_COPY_MODULE_PATH}`; } +export const ACCESS_BY_TOKEN_MODULE_PATH = 'access-by-token'; +export function getAccessByTokenModulePath() { + return `/${ACCESS_BY_TOKEN_MODULE_PATH}`; +} + export const HEALTH_PAGE_PATH = 'health'; export const SUBSCRIPTIONS_MODULE_PATH = 'subscriptions'; diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 15d7cf4952..584336a16e 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -10,6 +10,7 @@ import { import { NoPreloading, provideRouter, + withComponentInputBinding, withEnabledBlockingInitialNavigation, withInMemoryScrolling, withPreloading, @@ -65,6 +66,7 @@ import { import { ClientCookieService } from './core/services/client-cookie.service'; import { ListableModule } from './core/shared/listable.module'; import { XsrfInterceptor } from './core/xsrf/xsrf.interceptor'; +import { LOGIN_METHOD_FOR_DECORATOR_MAP } from './external-log-in/decorators/external-log-in.methods-decorator'; import { RootModule } from './root.module'; import { AUTH_METHOD_FOR_DECORATOR_MAP } from './shared/log-in/methods/log-in.methods-decorator'; import { METADATA_REPRESENTATION_COMPONENT_DECORATOR_MAP } from './shared/metadata-representation/metadata-representation.decorator'; @@ -110,6 +112,7 @@ export const commonAppConfig: ApplicationConfig = { withInMemoryScrolling(APP_ROUTING_SCROLL_CONF), withEnabledBlockingInitialNavigation(), withPreloading(NoPreloading), + withComponentInputBinding(), ), { provide: APP_BASE_HREF, @@ -168,6 +171,7 @@ export const commonAppConfig: ApplicationConfig = { /* Use models object so all decorators are actually called */ const modelList = models; +const loginMethodForDecoratorMap = LOGIN_METHOD_FOR_DECORATOR_MAP; const workflowTasks = WORKFLOW_TASK_OPTION_DECORATOR_MAP; const advancedWorfklowTasks = ADVANCED_WORKFLOW_TASK_OPTION_DECORATOR_MAP; const metadataRepresentations = METADATA_REPRESENTATION_COMPONENT_DECORATOR_MAP; diff --git a/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.spec.ts b/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.spec.ts index 0cc293c6f7..ec4854f8ff 100644 --- a/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.spec.ts +++ b/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.spec.ts @@ -1,4 +1,7 @@ -import { CommonModule } from '@angular/common'; +import { + CommonModule, + Location, +} from '@angular/common'; import { PLATFORM_ID } from '@angular/core'; import { ComponentFixture, @@ -14,6 +17,7 @@ import { of as observableOf } from 'rxjs'; import { getForbiddenRoute } from '../../app-routing-paths'; import { AuthService } from '../../core/auth/auth.service'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; import { SignpostingDataService } from '../../core/data/signposting-data.service'; import { HardRedirectService } from '../../core/services/hard-redirect.service'; @@ -21,6 +25,7 @@ import { ServerResponseService } from '../../core/services/server-response.servi import { Bitstream } from '../../core/shared/bitstream.model'; import { FileService } from '../../core/shared/file.service'; import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; +import { MatomoService } from '../../statistics/matomo.service'; import { BitstreamDownloadPageComponent } from './bitstream-download-page.component'; describe('BitstreamDownloadPageComponent', () => { @@ -33,10 +38,13 @@ describe('BitstreamDownloadPageComponent', () => { let hardRedirectService: HardRedirectService; let activatedRoute; let router; + let location: Location; + let dsoNameService: DSONameService; let bitstream: Bitstream; let serverResponseService: jasmine.SpyObj; let signpostingDataService: jasmine.SpyObj; + let matomoService: jasmine.SpyObj; const mocklink = { href: 'http://test.org', @@ -54,6 +62,7 @@ describe('BitstreamDownloadPageComponent', () => { authService = jasmine.createSpyObj('authService', { isAuthenticated: observableOf(true), setRedirectUrl: {}, + getShortlivedToken: observableOf('token'), }); authorizationService = jasmine.createSpyObj('authorizationSerivice', { isAuthorized: observableOf(true), @@ -63,9 +72,18 @@ describe('BitstreamDownloadPageComponent', () => { retrieveFileDownloadLink: observableOf('content-url-with-headers'), }); - hardRedirectService = jasmine.createSpyObj('fileService', { + hardRedirectService = jasmine.createSpyObj('hardRedirectService', { redirect: {}, }); + + location = jasmine.createSpyObj('location', { + back: {}, + }); + + dsoNameService = jasmine.createSpyObj('dsoNameService', { + getName: 'Test Bitstream', + }); + bitstream = Object.assign(new Bitstream(), { uuid: 'bitstreamUuid', _links: { @@ -73,16 +91,16 @@ describe('BitstreamDownloadPageComponent', () => { self: { href: 'bitstream-self-link' }, }, }); - activatedRoute = { data: observableOf({ - bitstream: createSuccessfulRemoteDataObject( - bitstream, - ), + bitstream: createSuccessfulRemoteDataObject(bitstream), }), params: observableOf({ id: 'testid', }), + queryParams: observableOf({ + accessToken: undefined, + }), }; router = jasmine.createSpyObj('router', ['navigateByUrl']); @@ -94,6 +112,8 @@ describe('BitstreamDownloadPageComponent', () => { signpostingDataService = jasmine.createSpyObj('SignpostingDataService', { getLinks: observableOf([mocklink, mocklink2]), }); + matomoService = jasmine.createSpyObj('MatomoService', ['appendVisitorId']); + matomoService.appendVisitorId.and.callFake((link) => observableOf(link)); } function initTestbed() { @@ -108,7 +128,10 @@ describe('BitstreamDownloadPageComponent', () => { { provide: HardRedirectService, useValue: hardRedirectService }, { provide: ServerResponseService, useValue: serverResponseService }, { provide: SignpostingDataService, useValue: signpostingDataService }, + { provide: MatomoService, useValue: matomoService }, { provide: PLATFORM_ID, useValue: 'server' }, + { provide: Location, useValue: location }, + { provide: DSONameService, useValue: dsoNameService }, ], }) .compileComponents(); @@ -142,9 +165,11 @@ describe('BitstreamDownloadPageComponent', () => { component = fixture.componentInstance; fixture.detectChanges(); }); - it('should redirect to the content link', () => { - expect(hardRedirectService.redirect).toHaveBeenCalledWith('bitstream-content-link'); - }); + it('should redirect to the content link', waitForAsync(() => { + fixture.whenStable().then(() => { + expect(hardRedirectService.redirect).toHaveBeenCalledWith('bitstream-content-link'); + }); + })); it('should add the signposting links', () => { expect(serverResponseService.setHeader).toHaveBeenCalled(); }); @@ -159,9 +184,11 @@ describe('BitstreamDownloadPageComponent', () => { component = fixture.componentInstance; fixture.detectChanges(); }); - it('should redirect to an updated content link', () => { - expect(hardRedirectService.redirect).toHaveBeenCalledWith('content-url-with-headers'); - }); + it('should redirect to an updated content link', waitForAsync(() => { + fixture.whenStable().then(() => { + expect(hardRedirectService.redirect).toHaveBeenCalledWith('content-url-with-headers'); + }); + })); }); describe('when the user is not authorized and logged in', () => { beforeEach(waitForAsync(() => { @@ -174,9 +201,11 @@ describe('BitstreamDownloadPageComponent', () => { component = fixture.componentInstance; fixture.detectChanges(); }); - it('should navigate to the forbidden route', () => { - expect(router.navigateByUrl).toHaveBeenCalledWith(getForbiddenRoute(), { skipLocationChange: true }); - }); + it('should navigate to the forbidden route', waitForAsync(() => { + fixture.whenStable().then(() => { + expect(router.navigateByUrl).toHaveBeenCalledWith(getForbiddenRoute(), { skipLocationChange: true }); + }); + })); }); describe('when the user is not authorized and not logged in', () => { beforeEach(waitForAsync(() => { @@ -190,10 +219,12 @@ describe('BitstreamDownloadPageComponent', () => { component = fixture.componentInstance; fixture.detectChanges(); }); - it('should navigate to the login page', () => { - expect(authService.setRedirectUrl).toHaveBeenCalled(); - expect(router.navigateByUrl).toHaveBeenCalledWith('login'); - }); + it('should navigate to the login page', waitForAsync(() => { + fixture.whenStable().then(() => { + expect(authService.setRedirectUrl).toHaveBeenCalled(); + expect(router.navigateByUrl).toHaveBeenCalledWith('login'); + }); + })); }); }); }); diff --git a/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.ts b/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.ts index ee329df16e..7763ccd0aa 100644 --- a/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.ts +++ b/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.ts @@ -11,6 +11,7 @@ import { } from '@angular/core'; import { ActivatedRoute, + Params, Router, } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; @@ -44,6 +45,7 @@ import { hasValue, isNotEmpty, } from '../../shared/empty.util'; +import { MatomoService } from '../../statistics/matomo.service'; @Component({ selector: 'ds-bitstream-download-page', @@ -73,6 +75,7 @@ export class BitstreamDownloadPageComponent implements OnInit { public dsoNameService: DSONameService, private signpostingDataService: SignpostingDataService, private responseService: ServerResponseService, + private matomoService: MatomoService, @Inject(PLATFORM_ID) protected platformId: string, ) { this.initPageLinks(); @@ -83,6 +86,10 @@ export class BitstreamDownloadPageComponent implements OnInit { } ngOnInit(): void { + const accessToken$: Observable = this.route.queryParams.pipe( + map((queryParams: Params) => queryParams?.accessToken || null), + take(1), + ); this.bitstreamRD$ = this.route.data.pipe( map((data) => data.bitstream)); @@ -96,11 +103,11 @@ export class BitstreamDownloadPageComponent implements OnInit { switchMap((bitstream: Bitstream) => { const isAuthorized$ = this.authorizationService.isAuthorized(FeatureID.CanDownload, isNotEmpty(bitstream) ? bitstream.self : undefined); const isLoggedIn$ = this.auth.isAuthenticated(); - return observableCombineLatest([isAuthorized$, isLoggedIn$, observableOf(bitstream)]); + return observableCombineLatest([isAuthorized$, isLoggedIn$, accessToken$, observableOf(bitstream)]); }), - filter(([isAuthorized, isLoggedIn, bitstream]: [boolean, boolean, Bitstream]) => hasValue(isAuthorized) && hasValue(isLoggedIn)), + filter(([isAuthorized, isLoggedIn, accessToken, bitstream]: [boolean, boolean, string, Bitstream]) => (hasValue(isAuthorized) && hasValue(isLoggedIn)) || hasValue(accessToken)), take(1), - switchMap(([isAuthorized, isLoggedIn, bitstream]: [boolean, boolean, Bitstream]) => { + switchMap(([isAuthorized, isLoggedIn, accessToken, bitstream]: [boolean, boolean, string, Bitstream]) => { if (isAuthorized && isLoggedIn) { return this.fileService.retrieveFileDownloadLink(bitstream._links.content.href).pipe( filter((fileLink) => hasValue(fileLink)), @@ -108,20 +115,34 @@ export class BitstreamDownloadPageComponent implements OnInit { map((fileLink) => { return [isAuthorized, isLoggedIn, bitstream, fileLink]; })); + } else if (hasValue(accessToken)) { + return [[isAuthorized, !isLoggedIn, bitstream, '', accessToken]]; } else { - return [[isAuthorized, isLoggedIn, bitstream, '']]; + return [[isAuthorized, isLoggedIn, bitstream, bitstream._links.content.href]]; } }), - ).subscribe(([isAuthorized, isLoggedIn, bitstream, fileLink]: [boolean, boolean, Bitstream, string]) => { + switchMap(([isAuthorized, isLoggedIn, bitstream, fileLink, accessToken]: [boolean, boolean, Bitstream, string, string]) => + this.matomoService.appendVisitorId(fileLink) + .pipe( + map((fileLinkWithVisitorId) => [isAuthorized, isLoggedIn, bitstream, fileLinkWithVisitorId, accessToken]), + ), + ), + ).subscribe(([isAuthorized, isLoggedIn, bitstream, fileLink, accessToken]: [boolean, boolean, Bitstream, string, string]) => { if (isAuthorized && isLoggedIn && isNotEmpty(fileLink)) { this.hardRedirectService.redirect(fileLink); - } else if (isAuthorized && !isLoggedIn) { - this.hardRedirectService.redirect(bitstream._links.content.href); - } else if (!isAuthorized && isLoggedIn) { - this.router.navigateByUrl(getForbiddenRoute(), { skipLocationChange: true }); - } else if (!isAuthorized && !isLoggedIn) { - this.auth.setRedirectUrl(this.router.url); - this.router.navigateByUrl('login'); + } else if (isAuthorized && !isLoggedIn && !hasValue(accessToken)) { + this.hardRedirectService.redirect(fileLink); + } else if (!isAuthorized) { + // Either we have an access token, or we are logged in, or we are not logged in. + // For now, the access token does not care if we are logged in or not. + if (hasValue(accessToken)) { + this.hardRedirectService.redirect(bitstream._links.content.href + '?accessToken=' + accessToken); + } else if (isLoggedIn) { + this.router.navigateByUrl(getForbiddenRoute(), { skipLocationChange: true }); + } else if (!isLoggedIn) { + this.auth.setRedirectUrl(this.router.url); + this.router.navigateByUrl('login'); + } } }); } diff --git a/src/app/core/auth/access-token.resolver.ts b/src/app/core/auth/access-token.resolver.ts new file mode 100644 index 0000000000..a0646d72e8 --- /dev/null +++ b/src/app/core/auth/access-token.resolver.ts @@ -0,0 +1,62 @@ +import { inject } from '@angular/core'; +import { + ResolveFn, + Router, +} from '@angular/router'; +import { Observable } from 'rxjs'; +import { + map, + tap, +} from 'rxjs/operators'; + +import { getForbiddenRoute } from '../../app-routing-paths'; +import { hasValue } from '../../shared/empty.util'; +import { ItemRequestDataService } from '../data/item-request-data.service'; +import { RemoteData } from '../data/remote-data'; +import { redirectOn4xx } from '../shared/authorized.operators'; +import { ItemRequest } from '../shared/item-request.model'; +import { + getFirstCompletedRemoteData, + getFirstSucceededRemoteDataPayload, +} from '../shared/operators'; +import { AuthService } from './auth.service'; + +/** + * Resolve an ItemRequest based on the accessToken in the query params + * Used in item-page-routes.ts to resolve the item request for all Item page components + * @param route + * @param state + * @param router + * @param authService + * @param itemRequestDataService + */ +export const accessTokenResolver: ResolveFn = ( + route, + state, + router: Router = inject(Router), + authService: AuthService = inject(AuthService), + itemRequestDataService: ItemRequestDataService = inject(ItemRequestDataService), +): Observable => { + const accessToken = route.queryParams.accessToken; + // Set null object if accesstoken is empty + if ( !hasValue(accessToken) ) { + return null; + } + // Get the item request from the server + return itemRequestDataService.getSanitizedRequestByAccessToken(accessToken).pipe( + getFirstCompletedRemoteData(), + // Handle authorization errors, not found errors and forbidden errors as normal + redirectOn4xx(router, authService), + map((rd: RemoteData) => rd), + // Get payload of the item request + getFirstSucceededRemoteDataPayload(), + tap(request => { + if (!hasValue(request)) { + // If the request is not found, redirect to 403 Forbidden + router.navigateByUrl(getForbiddenRoute()); + } + // Return the resolved item request object + return request; + }), + ); +}; diff --git a/src/app/core/auth/auth-methods.service.spec.ts b/src/app/core/auth/auth-methods.service.spec.ts new file mode 100644 index 0000000000..a0acdc1b16 --- /dev/null +++ b/src/app/core/auth/auth-methods.service.spec.ts @@ -0,0 +1,133 @@ +import { TestBed } from '@angular/core/testing'; +import { + Store, + StoreModule, +} from '@ngrx/store'; +import { + MockStore, + provideMockStore, +} from '@ngrx/store/testing'; + +import { storeModuleConfig } from '../../app.reducer'; +import { AuthMethodTypeComponent } from '../../shared/log-in/methods/auth-methods.type'; +import { authReducer } from './auth.reducer'; +import { AuthMethodsService } from './auth-methods.service'; +import { AuthMethod } from './models/auth.method'; +import { AuthMethodType } from './models/auth.method-type'; + +describe('AuthMethodsService', () => { + let service: AuthMethodsService; + let store: MockStore; + let mockAuthMethods: Map; + let mockAuthMethodsArray: AuthMethod[] = [ + { id: 'password', authMethodType: AuthMethodType.Password, position: 2 } as AuthMethod, + { id: 'shibboleth', authMethodType: AuthMethodType.Shibboleth, position: 1 } as AuthMethod, + { id: 'oidc', authMethodType: AuthMethodType.Oidc, position: 3 } as AuthMethod, + { id: 'ip', authMethodType: AuthMethodType.Ip, position: 4 } as AuthMethod, + ]; + + const initialState = { + core: { + auth: { + authMethods: mockAuthMethodsArray, + }, + }, + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot(authReducer, storeModuleConfig), + ], + providers: [ + AuthMethodsService, + provideMockStore({ initialState }), + ], + }); + + service = TestBed.inject(AuthMethodsService); + store = TestBed.inject(Store) as MockStore; + + // Setup mock auth methods map + mockAuthMethods = new Map(); + mockAuthMethods.set(AuthMethodType.Password, {} as AuthMethodTypeComponent); + mockAuthMethods.set(AuthMethodType.Shibboleth, {} as AuthMethodTypeComponent); + mockAuthMethods.set(AuthMethodType.Oidc, {} as AuthMethodTypeComponent); + mockAuthMethods.set(AuthMethodType.Ip, {} as AuthMethodTypeComponent); + + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('getAuthMethods', () => { + it('should return auth methods sorted by position', () => { + + // Expected result after sorting and filtering IP auth + const expected = [ + { id: 'shibboleth', authMethodType: AuthMethodType.Shibboleth, position: 1 }, + { id: 'password', authMethodType: AuthMethodType.Password, position: 2 }, + { id: 'oidc', authMethodType: AuthMethodType.Oidc, position: 3 }, + ]; + + service.getAuthMethods(mockAuthMethods).subscribe(result => { + expect(result.length).toBe(3); + expect(result).toEqual(expected); + }); + }); + + it('should exclude specified auth method type', () => { + + // Expected result after excluding Password auth and filtering IP auth + const expected = [ + { id: 'shibboleth', authMethodType: AuthMethodType.Shibboleth, position: 1 }, + { id: 'oidc', authMethodType: AuthMethodType.Oidc, position: 3 }, + ]; + + + service.getAuthMethods(mockAuthMethods, AuthMethodType.Password).subscribe(result => { + expect(result.length).toBe(2); + expect(result).toEqual(expected); + }); + }); + + it('should always filter out IP authentication method', () => { + + // Add IP auth to the mock methods map + mockAuthMethods.set(AuthMethodType.Ip, {} as AuthMethodTypeComponent); + + + service.getAuthMethods(mockAuthMethods).subscribe(result => { + expect(result.length).toBe(3); + expect(result.find(method => method.authMethodType === AuthMethodType.Ip)).toBeUndefined(); + }); + }); + + it('should handle empty auth methods array', () => { + const authMethods = new Map(); + + + service.getAuthMethods(authMethods).subscribe(result => { + expect(result.length).toBe(0); + expect(result).toEqual([]); + }); + }); + + it('should handle duplicate auth method types and keep only unique ones', () => { + // Arrange + const duplicateMethodsArray = [ + ...mockAuthMethodsArray, + { id: 'password2', authMethodType: AuthMethodType.Password, position: 5 } as AuthMethod, + ]; + + + service.getAuthMethods(mockAuthMethods).subscribe(result => { + expect(result.length).toBe(3); + // Check that we only have one Password auth method + const passwordMethods = result.filter(method => method.authMethodType === AuthMethodType.Password); + expect(passwordMethods.length).toBe(1); + }); + }); + }); +}); diff --git a/src/app/core/auth/auth-methods.service.ts b/src/app/core/auth/auth-methods.service.ts new file mode 100644 index 0000000000..ad1f2358e0 --- /dev/null +++ b/src/app/core/auth/auth-methods.service.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@angular/core'; +import { + select, + Store, +} from '@ngrx/store'; +import uniqBy from 'lodash/uniqBy'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { AppState } from '../../app.reducer'; +import { AuthMethodTypeComponent } from '../../shared/log-in/methods/auth-methods.type'; +import { rendersAuthMethodType } from '../../shared/log-in/methods/log-in.methods-decorator.utils'; +import { AuthMethod } from './models/auth.method'; +import { AuthMethodType } from './models/auth.method-type'; +import { getAuthenticationMethods } from './selectors'; + +@Injectable({ + providedIn: 'root', +}) +/** + * Service responsible for managing and filtering authentication methods. + * Provides methods to retrieve and process authentication methods from the application store. + */ +export class AuthMethodsService { + constructor(protected store: Store) { + } + + /** + * Retrieves and processes authentication methods from the store. + * + * @param authMethods A map of authentication method types to their corresponding components + * @param excludedAuthMethod Optional authentication method type to exclude from the results + * @returns An Observable of filtered and sorted authentication methods + */ + public getAuthMethods( + authMethods: Map, + excludedAuthMethod?: AuthMethodType, + ): Observable { + return this.store.pipe( + select(getAuthenticationMethods), + map((methods: AuthMethod[]) => methods + // ignore the given auth method if it should be excluded + .filter((authMethod: AuthMethod) => excludedAuthMethod == null || authMethod.authMethodType !== excludedAuthMethod) + .filter((authMethod: AuthMethod) => rendersAuthMethodType(authMethods, authMethod.authMethodType) !== undefined) + .sort((method1: AuthMethod, method2: AuthMethod) => method1.position - method2.position), + ), + // ignore the ip authentication method when it's returned by the backend + map((methods: AuthMethod[]) => uniqBy(methods.filter(a => a.authMethodType !== AuthMethodType.Ip), 'authMethodType')), + ); + } +} diff --git a/src/app/core/auth/auth-request.service.ts b/src/app/core/auth/auth-request.service.ts index 5d11b9f4cb..3d94e33668 100644 --- a/src/app/core/auth/auth-request.service.ts +++ b/src/app/core/auth/auth-request.service.ts @@ -139,4 +139,5 @@ export abstract class AuthRequestService { }), ); } + } diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index cd773b68cf..e761eb83ae 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -62,6 +62,7 @@ import { getFirstCompletedRemoteData, } from '../shared/operators'; import { PageInfo } from '../shared/page-info.model'; +import { URLCombiner } from '../url-combiner/url-combiner'; import { CheckAuthenticationTokenAction, RefreshTokenAction, @@ -278,7 +279,7 @@ export class AuthService { if (status.hasSucceeded) { return status.payload.specialGroups; } else { - return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(),[])); + return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])); } }), ); @@ -579,6 +580,31 @@ export class AuthService { }); } + /** + * Returns the external server redirect URL. + * @param origin - The origin route. + * @param redirectRoute - The redirect route. + * @param location - The location. + * @returns The external server redirect URL. + */ + getExternalServerRedirectUrl(origin: string, redirectRoute: string, location: string): string { + const correctRedirectUrl = new URLCombiner(origin, redirectRoute).toString(); + + let externalServerUrl = location; + const myRegexp = /\?redirectUrl=(.*)/g; + const match = myRegexp.exec(location); + const redirectUrlFromServer = (match && match[1]) ? match[1] : null; + + // Check whether the current page is different from the redirect url received from rest + if (isNotNull(redirectUrlFromServer) && redirectUrlFromServer !== correctRedirectUrl) { + // change the redirect url with the current page url + const newRedirectUrl = `?redirectUrl=${correctRedirectUrl}`; + externalServerUrl = location.replace(/\?redirectUrl=(.*)/g, newRedirectUrl); + } + + return externalServerUrl; + } + /** * Clear redirect url */ @@ -663,5 +689,4 @@ export class AuthService { this.store.dispatch(new UnsetUserAsIdleAction()); } } - } diff --git a/src/app/core/auth/models/auth.method-type.ts b/src/app/core/auth/models/auth.method-type.ts index 87871f433e..5cdfb19ea3 100644 --- a/src/app/core/auth/models/auth.method-type.ts +++ b/src/app/core/auth/models/auth.method-type.ts @@ -6,5 +6,5 @@ export enum AuthMethodType { X509 = 'x509', Oidc = 'oidc', Orcid = 'orcid', - Saml = 'saml' + Saml = 'saml', } diff --git a/src/app/core/auth/models/auth.registration-type.ts b/src/app/core/auth/models/auth.registration-type.ts new file mode 100644 index 0000000000..b8aaa1fe40 --- /dev/null +++ b/src/app/core/auth/models/auth.registration-type.ts @@ -0,0 +1,4 @@ +export enum AuthRegistrationType { + Orcid = 'ORCID', + Validation = 'VALIDATION_', +} diff --git a/src/app/core/breadcrumbs/quality-assurance-breadcrumb.service.spec.ts b/src/app/core/breadcrumbs/quality-assurance-breadcrumb.service.spec.ts deleted file mode 100644 index f8d30754ca..0000000000 --- a/src/app/core/breadcrumbs/quality-assurance-breadcrumb.service.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { - TestBed, - waitForAsync, -} from '@angular/core/testing'; -import { getTestScheduler } from 'jasmine-marbles'; - -import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; -import { QualityAssuranceBreadcrumbService } from './quality-assurance-breadcrumb.service'; - -describe('QualityAssuranceBreadcrumbService', () => { - let service: QualityAssuranceBreadcrumbService; - let translateService: any = { - instant: (str) => str, - }; - - let exampleString; - let exampleURL; - let exampleQaKey; - - function init() { - exampleString = 'sourceId'; - exampleURL = '/test/quality-assurance/'; - exampleQaKey = 'admin.quality-assurance.breadcrumbs'; - } - - beforeEach(waitForAsync(() => { - init(); - TestBed.configureTestingModule({}).compileComponents(); - })); - - beforeEach(() => { - service = new QualityAssuranceBreadcrumbService(translateService); - }); - - describe('getBreadcrumbs', () => { - it('should return a breadcrumb based on a string', () => { - const breadcrumbs = service.getBreadcrumbs(exampleString, exampleURL); - getTestScheduler().expectObservable(breadcrumbs).toBe('(a|)', { a: [new Breadcrumb(exampleQaKey, exampleURL), - new Breadcrumb(exampleString, exampleURL + exampleString)], - }); - }); - }); -}); diff --git a/src/app/core/breadcrumbs/quality-assurance-breadcrumb.resolver.spec.ts b/src/app/core/breadcrumbs/sources-breadcrumb.resolver.spec.ts similarity index 50% rename from src/app/core/breadcrumbs/quality-assurance-breadcrumb.resolver.spec.ts rename to src/app/core/breadcrumbs/sources-breadcrumb.resolver.spec.ts index fe2fe77e7f..e0dde82def 100644 --- a/src/app/core/breadcrumbs/quality-assurance-breadcrumb.resolver.spec.ts +++ b/src/app/core/breadcrumbs/sources-breadcrumb.resolver.spec.ts @@ -1,15 +1,17 @@ -import { qualityAssuranceBreadcrumbResolver } from './quality-assurance-breadcrumb.resolver'; +import { sourcesBreadcrumbResolver } from './sources-breadcrumb.resolver'; -describe('qualityAssuranceBreadcrumbResolver', () => { +describe('sourcesBreadcrumbResolver', () => { describe('resolve', () => { let resolver: any; - let qualityAssuranceBreadcrumbService: any; + let sourcesBreadcrumbService: any; let route: any; + const i18nKey = 'breadcrumbKey'; const fullPath = '/test/quality-assurance/'; - const expectedKey = 'testSourceId:testTopicId'; + const expectedKey = 'breadcrumbKey:testSourceId:testTopicId'; beforeEach(() => { route = { + data: { breadcrumbKey: i18nKey }, paramMap: { get: function (param) { return this[param]; @@ -18,13 +20,13 @@ describe('qualityAssuranceBreadcrumbResolver', () => { topicId: 'testTopicId', }, }; - qualityAssuranceBreadcrumbService = {}; - resolver = qualityAssuranceBreadcrumbResolver; + sourcesBreadcrumbService = {}; + resolver = sourcesBreadcrumbResolver; }); it('should resolve the breadcrumb config', () => { - const resolvedConfig = resolver(route as any, { url: fullPath + 'testSourceId' } as any, qualityAssuranceBreadcrumbService); - const expectedConfig = { provider: qualityAssuranceBreadcrumbService, key: expectedKey, url: fullPath }; + const resolvedConfig = resolver(route as any, { url: fullPath + 'testSourceId' } as any, sourcesBreadcrumbService); + const expectedConfig = { provider: sourcesBreadcrumbService, key: expectedKey, url: fullPath }; expect(resolvedConfig).toEqual(expectedConfig); }); }); diff --git a/src/app/core/breadcrumbs/quality-assurance-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/sources-breadcrumb.resolver.ts similarity index 65% rename from src/app/core/breadcrumbs/quality-assurance-breadcrumb.resolver.ts rename to src/app/core/breadcrumbs/sources-breadcrumb.resolver.ts index 6507a75de6..253bed174c 100644 --- a/src/app/core/breadcrumbs/quality-assurance-breadcrumb.resolver.ts +++ b/src/app/core/breadcrumbs/sources-breadcrumb.resolver.ts @@ -6,16 +6,17 @@ import { } from '@angular/router'; import { BreadcrumbConfig } from '../../breadcrumbs/breadcrumb/breadcrumb-config.model'; -import { QualityAssuranceBreadcrumbService } from './quality-assurance-breadcrumb.service'; +import { SourcesBreadcrumbService } from './sources-breadcrumb.service'; -export const qualityAssuranceBreadcrumbResolver: ResolveFn> = ( +export const sourcesBreadcrumbResolver: ResolveFn> = ( route: ActivatedRouteSnapshot, state: RouterStateSnapshot, - breadcrumbService: QualityAssuranceBreadcrumbService = inject(QualityAssuranceBreadcrumbService), + breadcrumbService: SourcesBreadcrumbService = inject(SourcesBreadcrumbService), ): BreadcrumbConfig => { + const breadcrumbKey = route.data.breadcrumbKey; const sourceId = route.paramMap.get('sourceId'); const topicId = route.paramMap.get('topicId'); - let key = sourceId; + let key = `${breadcrumbKey}:${sourceId}`; if (topicId) { key += `:${topicId}`; diff --git a/src/app/core/breadcrumbs/sources-breadcrumb.service.spec.ts b/src/app/core/breadcrumbs/sources-breadcrumb.service.spec.ts new file mode 100644 index 0000000000..e4cdf523ce --- /dev/null +++ b/src/app/core/breadcrumbs/sources-breadcrumb.service.spec.ts @@ -0,0 +1,60 @@ +import { + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { getTestScheduler } from 'jasmine-marbles'; + +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { SourcesBreadcrumbService } from './sources-breadcrumb.service'; + +describe('SourcesBreadcrumbService', () => { + let service: SourcesBreadcrumbService; + let translateService: any = { + instant: (str) => str, + }; + + let exampleString; + let exampleSource; + let exampleTopic; + let exampleArg; + let exampleArgTopic; + let exampleURL; + let exampleQaKey; + + function init() { + exampleString = 'admin.quality-assurance'; + exampleSource = 'sourceId'; + exampleTopic = 'topic'; + exampleArg = `${exampleString}:${exampleSource}`; + exampleArgTopic = `${exampleString}:${exampleSource}:${exampleTopic}`; + exampleURL = '/test/quality-assurance/'; + exampleQaKey = 'admin.quality-assurance.breadcrumbs'; + } + + beforeEach(waitForAsync(() => { + init(); + TestBed.configureTestingModule({}).compileComponents(); + })); + + beforeEach(() => { + service = new SourcesBreadcrumbService(translateService); + }); + + describe('getBreadcrumbs', () => { + + it('should return a breadcrumb based on source only', () => { + const breadcrumbs = service.getBreadcrumbs(exampleArg, exampleURL); + getTestScheduler().expectObservable(breadcrumbs).toBe('(a|)', { a: [new Breadcrumb(exampleQaKey, exampleURL), + new Breadcrumb(exampleSource, exampleURL + exampleSource)], + }); + }); + + it('should return a breadcrumb based also on topic', () => { + const breadcrumbs = service.getBreadcrumbs(exampleArgTopic, exampleURL); + getTestScheduler().expectObservable(breadcrumbs).toBe('(a|)', { a: [new Breadcrumb(exampleQaKey, exampleURL), + new Breadcrumb(exampleSource, exampleURL + exampleSource), + new Breadcrumb(exampleTopic, undefined)], + }); + }); + }); +}); diff --git a/src/app/core/breadcrumbs/quality-assurance-breadcrumb.service.ts b/src/app/core/breadcrumbs/sources-breadcrumb.service.ts similarity index 73% rename from src/app/core/breadcrumbs/quality-assurance-breadcrumb.service.ts rename to src/app/core/breadcrumbs/sources-breadcrumb.service.ts index 580a5e5f8e..71a9ae2a7d 100644 --- a/src/app/core/breadcrumbs/quality-assurance-breadcrumb.service.ts +++ b/src/app/core/breadcrumbs/sources-breadcrumb.service.ts @@ -14,9 +14,9 @@ import { BreadcrumbsProviderService } from './breadcrumbsProviderService'; @Injectable({ providedIn: 'root', }) -export class QualityAssuranceBreadcrumbService implements BreadcrumbsProviderService { +export class SourcesBreadcrumbService implements BreadcrumbsProviderService { - private QUALITY_ASSURANCE_BREADCRUMB_KEY = 'admin.quality-assurance.breadcrumbs'; + private BREADCRUMB_SUFFIX = '.breadcrumbs'; constructor( private translationService: TranslateService, ) { @@ -31,15 +31,16 @@ export class QualityAssuranceBreadcrumbService implements BreadcrumbsProviderSer */ getBreadcrumbs(key: string, url: string): Observable { const args = key.split(':'); - const sourceId = args[0]; - const topicId = args.length > 2 ? args[args.length - 1] : args[1]; + const breadcrumbKey = args[0] + this.BREADCRUMB_SUFFIX; + const sourceId = args[1]; + const topicId = args.length > 3 ? args[args.length - 1] : args[2]; if (topicId) { - return observableOf( [new Breadcrumb(this.translationService.instant(this.QUALITY_ASSURANCE_BREADCRUMB_KEY), url), + return observableOf( [new Breadcrumb(this.translationService.instant(breadcrumbKey), url), new Breadcrumb(sourceId, `${url}${sourceId}`), new Breadcrumb(topicId, undefined)]); } else { - return observableOf([new Breadcrumb(this.translationService.instant(this.QUALITY_ASSURANCE_BREADCRUMB_KEY), url), + return observableOf([new Breadcrumb(this.translationService.instant(breadcrumbKey), url), new Breadcrumb(sourceId, `${url}${sourceId}`)]); } diff --git a/src/app/core/data/access-status-data.service.spec.ts b/src/app/core/data/access-status-data.service.spec.ts index ed587c26d2..27c4005460 100644 --- a/src/app/core/data/access-status-data.service.spec.ts +++ b/src/app/core/data/access-status-data.service.spec.ts @@ -46,11 +46,11 @@ describe('AccessStatusDataService', () => { createService(); }); - describe('when calling findAccessStatusFor', () => { + describe('when calling findItemAccessStatusFor', () => { let contentSource$; beforeEach(() => { - contentSource$ = service.findAccessStatusFor(mockItem); + contentSource$ = service.findItemAccessStatusFor(mockItem); }); it('should send a new GetRequest', fakeAsync(() => { diff --git a/src/app/core/data/access-status-data.service.ts b/src/app/core/data/access-status-data.service.ts index 6d8acb1c8b..576b972f56 100644 --- a/src/app/core/data/access-status-data.service.ts +++ b/src/app/core/data/access-status-data.service.ts @@ -29,7 +29,7 @@ export class AccessStatusDataService extends BaseDataService * Returns {@link RemoteData} of {@link AccessStatusObject} that is the access status of the given item * @param item Item we want the access status of */ - findAccessStatusFor(item: Item): Observable> { + findItemAccessStatusFor(item: Item): Observable> { return this.findByHref(item._links.accessStatus.href); } } diff --git a/src/app/core/data/eperson-registration.service.spec.ts b/src/app/core/data/eperson-registration.service.spec.ts index a60cef121a..b06f614139 100644 --- a/src/app/core/data/eperson-registration.service.spec.ts +++ b/src/app/core/data/eperson-registration.service.spec.ts @@ -95,7 +95,7 @@ describe('EpersonRegistrationService', () => { const expected = service.registerEmail('test@mail.org', 'afreshcaptchatoken'); let headers = new HttpHeaders(); const options: HttpOptions = Object.create({}); - headers = headers.append('x-recaptcha-token', 'afreshcaptchatoken'); + headers = headers.append('x-captcha-payload', 'afreshcaptchatoken'); options.headers = headers; expect(requestService.send).toHaveBeenCalledWith(new PostRequest('request-id', 'rest-url/registrations', registration, options)); @@ -105,7 +105,7 @@ describe('EpersonRegistrationService', () => { describe('searchByToken', () => { it('should return a registration corresponding to the provided token', () => { - const expected = service.searchByToken('test-token'); + const expected = service.searchByTokenAndUpdateData('test-token'); expect(expected).toBeObservable(cold('(a|)', { a: jasmine.objectContaining({ @@ -123,7 +123,7 @@ describe('EpersonRegistrationService', () => { testScheduler.run(({ cold, expectObservable }) => { rdbService.buildSingle.and.returnValue(cold('a', { a: rd })); - service.searchByToken('test-token'); + service.searchByTokenAndUpdateData('test-token'); expect(requestService.send).toHaveBeenCalledWith( jasmine.objectContaining({ diff --git a/src/app/core/data/eperson-registration.service.ts b/src/app/core/data/eperson-registration.service.ts index 90a3fab83a..a6a7f9478c 100644 --- a/src/app/core/data/eperson-registration.service.ts +++ b/src/app/core/data/eperson-registration.service.ts @@ -3,6 +3,7 @@ import { HttpParams, } from '@angular/common/http'; import { Injectable } from '@angular/core'; +import { Operation } from 'fast-json-patch'; import { Observable } from 'rxjs'; import { filter, @@ -18,6 +19,7 @@ import { RemoteDataBuildService } from '../cache/builders/remote-data-build.serv import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { GenericConstructor } from '../shared/generic-constructor'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { NoContent } from '../shared/NoContent.model'; import { getFirstCompletedRemoteData } from '../shared/operators'; import { Registration } from '../shared/registration.model'; import { ResponseParsingService } from './parsing.service'; @@ -25,6 +27,7 @@ import { RegistrationResponseParsingService } from './registration-response-pars import { RemoteData } from './remote-data'; import { GetRequest, + PatchRequest, PostRequest, } from './request.models'; import { RequestService } from './request.service'; @@ -45,7 +48,6 @@ export class EpersonRegistrationService { protected rdbService: RemoteDataBuildService, protected halService: HALEndpointService, ) { - } /** @@ -67,7 +69,7 @@ export class EpersonRegistrationService { /** * Register a new email address * @param email - * @param captchaToken the value of x-recaptcha-token header + * @param captchaToken the value of x-captcha-payload header */ registerEmail(email: string, captchaToken: string = null, type?: string): Observable> { const registration = new Registration(); @@ -80,7 +82,7 @@ export class EpersonRegistrationService { const options: HttpOptions = Object.create({}); let headers = new HttpHeaders(); if (captchaToken) { - headers = headers.append('x-recaptcha-token', captchaToken); + headers = headers.append('x-captcha-payload', captchaToken); } options.headers = headers; @@ -103,10 +105,11 @@ export class EpersonRegistrationService { } /** - * Search a registration based on the provided token - * @param token + * Searches for a registration based on the provided token. + * @param token The token to search for. + * @returns An observable of remote data containing the registration. */ - searchByToken(token: string): Observable> { + searchByTokenAndUpdateData(token: string): Observable> { const requestId = this.requestService.generateRequestId(); const href$ = this.getTokenSearchEndpoint(token).pipe( @@ -126,7 +129,11 @@ export class EpersonRegistrationService { return this.rdbService.buildSingle(href$).pipe( map((rd) => { if (rd.hasSucceeded && hasValue(rd.payload)) { - return Object.assign(rd, { payload: Object.assign(rd.payload, { token }) }); + return Object.assign(rd, { payload: Object.assign(new Registration(), { + email: rd.payload.email, + token: token, + user: rd.payload.user, + }) }); } else { return rd; } @@ -134,4 +141,69 @@ export class EpersonRegistrationService { ); } + /** + * Searches for a registration by token and handles any errors that may occur. + * @param token The token to search for. + * @returns An observable of remote data containing the registration. + */ + searchByTokenAndHandleError(token: string): Observable> { + const requestId = this.requestService.generateRequestId(); + + const href$ = this.getTokenSearchEndpoint(token).pipe( + find((href: string) => hasValue(href)), + ); + + href$.subscribe((href: string) => { + const request = new GetRequest(requestId, href); + Object.assign(request, { + getResponseParser(): GenericConstructor { + return RegistrationResponseParsingService; + }, + }); + this.requestService.send(request, true); + }); + return this.rdbService.buildSingle(href$); + } + + /** + * Patch the registration object to update the email address + * @param value provided by the user during the registration confirmation process + * @param registrationId The id of the registration object + * @param token The token of the registration object + * @param updateValue Flag to indicate if the email should be updated or added + * @returns Remote Data state of the patch request + */ + patchUpdateRegistration(values: string[], field: string, registrationId: string, token: string, operator: 'add' | 'replace'): Observable> { + const requestId = this.requestService.generateRequestId(); + + const href$ = this.getRegistrationEndpoint().pipe( + find((href: string) => hasValue(href)), + map((href: string) => `${href}/${registrationId}?token=${token}`), + ); + + href$.subscribe((href: string) => { + const operations = this.generateOperations(values, field, operator); + const patchRequest = new PatchRequest(requestId, href, operations); + this.requestService.send(patchRequest); + }); + + return this.rdbService.buildFromRequestUUID(requestId); + } + + /** + * Custom method to generate the operations to be performed on the registration object + * @param value provided by the user during the registration confirmation process + * @param updateValue Flag to indicate if the email should be updated or added + * @returns Operations to be performed on the registration object + */ + private generateOperations(values: string[], field: string, operator: 'add' | 'replace'): Operation[] { + let operations = []; + if (values.length > 0 && hasValue(field) ) { + operations = [{ + op: operator, path: `/${field}`, value: values, + }]; + } + + return operations; + } } diff --git a/src/app/core/data/item-request-data.service.spec.ts b/src/app/core/data/item-request-data.service.spec.ts index 68577ae6e2..59a497777f 100644 --- a/src/app/core/data/item-request-data.service.spec.ts +++ b/src/app/core/data/item-request-data.service.spec.ts @@ -1,10 +1,17 @@ +import { HttpHeaders } from '@angular/common/http'; import { of as observableOf } from 'rxjs'; import { RequestCopyEmail } from '../../request-copy/email-request-copy/request-copy-email.model'; +import { MockBitstream1 } from '../../shared/mocks/item.mock'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ConfigurationProperty } from '../shared/configuration-property.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { ItemRequest } from '../shared/item-request.model'; +import { ConfigurationDataService } from './configuration-data.service'; +import { AuthorizationDataService } from './feature-authorization/authorization-data.service'; +import { FeatureID } from './feature-authorization/feature-id'; +import { FindListOptions } from './find-list-options.model'; import { ItemRequestDataService } from './item-request-data.service'; import { PostRequest } from './request.models'; import { RequestService } from './request.service'; @@ -16,12 +23,36 @@ describe('ItemRequestDataService', () => { let requestService: RequestService; let rdbService: RemoteDataBuildService; let halService: HALEndpointService; + let configService: ConfigurationDataService; + let authorizationDataService: AuthorizationDataService; const restApiEndpoint = 'rest/api/endpoint/'; const requestId = 'request-id'; let itemRequest: ItemRequest; beforeEach(() => { + configService = jasmine.createSpyObj('ConfigurationDataService', ['findByPropertyName']); + (configService.findByPropertyName as jasmine.Spy).and.callFake((propertyName: string) => { + switch (propertyName) { + case 'request.item.create.captcha': + return createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'request.item.create.captcha', + values: ['true'], + })); + case 'request.item.grant.link.period': + return createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'request.item.grant.link.period', + values: ['FOREVER', '+1DAY', '+1MONTH'], + })); + default: + return createSuccessfulRemoteDataObject$(new ConfigurationProperty()); + } + }); + + + authorizationDataService = jasmine.createSpyObj('authorizationService', { + isAuthorized: observableOf(false), + }); itemRequest = Object.assign(new ItemRequest(), { token: 'item-request-token', }); @@ -36,13 +67,42 @@ describe('ItemRequestDataService', () => { getEndpoint: observableOf(restApiEndpoint), }); - service = new ItemRequestDataService(requestService, rdbService, null, halService); + service = new ItemRequestDataService(requestService, rdbService, null, halService, configService, authorizationDataService); + }); + + describe('searchBy', () => { + it('should use searchData to perform search operations', () => { + const searchMethod = 'testMethod'; + const options = new FindListOptions(); + + const searchDataSpy = spyOn((service as any).searchData, 'searchBy').and.returnValue(observableOf(null)); + + service.searchBy(searchMethod, options); + + expect(searchDataSpy).toHaveBeenCalledWith( + searchMethod, + options, + undefined, + undefined, + ); + }); }); describe('requestACopy', () => { it('should send a POST request containing the provided item request', (done) => { - service.requestACopy(itemRequest).subscribe(() => { - expect(requestService.send).toHaveBeenCalledWith(new PostRequest(requestId, restApiEndpoint, itemRequest)); + const captchaPayload = 'payload'; + service.requestACopy(itemRequest, captchaPayload).subscribe(() => { + expect(requestService.send).toHaveBeenCalledWith( + new PostRequest( + requestId, + restApiEndpoint, + itemRequest, + { + headers: new HttpHeaders().set('x-captcha-payload', captchaPayload), + }, + ), + false, + ); done(); }); }); @@ -56,14 +116,19 @@ describe('ItemRequestDataService', () => { }); it('should send a PUT request containing the correct properties', (done) => { - service.grant(itemRequest.token, email, true).subscribe(() => { + service.grant(itemRequest.token, email, true, '+1DAY').subscribe(() => { expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({ method: RestRequestMethod.PUT, + href: `${restApiEndpoint}/${itemRequest.token}`, body: JSON.stringify({ acceptRequest: true, responseMessage: email.message, subject: email.subject, suggestOpenAccess: true, + accessPeriod: '+1DAY', + }), + options: jasmine.objectContaining({ + headers: jasmine.any(HttpHeaders), }), })); done(); @@ -82,15 +147,70 @@ describe('ItemRequestDataService', () => { service.deny(itemRequest.token, email).subscribe(() => { expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({ method: RestRequestMethod.PUT, + href: `${restApiEndpoint}/${itemRequest.token}`, body: JSON.stringify({ acceptRequest: false, responseMessage: email.message, subject: email.subject, suggestOpenAccess: false, + accessPeriod: null, + }), + options: jasmine.objectContaining({ + headers: jasmine.any(HttpHeaders), }), })); done(); }); }); }); + + describe('requestACopy', () => { + it('should send a POST request containing the provided item request', (done) => { + const captchaPayload = 'payload'; + service.requestACopy(itemRequest, captchaPayload).subscribe(() => { + expect(requestService.send).toHaveBeenCalledWith( + new PostRequest( + requestId, + restApiEndpoint, + itemRequest, + { + headers: new HttpHeaders().set('x-captcha-payload', captchaPayload), + }, + ), + false, + ); + done(); + }); + }); + }); + + describe('getConfiguredAccessPeriods', () => { + it('should return parsed integer values from config', () => { + service.getConfiguredAccessPeriods().subscribe(periods => { + expect(periods).toEqual(['FOREVER', '+1DAY', '+1MONTH']); + }); + }); + }); + describe('isProtectedByCaptcha', () => { + it('should return true when config value is "true"', () => { + const mockConfigProperty = { + name: 'request.item.create.captcha', + values: ['true'], + } as ConfigurationProperty; + service.isProtectedByCaptcha().subscribe(result => { + expect(result).toBe(true); + }); + }); + }); + + describe('canDownload', () => { + it('should check authorization for bitstream download', () => { + service.canDownload(MockBitstream1).subscribe(result => { + expect(authorizationDataService.isAuthorized).toHaveBeenCalledWith(FeatureID.CanDownload, MockBitstream1.self); + expect(result).toBe(false); + }); + }); + }); + + }); diff --git a/src/app/core/data/item-request-data.service.ts b/src/app/core/data/item-request-data.service.ts index 5c85ed1471..26fd4923b4 100644 --- a/src/app/core/data/item-request-data.service.ts +++ b/src/app/core/data/item-request-data.service.ts @@ -13,14 +13,27 @@ import { hasValue, isNotEmpty, } from '../../shared/empty.util'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { RequestParam } from '../cache/models/request-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { Bitstream } from '../shared/bitstream.model'; +import { ConfigurationProperty } from '../shared/configuration-property.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { ItemRequest } from '../shared/item-request.model'; import { getFirstCompletedRemoteData } from '../shared/operators'; import { sendRequest } from '../shared/request.operators'; import { IdentifiableDataService } from './base/identifiable-data.service'; +import { + SearchData, + SearchDataImpl, +} from './base/search-data'; +import { ConfigurationDataService } from './configuration-data.service'; +import { AuthorizationDataService } from './feature-authorization/authorization-data.service'; +import { FeatureID } from './feature-authorization/feature-id'; +import { FindListOptions } from './find-list-options.model'; +import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; import { PostRequest, @@ -34,14 +47,20 @@ import { RequestService } from './request.service'; @Injectable({ providedIn: 'root', }) -export class ItemRequestDataService extends IdentifiableDataService { +export class ItemRequestDataService extends IdentifiableDataService implements SearchData { + + private searchData: SearchDataImpl; + constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, + protected configService: ConfigurationDataService, + protected authorizationService: AuthorizationDataService, ) { super('itemrequests', requestService, rdbService, objectCache, halService); + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); } getItemRequestEndpoint(): Observable { @@ -61,17 +80,26 @@ export class ItemRequestDataService extends IdentifiableDataService /** * Request a copy of an item * @param itemRequest + * @param captchaPayload payload of captcha verification */ - requestACopy(itemRequest: ItemRequest): Observable> { + requestACopy(itemRequest: ItemRequest, captchaPayload: string): Observable> { const requestId = this.requestService.generateRequestId(); const href$ = this.getItemRequestEndpoint(); + // Inject captcha payload into headers + const options: HttpOptions = Object.create({}); + if (captchaPayload) { + let headers = new HttpHeaders(); + headers = headers.set('x-captcha-payload', captchaPayload); + options.headers = headers; + } + href$.pipe( find((href: string) => hasValue(href)), map((href: string) => { - const request = new PostRequest(requestId, href, itemRequest); - this.requestService.send(request); + const request = new PostRequest(requestId, href, itemRequest, options); + this.requestService.send(request, false); }), ).subscribe(); @@ -94,9 +122,10 @@ export class ItemRequestDataService extends IdentifiableDataService * @param token Token of the {@link ItemRequest} * @param email Email to send back to the user requesting the item * @param suggestOpenAccess Whether or not to suggest the item to become open access + * @param accessPeriod How long in seconds to grant access, from the decision date (only applies to links, not attachments) */ - grant(token: string, email: RequestCopyEmail, suggestOpenAccess = false): Observable> { - return this.process(token, email, true, suggestOpenAccess); + grant(token: string, email: RequestCopyEmail, suggestOpenAccess = false, accessPeriod: string = null): Observable> { + return this.process(token, email, true, suggestOpenAccess, accessPeriod); } /** @@ -105,8 +134,9 @@ export class ItemRequestDataService extends IdentifiableDataService * @param email Email to send back to the user requesting the item * @param grant Grant or deny the request (true = grant, false = deny) * @param suggestOpenAccess Whether or not to suggest the item to become open access + * @param accessPeriod How long in seconds to grant access, from the decision date (only applies to links, not attachments) */ - process(token: string, email: RequestCopyEmail, grant: boolean, suggestOpenAccess = false): Observable> { + process(token: string, email: RequestCopyEmail, grant: boolean, suggestOpenAccess = false, accessPeriod: string = null): Observable> { const requestId = this.requestService.generateRequestId(); this.getItemRequestEndpointByToken(token).pipe( @@ -121,6 +151,7 @@ export class ItemRequestDataService extends IdentifiableDataService responseMessage: email.message, subject: email.subject, suggestOpenAccess, + accessPeriod: accessPeriod, }), options); }), sendRequest(this.requestService), @@ -128,4 +159,81 @@ export class ItemRequestDataService extends IdentifiableDataService return this.rdbService.buildFromRequestUUID(requestId); } + + /** + * Get a sanitized item request using the searchBy method and the access token sent to the original requester. + * + * @param accessToken access token contained in the secure link sent to a requester + */ + getSanitizedRequestByAccessToken(accessToken: string): Observable> { + const findListOptions = Object.assign({}, new FindListOptions(), { + searchParams: [ + new RequestParam('accessToken', accessToken), + ], + }); + const hrefObs = this.getSearchByHref( + 'byAccessToken', + findListOptions, + ); + + return this.searchData.findByHref( + hrefObs, + ); + } + + searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale); + } + + /** + * Get configured access periods (in seconds) to populate the dropdown in the item request approval form + * if the 'send secure link' feature is configured. + * Expects integer values, conversion to number is done in this processing + */ + getConfiguredAccessPeriods(): Observable { + return this.configService.findByPropertyName('request.item.grant.link.period').pipe( + getFirstCompletedRemoteData(), + map((propertyRD: RemoteData) => propertyRD.hasSucceeded ? propertyRD.payload.values : []), + ); + } + + /** + * Is the request copy form protected by a captcha? This will be used to decide whether to render the captcha + * component in bitstream-request-a-copy-page component + */ + isProtectedByCaptcha(): Observable { + return this.configService.findByPropertyName('request.item.create.captcha').pipe( + getFirstCompletedRemoteData(), + map((rd) => { + if (rd.hasSucceeded) { + return rd.payload.values.length > 0 && rd.payload.values[0] === 'true'; + } else { + return false; + } + })); + } + + /** + * Create the HREF for a specific object's search method with given options object + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @return {Observable} + * Return an observable that emits created HREF + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + public getSearchByHref(searchMethod: string, options?: FindListOptions, ...linksToFollow: FollowLinkConfig[]): Observable { + return this.searchData.getSearchByHref(searchMethod, options, ...linksToFollow); + } + + /** + * Authorization check to see if the user already has download access to the given bitstream. + * Wrapped in this service to give it a central place and make it easy to mock for testing. + * + * @param bitstream The bitstream to be downloaded + * @return {Observable} true if user may download, false if not + */ + canDownload(bitstream: Bitstream): Observable { + return this.authorizationService.isAuthorized(FeatureID.CanDownload, bitstream?.self); + } } diff --git a/src/app/core/data/proof-of-work-captcha-data.service.ts b/src/app/core/data/proof-of-work-captcha-data.service.ts new file mode 100644 index 0000000000..4f445c4dde --- /dev/null +++ b/src/app/core/data/proof-of-work-captcha-data.service.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { HALEndpointService } from '../shared/hal-endpoint.service'; + +/** + * Service for retrieving captcha challenge data, so proof-of-work calculations can be performed + * and returned with protected form data. + */ +@Injectable({ providedIn: 'root' }) +export class ProofOfWorkCaptchaDataService { + + private linkPath = 'captcha'; + + constructor( + private halService: HALEndpointService) { + } + + /** + * Get the endpoint for retrieving a new captcha challenge, to be passed + * to the Altcha captcha component as an input property + */ + public getChallengeHref(): Observable { + return this.getEndpoint().pipe( + map((endpoint) => endpoint + '/challenge'), + ); + } + + /** + * Get the base CAPTCHA endpoint URL + * @protected + */ + protected getEndpoint(): Observable { + return this.halService.getEndpoint(this.linkPath); + } +} diff --git a/src/app/core/data/signposting-links.model.ts b/src/app/core/data/signposting-links.model.ts index 11d2cafe00..dcc35e91e8 100644 --- a/src/app/core/data/signposting-links.model.ts +++ b/src/app/core/data/signposting-links.model.ts @@ -4,5 +4,6 @@ export interface SignpostingLink { href?: string, rel?: string, - type?: string + type?: string, + profile?: string } diff --git a/src/app/core/eperson/eperson-data.service.spec.ts b/src/app/core/eperson/eperson-data.service.spec.ts index 749afb5daa..ac735d8e92 100644 --- a/src/app/core/eperson/eperson-data.service.spec.ts +++ b/src/app/core/eperson/eperson-data.service.spec.ts @@ -46,6 +46,7 @@ import { CoreState } from '../core-state.model'; import { ChangeAnalyzer } from '../data/change-analyzer'; import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; import { FindListOptions } from '../data/find-list-options.model'; +import { RemoteData } from '../data/remote-data'; import { PatchRequest, PostRequest, @@ -351,6 +352,21 @@ describe('EPersonDataService', () => { }); }); + describe('mergeEPersonDataWithToken', () => { + const uuid = '1234-5678-9012-3456'; + const token = 'abcd-efgh-ijkl-mnop'; + const metadataKey = 'eperson.firstname'; + beforeEach(() => { + spyOn(service, 'mergeEPersonDataWithToken').and.returnValue(createSuccessfulRemoteDataObject$(EPersonMock)); + }); + + it('should merge EPerson data with token', () => { + service.mergeEPersonDataWithToken(uuid, token, metadataKey).subscribe((result: RemoteData) => { + expect(result.hasSucceeded).toBeTrue(); + }); + expect(service.mergeEPersonDataWithToken).toHaveBeenCalledWith(uuid, token, metadataKey); + }); + }); }); class DummyChangeAnalyzer implements ChangeAnalyzer { diff --git a/src/app/core/eperson/eperson-data.service.ts b/src/app/core/eperson/eperson-data.service.ts index 0de6de7407..33a02de4d7 100644 --- a/src/app/core/eperson/eperson-data.service.ts +++ b/src/app/core/eperson/eperson-data.service.ts @@ -394,6 +394,32 @@ export class EPersonDataService extends IdentifiableDataService impleme return this.rdbService.buildFromRequestUUID(requestId); } + /** + * Sends a POST request to merge registration data related to the provided registration-token, + * into the eperson related to the provided uuid + * @param uuid the user uuid + * @param token registration-token + * @param metadataKey metadata key of the metadata field that should be overriden + */ + mergeEPersonDataWithToken(uuid: string, token: string, metadataKey?: string): Observable> { + const requestId = this.requestService.generateRequestId(); + const hrefObs = this.getBrowseEndpoint().pipe( + map((href: string) => + hasValue(metadataKey) + ? `${href}/${uuid}?token=${token}&override=${metadataKey}` + : `${href}/${uuid}?token=${token}`, + ), + ); + + hrefObs.pipe( + find((href: string) => hasValue(href)), + ).subscribe((href: string) => { + const request = new PostRequest(requestId, href); + this.requestService.send(request); + }); + + return this.rdbService.buildFromRequestUUID(requestId); + } /** * Create a new object on the server, and store the response in the object cache diff --git a/src/app/core/provide-core.ts b/src/app/core/provide-core.ts index 78629f9d95..0057c0823d 100644 --- a/src/app/core/provide-core.ts +++ b/src/app/core/provide-core.ts @@ -176,7 +176,6 @@ export const models = ResearcherProfile, OrcidQueue, OrcidHistory, - AccessStatusObject, IdentifierData, Subscription, ItemRequest, diff --git a/src/app/core/shared/bitstream.model.ts b/src/app/core/shared/bitstream.model.ts index 73e5e04b36..29a80069db 100644 --- a/src/app/core/shared/bitstream.model.ts +++ b/src/app/core/shared/bitstream.model.ts @@ -4,6 +4,8 @@ import { inheritSerialization, } from 'cerialize'; import { Observable } from 'rxjs'; +import { AccessStatusObject } from 'src/app/shared/object-collection/shared/badges/access-status-badge/access-status.model'; +import { ACCESS_STATUS } from 'src/app/shared/object-collection/shared/badges/access-status-badge/access-status.resource-type'; import { link, @@ -52,6 +54,7 @@ export class Bitstream extends DSpaceObject implements ChildHALResource { format: HALLink; content: HALLink; thumbnail: HALLink; + accessStatus: HALLink; }; /** @@ -75,6 +78,13 @@ export class Bitstream extends DSpaceObject implements ChildHALResource { @link(BUNDLE) bundle?: Observable>; + /** + * The access status for this Bitstream + * Will be undefined unless the access status {@link HALLink} has been resolved. + */ + @link(ACCESS_STATUS, false, 'accessStatus') + accessStatus?: Observable>; + getParentLinkKey(): keyof this['_links'] { return 'format'; } diff --git a/src/app/core/shared/context.model.ts b/src/app/core/shared/context.model.ts index 566dba01b4..8775272f34 100644 --- a/src/app/core/shared/context.model.ts +++ b/src/app/core/shared/context.model.ts @@ -44,4 +44,10 @@ export enum Context { Bitstream = 'bitstream', CoarNotify = 'coarNotify', + + /** + * The Edit Metadata field Context values that are used in the Edit Item Metadata tab. + */ + AddMetadata = 'addMetadata', + EditMetadata = 'editMetadata', } diff --git a/src/app/core/shared/item-request.model.ts b/src/app/core/shared/item-request.model.ts index 5a4f912363..5180be1fe7 100644 --- a/src/app/core/shared/item-request.model.ts +++ b/src/app/core/shared/item-request.model.ts @@ -80,7 +80,19 @@ export class ItemRequest implements CacheableObject { */ @autoserialize bitstreamId: string; + /** + * Access token of the request (read-only) + */ + @autoserialize + accessToken: string; + /** + * Access expiry date of the request + */ + @autoserialize + accessExpiry: string; + @autoserialize + accessExpired: boolean; /** * The {@link HALLink}s for this ItemRequest */ diff --git a/src/app/core/shared/item.model.ts b/src/app/core/shared/item.model.ts index b13cf25f03..d6066f098c 100644 --- a/src/app/core/shared/item.model.ts +++ b/src/app/core/shared/item.model.ts @@ -130,7 +130,7 @@ export class Item extends DSpaceObject implements ChildHALResource, HandleObject * The access status for this Item * Will be undefined unless the access status {@link HALLink} has been resolved. */ - @link(ACCESS_STATUS) + @link(ACCESS_STATUS, false, 'accessStatus') accessStatus?: Observable>; /** diff --git a/src/app/core/shared/media-viewer-item.model.ts b/src/app/core/shared/media-viewer-item.model.ts index 1cf4948408..dd4fafeb34 100644 --- a/src/app/core/shared/media-viewer-item.model.ts +++ b/src/app/core/shared/media-viewer-item.model.ts @@ -23,4 +23,9 @@ export class MediaViewerItem { * Incoming Bitsream thumbnail */ thumbnail: string; + + /** + * Access token, if accessed via a Request-a-Copy link + */ + accessToken: string; } diff --git a/src/app/core/shared/registration.model.ts b/src/app/core/shared/registration.model.ts index e2efa6a02c..90663042fc 100644 --- a/src/app/core/shared/registration.model.ts +++ b/src/app/core/shared/registration.model.ts @@ -1,11 +1,27 @@ +// eslint-disable-next-line max-classes-per-file +import { AuthRegistrationType } from '../auth/models/auth.registration-type'; import { typedObject } from '../cache/builders/build-decorators'; +import { MetadataValue } from './metadata.models'; import { REGISTRATION } from './registration.resource-type'; import { ResourceType } from './resource-type'; import { UnCacheableObject } from './uncacheable-object.model'; +export class RegistrationDataMetadataMap { + [key: string]: RegistrationDataMetadataValue[]; +} + +export class RegistrationDataMetadataValue extends MetadataValue { + overrides?: string; +} @typedObject export class Registration implements UnCacheableObject { static type = REGISTRATION; + + /** + * The unique identifier of this registration data + */ + id: string; + /** * The object type */ @@ -29,8 +45,24 @@ export class Registration implements UnCacheableObject { * The token linked to the registration */ groupNames: string[]; + /** * The token linked to the registration */ groups: string[]; + + /** + * The registration type (e.g. orcid, shibboleth, etc.) + */ + registrationType?: AuthRegistrationType; + + /** + * The netId of the user (e.g. for ORCID - <:orcid>) + */ + netId?: string; + + /** + * The metadata involved during the registration process + */ + registrationMetadata?: RegistrationDataMetadataMap; } diff --git a/src/app/core/shared/search/search.service.spec.ts b/src/app/core/shared/search/search.service.spec.ts index 403def3d32..76bd2b06ca 100644 --- a/src/app/core/shared/search/search.service.spec.ts +++ b/src/app/core/shared/search/search.service.spec.ts @@ -37,6 +37,7 @@ import { SearchService } from './search.service'; import { SearchConfigurationService } from './search-configuration.service'; import anything = jasmine.anything; + @Component({ template: '', standalone: true, diff --git a/src/app/core/shared/search/search.service.ts b/src/app/core/shared/search/search.service.ts index 60a2da6af1..9fa03ee5ad 100644 --- a/src/app/core/shared/search/search.service.ts +++ b/src/app/core/shared/search/search.service.ts @@ -367,7 +367,7 @@ export class SearchService { const appliedFilter = appliedFilters[i]; filters.push(appliedFilter); } - this.angulartics2.eventTrack.next({ + const searchTrackObject = { action: 'search', properties: { searchOptions: config, @@ -384,7 +384,9 @@ export class SearchService { filters: filters, clickedObject, }, - }); + }; + + this.angulartics2.eventTrack.next(searchTrackObject); } /** diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.html index d1ec80376e..904a6b962f 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.html +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.html @@ -2,17 +2,18 @@ @for (mdValue of form.fields[mdField]; track mdValue; let idx = $index) { + [dso]="dso" + [context]="Context.EditMetadata" + [mdValue]="mdValue" + [mdField]="mdField" + [dsoType]="dsoType" + [saving$]="saving$" + [isOnlyValue]="form.fields[mdField].length === 1" + (edit)="mdValue.editing = true" + (confirm)="mdValue.confirmChanges($event); form.resetReinstatable(); valueSaved.emit()" + (remove)="mdValue.change === DsoEditMetadataChangeTypeEnum.ADD ? form.remove(mdField, idx) : mdValue.change = DsoEditMetadataChangeTypeEnum.REMOVE; form.resetReinstatable(); valueSaved.emit()" + (undo)="mdValue.change === DsoEditMetadataChangeTypeEnum.ADD ? form.remove(mdField, idx) : mdValue.discard(); valueSaved.emit()" + (dragging)="$event ? draggingMdField$.next(mdField) : draggingMdField$.next(null)"> } diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.ts index 003ce13ad3..f4b66c6113 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.ts @@ -15,6 +15,7 @@ import { Observable, } from 'rxjs'; +import { Context } from '../../../core/shared/context.model'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { DsoEditMetadataChangeType, @@ -78,6 +79,8 @@ export class DsoEditMetadataFieldValuesComponent { */ public DsoEditMetadataChangeTypeEnum = DsoEditMetadataChangeType; + public readonly Context = Context; + /** * Drop a value into a new position * Update the form's value array for the current field to match the dropped position diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/abstract-dso-edit-metadata-value-field.component.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/abstract-dso-edit-metadata-value-field.component.ts new file mode 100644 index 0000000000..3bd5b5b2e9 --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/abstract-dso-edit-metadata-value-field.component.ts @@ -0,0 +1,63 @@ +import { + Component, + EventEmitter, + Input, + Output, +} from '@angular/core'; + +import { Context } from '../../../core/shared/context.model'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { DsoEditMetadataValue } from '../dso-edit-metadata-form'; +import { EditMetadataValueFieldType } from './dso-edit-metadata-field-type.enum'; + +/** + * Abstract base component for editing metadata fields. + * + * This abstract component is only designed to contain the common `@Input()` & `@Output()` fields, that the + * {@link DsoEditMetadataValueFieldLoaderComponent} passes to its dynamically generated components. This class should + * not contain any methods or any other type of logic. Such logic should instead be created in + * {@link DsoEditMetadataFieldService}. + */ +@Component({ + selector: 'ds-abstract-dso-edit-metadata-value-field', + template: '', + standalone: true, +}) +export abstract class AbstractDsoEditMetadataValueFieldComponent { + + /** + * The optional context + */ + @Input() context: Context; + + /** + * The {@link DSpaceObject} + */ + @Input() dso: DSpaceObject; + + /** + * The type of the DSO, used to determines i18n messages + */ + @Input() dsoType: string; + + /** + * The type of the field + */ + @Input() type: EditMetadataValueFieldType; + + /** + * The metadata field + */ + @Input() mdField: string; + + /** + * Editable metadata value to show + */ + @Input() mdValue: DsoEditMetadataValue; + + /** + * Emits when the user clicked confirm + */ + @Output() confirm: EventEmitter = new EventEmitter(); + +} diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-authority-field/dso-edit-metadata-authority-field.component.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-authority-field/dso-edit-metadata-authority-field.component.html new file mode 100644 index 0000000000..995cfc7b61 --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-authority-field/dso-edit-metadata-authority-field.component.html @@ -0,0 +1,61 @@ +@if (mdValue.editing) { + @if ((isAuthorityControlled$ | async) !== true || (enabledFreeTextEditing && (isSuggesterVocabulary$ | async) !== true)) { + + } + @if ((isScrollableVocabulary$ | async) && !enabledFreeTextEditing) { + + + } + @if (((isHierarchicalVocabulary$ | async) && !enabledFreeTextEditing) || (isSuggesterVocabulary$ | async)) { + + + } + @if ((isHierarchicalVocabulary$ | async) || (isScrollableVocabulary$ | async)) { + + } + @if ((isAuthorityControlled$ | async) && (isSuggesterVocabulary$ | async)) { +
+
+ + + @if (editingAuthority) { + + } @else { + + } +
+
+ } +} diff --git a/src/app/notifications/suggestion-list-element/suggestion-evidences/suggestion-evidences.component.scss b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-authority-field/dso-edit-metadata-authority-field.component.scss similarity index 100% rename from src/app/notifications/suggestion-list-element/suggestion-evidences/suggestion-evidences.component.scss rename to src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-authority-field/dso-edit-metadata-authority-field.component.scss diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-authority-field/dso-edit-metadata-authority-field.component.spec.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-authority-field/dso-edit-metadata-authority-field.component.spec.ts new file mode 100644 index 0000000000..1482c329b5 --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-authority-field/dso-edit-metadata-authority-field.component.spec.ts @@ -0,0 +1,360 @@ +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { TranslateModule } from '@ngx-translate/core'; + +import { ItemDataService } from '../../../../core/data/item-data.service'; +import { MetadataField } from '../../../../core/metadata/metadata-field.model'; +import { MetadataSchema } from '../../../../core/metadata/metadata-schema.model'; +import { RegistryService } from '../../../../core/registry/registry.service'; +import { Collection } from '../../../../core/shared/collection.model'; +import { ConfidenceType } from '../../../../core/shared/confidence-type'; +import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; +import { Item } from '../../../../core/shared/item.model'; +import { MetadataValue } from '../../../../core/shared/metadata.models'; +import { Vocabulary } from '../../../../core/submission/vocabularies/models/vocabulary.model'; +import { VocabularyService } from '../../../../core/submission/vocabularies/vocabulary.service'; +import { DynamicOneboxModel } from '../../../../shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.model'; +import { DsDynamicScrollableDropdownComponent } from '../../../../shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component'; +import { DynamicScrollableDropdownModel } from '../../../../shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.model'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; +import { createPaginatedList } from '../../../../shared/testing/utils.test'; +import { VocabularyServiceStub } from '../../../../shared/testing/vocabulary-service.stub'; +import { DsoEditMetadataValue } from '../../dso-edit-metadata-form'; +import { DsoEditMetadataAuthorityFieldComponent } from './dso-edit-metadata-authority-field.component'; + +describe('DsoEditMetadataAuthorityFieldComponent', () => { + let component: DsoEditMetadataAuthorityFieldComponent; + let fixture: ComponentFixture; + + let vocabularyService: any; + let itemService: ItemDataService; + let registryService: RegistryService; + let notificationsService: NotificationsService; + + let dso: DSpaceObject; + + const collection = Object.assign(new Collection(), { + uuid: 'fake-uuid', + }); + + const item = Object.assign(new Item(), { + _links: { + self: { href: 'fake-item-url/item' }, + }, + id: 'item', + uuid: 'item', + owningCollection: createSuccessfulRemoteDataObject$(collection), + }); + + const mockVocabularyScrollable: Vocabulary = { + id: 'scrollable', + name: 'scrollable', + scrollable: true, + hierarchical: false, + preloadLevel: 0, + type: 'vocabulary', + _links: { + self: { + href: 'self', + }, + entries: { + href: 'entries', + }, + }, + }; + + const mockVocabularyHierarchical: Vocabulary = { + id: 'hierarchical', + name: 'hierarchical', + scrollable: false, + hierarchical: true, + preloadLevel: 2, + type: 'vocabulary', + _links: { + self: { + href: 'self', + }, + entries: { + href: 'entries', + }, + }, + }; + + const mockVocabularySuggester: Vocabulary = { + id: 'suggester', + name: 'suggester', + scrollable: false, + hierarchical: false, + preloadLevel: 0, + type: 'vocabulary', + _links: { + self: { + href: 'self', + }, + entries: { + href: 'entries', + }, + }, + }; + + let editMetadataValue: DsoEditMetadataValue; + let metadataValue: MetadataValue; + let metadataSchema: MetadataSchema; + let metadataFields: MetadataField[]; + + beforeEach(async () => { + itemService = jasmine.createSpyObj('itemService', { + findByHref: createSuccessfulRemoteDataObject$(item), + }); + vocabularyService = new VocabularyServiceStub(); + registryService = jasmine.createSpyObj('registryService', { + queryMetadataFields: createSuccessfulRemoteDataObject$(createPaginatedList(metadataFields)), + }); + notificationsService = jasmine.createSpyObj('notificationsService', ['error', 'success']); + + metadataValue = Object.assign(new MetadataValue(), { + value: 'Regular Name', + language: 'en', + place: 0, + authority: undefined, + }); + editMetadataValue = new DsoEditMetadataValue(metadataValue); + metadataSchema = Object.assign(new MetadataSchema(), { + id: 0, + prefix: 'metadata', + namespace: 'https://example.com/', + }); + metadataFields = [ + Object.assign(new MetadataField(), { + id: 0, + element: 'regular', + qualifier: null, + schema: createSuccessfulRemoteDataObject$(metadataSchema), + }), + ]; + dso = Object.assign(new DSpaceObject(), { + _links: { + self: { href: 'fake-dso-url/dso' }, + }, + }); + + await TestBed.configureTestingModule({ + imports: [ + DsoEditMetadataAuthorityFieldComponent, + TranslateModule.forRoot(), + ], + providers: [ + { provide: VocabularyService, useValue: vocabularyService }, + { provide: ItemDataService, useValue: itemService }, + { provide: RegistryService, useValue: registryService }, + { provide: NotificationsService, useValue: notificationsService }, + ], + }).overrideComponent(DsoEditMetadataAuthorityFieldComponent, { + remove: { + imports: [ + DsDynamicScrollableDropdownComponent, + ], + }, + }).compileComponents(); + + fixture = TestBed.createComponent(DsoEditMetadataAuthorityFieldComponent); + component = fixture.componentInstance; + component.mdValue = editMetadataValue; + component.dso = dso; + fixture.detectChanges(); + }); + + describe('when the metadata field uses a scrollable vocabulary and is editing', () => { + beforeEach(waitForAsync(() => { + spyOn(vocabularyService, 'getVocabularyByMetadataAndCollection').and.returnValue(createSuccessfulRemoteDataObject$(mockVocabularyScrollable)); + metadataValue = Object.assign(new MetadataValue(), { + value: 'Authority Controlled value', + language: 'en', + place: 0, + authority: null, + }); + editMetadataValue = new DsoEditMetadataValue(metadataValue); + editMetadataValue.editing = true; + component.mdValue = editMetadataValue; + component.mdField = 'metadata.scrollable'; + component.ngOnInit(); + fixture.detectChanges(); + })); + + it('should render the DsDynamicScrollableDropdownComponent', () => { + expect(vocabularyService.getVocabularyByMetadataAndCollection).toHaveBeenCalled(); + expect(fixture.debugElement.query(By.css('ds-dynamic-scrollable-dropdown'))).toBeTruthy(); + }); + + it('getModel should return a DynamicScrollableDropdownModel', () => { + const model = component.getModel(); + + expect(model instanceof DynamicScrollableDropdownModel).toBe(true); + expect(model.vocabularyOptions.name).toBe(mockVocabularyScrollable.name); + }); + }); + + describe('when the metadata field uses a hierarchical vocabulary and is editing', () => { + beforeEach(waitForAsync(() => { + spyOn(vocabularyService, 'getVocabularyByMetadataAndCollection').and.returnValue(createSuccessfulRemoteDataObject$(mockVocabularyHierarchical)); + metadataValue = Object.assign(new MetadataValue(), { + value: 'Authority Controlled value', + language: 'en', + place: 0, + authority: null, + }); + editMetadataValue = new DsoEditMetadataValue(metadataValue); + editMetadataValue.editing = true; + component.mdValue = editMetadataValue; + component.mdField = 'metadata.hierarchical'; + component.ngOnInit(); + fixture.detectChanges(); + })); + + it('should render the DsDynamicOneboxComponent', () => { + expect(vocabularyService.getVocabularyByMetadataAndCollection).toHaveBeenCalled(); + expect(fixture.debugElement.query(By.css('ds-dynamic-onebox'))).toBeTruthy(); + }); + + it('getModel should return a DynamicOneboxModel', () => { + const model = component.getModel(); + + expect(model instanceof DynamicOneboxModel).toBe(true); + expect(model.vocabularyOptions.name).toBe(mockVocabularyHierarchical.name); + }); + }); + + describe('when the metadata field uses a suggester vocabulary and is editing', () => { + beforeEach(waitForAsync(() => { + spyOn(vocabularyService, 'getVocabularyByMetadataAndCollection').and.returnValue(createSuccessfulRemoteDataObject$(mockVocabularySuggester)); + spyOn(component.confirm, 'emit'); + metadataValue = Object.assign(new MetadataValue(), { + value: 'Authority Controlled value', + language: 'en', + place: 0, + authority: 'authority-key', + confidence: ConfidenceType.CF_UNCERTAIN, + }); + editMetadataValue = new DsoEditMetadataValue(metadataValue); + editMetadataValue.editing = true; + component.mdValue = editMetadataValue; + component.mdField = 'metadata.suggester'; + component.ngOnInit(); + fixture.detectChanges(); + })); + + it('should render the DsDynamicOneboxComponent', () => { + expect(vocabularyService.getVocabularyByMetadataAndCollection).toHaveBeenCalled(); + expect(fixture.debugElement.query(By.css('ds-dynamic-onebox'))).toBeTruthy(); + }); + + it('getModel should return a DynamicOneboxModel', () => { + const model = component.getModel(); + + expect(model instanceof DynamicOneboxModel).toBe(true); + expect(model.vocabularyOptions.name).toBe(mockVocabularySuggester.name); + }); + + describe('authority key edition', () => { + + it('should update confidence to CF_NOVALUE when authority is cleared', () => { + component.mdValue.newValue.authority = ''; + + component.onChangeAuthorityKey(); + + expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_NOVALUE); + expect(component.confirm.emit).toHaveBeenCalledWith(false); + }); + + it('should update confidence to CF_ACCEPTED when authority key is edited', () => { + component.mdValue.newValue.authority = 'newAuthority'; + component.mdValue.originalValue.authority = 'oldAuthority'; + + component.onChangeAuthorityKey(); + + expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_ACCEPTED); + expect(component.confirm.emit).toHaveBeenCalledWith(false); + }); + + it('should not update confidence when authority key remains the same', () => { + component.mdValue.newValue.authority = 'sameAuthority'; + component.mdValue.originalValue.authority = 'sameAuthority'; + + component.onChangeAuthorityKey(); + + expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_UNCERTAIN); + expect(component.confirm.emit).not.toHaveBeenCalled(); + }); + + it('should call onChangeEditingAuthorityStatus with true when clicking the lock button', () => { + spyOn(component, 'onChangeEditingAuthorityStatus'); + const lockButton = fixture.nativeElement.querySelector('#metadata-confirm-btn'); + + lockButton.click(); + + expect(component.onChangeEditingAuthorityStatus).toHaveBeenCalledWith(true); + }); + + it('should disable the input when editingAuthority is false', (done) => { + component.editingAuthority = false; + + fixture.detectChanges(); + + fixture.detectChanges(); + fixture.whenStable().then(() => { + const inputElement = fixture.nativeElement.querySelector('input[data-test="authority-input"]'); + expect(inputElement.disabled).toBeTruthy(); + done(); + }); + }); + + it('should enable the input when editingAuthority is true', (done) => { + component.editingAuthority = true; + + fixture.detectChanges(); + fixture.whenStable().then(() => { + const inputElement = fixture.nativeElement.querySelector('input[data-test="authority-input"]'); + expect(inputElement.disabled).toBeFalsy(); + done(); + }); + + + }); + + it('should update mdValue.newValue properties when authority is present', () => { + const event = { + value: 'Some value', + authority: 'Some authority', + }; + + component.onChangeAuthorityField(event); + + expect(component.mdValue.newValue.value).toBe(event.value); + expect(component.mdValue.newValue.authority).toBe(event.authority); + expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_ACCEPTED); + expect(component.confirm.emit).toHaveBeenCalledWith(false); + }); + + it('should update mdValue.newValue properties when authority is not present', () => { + const event = { + value: 'Some value', + authority: null, + }; + + component.onChangeAuthorityField(event); + + expect(component.mdValue.newValue.value).toBe(event.value); + expect(component.mdValue.newValue.authority).toBeNull(); + expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_UNSET); + expect(component.confirm.emit).toHaveBeenCalledWith(false); + }); + + }); + + }); +}); diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-authority-field/dso-edit-metadata-authority-field.component.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-authority-field/dso-edit-metadata-authority-field.component.ts new file mode 100644 index 0000000000..e4101535ed --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-authority-field/dso-edit-metadata-authority-field.component.ts @@ -0,0 +1,331 @@ +import { + AsyncPipe, + NgClass, +} from '@angular/common'; +import { + ChangeDetectorRef, + Component, + OnChanges, + OnInit, + SimpleChanges, +} from '@angular/core'; +import { + FormsModule, + UntypedFormControl, + UntypedFormGroup, +} from '@angular/forms'; +import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { + BehaviorSubject, + Observable, + of as observableOf, +} from 'rxjs'; +import { + map, + switchMap, + take, + tap, +} from 'rxjs/operators'; + +import { ItemDataService } from '../../../../core/data/item-data.service'; +import { RegistryService } from '../../../../core/registry/registry.service'; +import { ConfidenceType } from '../../../../core/shared/confidence-type'; +import { + getFirstCompletedRemoteData, + metadataFieldsToString, +} from '../../../../core/shared/operators'; +import { Vocabulary } from '../../../../core/submission/vocabularies/models/vocabulary.model'; +import { VocabularyOptions } from '../../../../core/submission/vocabularies/models/vocabulary-options.model'; +import { isNotEmpty } from '../../../../shared/empty.util'; +import { DsDynamicOneboxComponent } from '../../../../shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component'; +import { + DsDynamicOneboxModelConfig, + DynamicOneboxModel, +} from '../../../../shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.model'; +import { DsDynamicScrollableDropdownComponent } from '../../../../shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component'; +import { + DynamicScrollableDropdownModel, + DynamicScrollableDropdownModelConfig, +} from '../../../../shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.model'; +import { FormFieldMetadataValueObject } from '../../../../shared/form/builder/models/form-field-metadata-value.model'; +import { AuthorityConfidenceStateDirective } from '../../../../shared/form/directives/authority-confidence-state.directive'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { DebounceDirective } from '../../../../shared/utils/debounce.directive'; +import { followLink } from '../../../../shared/utils/follow-link-config.model'; +import { AbstractDsoEditMetadataValueFieldComponent } from '../abstract-dso-edit-metadata-value-field.component'; +import { DsoEditMetadataFieldService } from '../dso-edit-metadata-field.service'; + +/** + * The component used to gather input for authority controlled metadata fields + */ +@Component({ + selector: 'ds-dso-edit-metadata-authority-field', + templateUrl: './dso-edit-metadata-authority-field.component.html', + styleUrls: ['./dso-edit-metadata-authority-field.component.scss'], + standalone: true, + imports: [ + DsDynamicScrollableDropdownComponent, + DsDynamicOneboxComponent, + AuthorityConfidenceStateDirective, + NgbTooltipModule, + AsyncPipe, + TranslateModule, + FormsModule, + NgClass, + DebounceDirective, + ], +}) +export class DsoEditMetadataAuthorityFieldComponent extends AbstractDsoEditMetadataValueFieldComponent implements OnInit, OnChanges { + + /** + * Whether the authority field is currently being edited + */ + public editingAuthority = false; + + /** + * Whether the free-text editing is enabled when scrollable dropdown or hierarchical vocabulary is used + */ + public enabledFreeTextEditing = false; + + /** + * Field group used by authority field + */ + group = new UntypedFormGroup({ authorityField: new UntypedFormControl() }); + + /** + * Model to use for editing authorities values + */ + private model$: BehaviorSubject = new BehaviorSubject(null); + + /** + * Observable with information about the authority vocabulary used + */ + private vocabulary$: Observable; + + /** + * Observables with information about the authority vocabulary type used + */ + isAuthorityControlled$: Observable; + isHierarchicalVocabulary$: Observable; + isScrollableVocabulary$: Observable; + isSuggesterVocabulary$: Observable; + + constructor( + protected cdr: ChangeDetectorRef, + protected dsoEditMetadataFieldService: DsoEditMetadataFieldService, + protected itemService: ItemDataService, + protected notificationsService: NotificationsService, + protected registryService: RegistryService, + protected translate: TranslateService, + ) { + super(); + } + + ngOnInit(): void { + this.initAuthorityProperties(); + } + + /** + * Initialise potential properties of a authority controlled metadata field + */ + initAuthorityProperties(): void { + this.vocabulary$ = this.dsoEditMetadataFieldService.findDsoFieldVocabulary(this.dso, this.mdField); + + this.isAuthorityControlled$ = this.vocabulary$.pipe( + // Create the model used by the authority fields to ensure its existence when the field is initialized + tap((v: Vocabulary) => this.model$.next(this.createModel(v))), + map((result: Vocabulary) => isNotEmpty(result)), + ); + + this.isHierarchicalVocabulary$ = this.vocabulary$.pipe( + map((result: Vocabulary) => isNotEmpty(result) && result.hierarchical), + ); + + this.isScrollableVocabulary$ = this.vocabulary$.pipe( + map((result: Vocabulary) => isNotEmpty(result) && result.scrollable), + ); + + this.isSuggesterVocabulary$ = this.vocabulary$.pipe( + map((result: Vocabulary) => isNotEmpty(result) && !result.hierarchical && !result.scrollable), + ); + + } + + /** + * Returns a {@link DynamicOneboxModel} or {@link DynamicScrollableDropdownModel} model based on the + * vocabulary used. + */ + private createModel(vocabulary: Vocabulary): DynamicOneboxModel | DynamicScrollableDropdownModel { + if (isNotEmpty(vocabulary)) { + let formFieldValue: FormFieldMetadataValueObject | string; + if (isNotEmpty(this.mdValue.newValue.value)) { + formFieldValue = new FormFieldMetadataValueObject(); + formFieldValue.value = this.mdValue.newValue.value; + formFieldValue.display = this.mdValue.newValue.value; + if (this.mdValue.newValue.authority) { + formFieldValue.authority = this.mdValue.newValue.authority; + formFieldValue.confidence = this.mdValue.newValue.confidence; + } + } else { + formFieldValue = this.mdValue.newValue.value; + } + + const vocabularyOptions = vocabulary ? { + closed: false, + name: vocabulary.name, + } as VocabularyOptions : null; + + if (!vocabulary.scrollable) { + const model: DsDynamicOneboxModelConfig = { + id: 'authorityField', + label: `${this.dsoType}.edit.metadata.edit.value`, + vocabularyOptions: vocabularyOptions, + metadataFields: [this.mdField], + value: formFieldValue, + repeatable: false, + submissionId: 'edit-metadata', + hasSelectableMetadata: false, + }; + return new DynamicOneboxModel(model); + } else { + const model: DynamicScrollableDropdownModelConfig = { + id: 'authorityField', + label: `${this.dsoType}.edit.metadata.edit.value`, + placeholder: `${this.dsoType}.edit.metadata.edit.value`, + vocabularyOptions: vocabularyOptions, + metadataFields: [this.mdField], + value: formFieldValue, + repeatable: false, + submissionId: 'edit-metadata', + hasSelectableMetadata: false, + maxOptions: 10, + }; + return new DynamicScrollableDropdownModel(model); + } + } else { + return null; + } + } + + /** + * Change callback for the component. Check if the mdField has changed to retrieve whether it is metadata + * that uses a controlled vocabulary and update the related properties + * + * @param {SimpleChanges} changes + */ + ngOnChanges(changes: SimpleChanges): void { + if (isNotEmpty(changes.mdField) && !changes.mdField.firstChange) { + if (isNotEmpty(changes.mdField.currentValue)) { + if (isNotEmpty(changes.mdField.previousValue) && + changes.mdField.previousValue !== changes.mdField.currentValue) { + // Clear authority value in case it has been assigned with the previous metadataField used + this.mdValue.newValue.authority = null; + this.mdValue.newValue.confidence = ConfidenceType.CF_UNSET; + } + + // Only ask if the current mdField have a period character to reduce request + if (changes.mdField.currentValue.includes('.')) { + this.validateMetadataField().subscribe((isValid: boolean) => { + if (isValid) { + this.initAuthorityProperties(); + this.cdr.detectChanges(); + } + }); + } + } + } + } + + /** + * Validate the metadata field to check if it exists on the server and return an observable boolean for success/error + */ + validateMetadataField(): Observable { + return this.registryService.queryMetadataFields(this.mdField, null, true, false, followLink('schema')).pipe( + getFirstCompletedRemoteData(), + switchMap((rd) => { + if (rd.hasSucceeded) { + return observableOf(rd).pipe( + metadataFieldsToString(), + take(1), + map((fields: string[]) => fields.indexOf(this.mdField) > -1), + ); + } else { + this.notificationsService.error(this.translate.instant(`${this.dsoType}.edit.metadata.metadatafield.error`), rd.errorMessage); + return [false]; + } + }), + ); + } + + /** + * Process the change of authority field value updating the authority key and confidence as necessary + */ + onChangeAuthorityField(event): void { + if (event) { + this.mdValue.newValue.value = event.value; + if (event.authority) { + this.mdValue.newValue.authority = event.authority; + this.mdValue.newValue.confidence = ConfidenceType.CF_ACCEPTED; + } else { + this.mdValue.newValue.authority = null; + this.mdValue.newValue.confidence = ConfidenceType.CF_UNSET; + } + this.confirm.emit(false); + } else { + // The event is undefined when the user clears the selection in scrollable dropdown + this.mdValue.newValue.value = ''; + this.mdValue.newValue.authority = null; + this.mdValue.newValue.confidence = ConfidenceType.CF_UNSET; + this.confirm.emit(false); + } + } + + /** + * Returns the {@link DynamicOneboxModel} or {@link DynamicScrollableDropdownModel} model used + * for the authority field + */ + getModel(): DynamicOneboxModel | DynamicScrollableDropdownModel { + return this.model$.value; + } + + /** + * Change the status of the editingAuthority property + * @param status + */ + onChangeEditingAuthorityStatus(status: boolean) { + this.editingAuthority = status; + } + + /** + * Processes the change in authority value, updating the confidence as necessary. + * If the authority key is cleared, the confidence is set to {@link ConfidenceType.CF_NOVALUE}. + * If the authority key is edited and differs from the original, the confidence is set to {@link ConfidenceType.CF_ACCEPTED}. + */ + onChangeAuthorityKey() { + if (this.mdValue.newValue.authority === '') { + this.mdValue.newValue.confidence = ConfidenceType.CF_NOVALUE; + this.confirm.emit(false); + } else if (this.mdValue.newValue.authority !== this.mdValue.originalValue.authority) { + this.mdValue.newValue.confidence = ConfidenceType.CF_ACCEPTED; + this.confirm.emit(false); + } + } + + /** + * Toggles the free-text editing mode + */ + toggleFreeTextEdition() { + if (this.enabledFreeTextEditing) { + if (this.getModel().value !== this.mdValue.newValue.value) { + // Reload the model to adapt it to the new possible value modified during free text editing + this.initAuthorityProperties(); + } + } + this.enabledFreeTextEditing = !this.enabledFreeTextEditing; + } + +} diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-entity-field/dso-edit-metadata-entity-field.component.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-entity-field/dso-edit-metadata-entity-field.component.html new file mode 100644 index 0000000000..6ddde549ea --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-entity-field/dso-edit-metadata-entity-field.component.html @@ -0,0 +1,8 @@ + diff --git a/src/app/notifications/suggestion-targets/publication-claim/publication-claim.component.scss b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-entity-field/dso-edit-metadata-entity-field.component.scss similarity index 100% rename from src/app/notifications/suggestion-targets/publication-claim/publication-claim.component.scss rename to src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-entity-field/dso-edit-metadata-entity-field.component.scss diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-entity-field/dso-edit-metadata-entity-field.component.spec.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-entity-field/dso-edit-metadata-entity-field.component.spec.ts new file mode 100644 index 0000000000..486f4e825e --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-entity-field/dso-edit-metadata-entity-field.component.spec.ts @@ -0,0 +1,38 @@ +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; + +import { EntityTypeDataService } from '../../../../core/data/entity-type-data.service'; +import { EntityTypeDataServiceStub } from '../../../../shared/testing/entity-type-data.service.stub'; +import { DsoEditMetadataEntityFieldComponent } from './dso-edit-metadata-entity-field.component'; + +describe('DsoEditMetadataEntityFieldComponent', () => { + let component: DsoEditMetadataEntityFieldComponent; + let fixture: ComponentFixture; + + let entityTypeService: EntityTypeDataServiceStub; + + beforeEach(async () => { + entityTypeService = new EntityTypeDataServiceStub(); + + await TestBed.configureTestingModule({ + imports: [ + DsoEditMetadataEntityFieldComponent, + TranslateModule.forRoot(), + ], + providers: [ + { provide: EntityTypeDataService, useValue: entityTypeService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(DsoEditMetadataEntityFieldComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-entity-field/dso-edit-metadata-entity-field.component.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-entity-field/dso-edit-metadata-entity-field.component.ts new file mode 100644 index 0000000000..e4e2087cf1 --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-entity-field/dso-edit-metadata-entity-field.component.ts @@ -0,0 +1,48 @@ +import { AsyncPipe } from '@angular/common'; +import { + Component, + OnInit, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { TranslateModule } from '@ngx-translate/core'; +import { Observable } from 'rxjs'; + +import { EntityTypeDataService } from '../../../../core/data/entity-type-data.service'; +import { ItemType } from '../../../../core/shared/item-relationships/item-type.model'; +import { getFirstSucceededRemoteListPayload } from '../../../../core/shared/operators'; +import { AbstractDsoEditMetadataValueFieldComponent } from '../abstract-dso-edit-metadata-value-field.component'; + +/** + * The component used to gather input for entity-type metadata fields + */ +@Component({ + selector: 'ds-dso-edit-metadata-entity-field', + templateUrl: './dso-edit-metadata-entity-field.component.html', + styleUrls: ['./dso-edit-metadata-entity-field.component.scss'], + standalone: true, + imports: [ + AsyncPipe, + FormsModule, + TranslateModule, + ], +}) +export class DsoEditMetadataEntityFieldComponent extends AbstractDsoEditMetadataValueFieldComponent implements OnInit { + + /** + * List of all the existing entity types + */ + entities$: Observable; + + constructor( + protected entityTypeService: EntityTypeDataService, + ) { + super(); + } + + ngOnInit(): void { + this.entities$ = this.entityTypeService.findAll({ elementsPerPage: 100, currentPage: 1 }).pipe( + getFirstSucceededRemoteListPayload(), + ); + } + +} diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-field-type.enum.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-field-type.enum.ts new file mode 100644 index 0000000000..cc9c105c4f --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-field-type.enum.ts @@ -0,0 +1,8 @@ +/** + * The edit metadata field tab types + */ +export enum EditMetadataValueFieldType { + PLAIN_TEXT = 'PLAIN_TEXT', + ENTITY_TYPE = 'ENTITY_TYPE', + AUTHORITY = 'AUTHORITY', +} diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-field.service.spec.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-field.service.spec.ts new file mode 100644 index 0000000000..daea727838 --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-field.service.spec.ts @@ -0,0 +1,31 @@ +import { TestBed } from '@angular/core/testing'; + +import { ItemDataService } from '../../../core/data/item-data.service'; +import { VocabularyService } from '../../../core/submission/vocabularies/vocabulary.service'; +import { ItemDataServiceStub } from '../../../shared/testing/item-data.service.stub'; +import { VocabularyServiceStub } from '../../../shared/testing/vocabulary-service.stub'; +import { DsoEditMetadataFieldService } from './dso-edit-metadata-field.service'; + +describe('DsoEditMetadataFieldService', () => { + let service: DsoEditMetadataFieldService; + + let itemService: ItemDataServiceStub; + let vocabularyService: VocabularyServiceStub; + + beforeEach(() => { + itemService = new ItemDataServiceStub(); + vocabularyService = new VocabularyServiceStub(); + + TestBed.configureTestingModule({ + providers: [ + { provide: ItemDataService, useValue: itemService }, + { provide: VocabularyService, useValue: vocabularyService }, + ], + }); + service = TestBed.inject(DsoEditMetadataFieldService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-field.service.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-field.service.ts new file mode 100644 index 0000000000..d235b4c08e --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-field.service.ts @@ -0,0 +1,56 @@ +import { Injectable } from '@angular/core'; +import { + Observable, + of as observableOf, +} from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { ItemDataService } from '../../../core/data/item-data.service'; +import { Collection } from '../../../core/shared/collection.model'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { Item } from '../../../core/shared/item.model'; +import { getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators'; +import { Vocabulary } from '../../../core/submission/vocabularies/models/vocabulary.model'; +import { VocabularyService } from '../../../core/submission/vocabularies/vocabulary.service'; +import { isNotEmpty } from '../../../shared/empty.util'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; + +/** + * A service containing all the common logic for the components generated by the + * {@link DsoEditMetadataValueFieldLoaderComponent}. + */ +@Injectable({ + providedIn: 'root', +}) +export class DsoEditMetadataFieldService { + + constructor( + protected itemService: ItemDataService, + protected vocabularyService: VocabularyService, + ) { + } + + /** + * Find the vocabulary of the given {@link mdField} for the given item. + * + * @param dso The item + * @param mdField The metadata field + */ + findDsoFieldVocabulary(dso: DSpaceObject, mdField: string): Observable { + if (isNotEmpty(mdField)) { + const owningCollection$: Observable = this.itemService.findByHref(dso._links.self.href, true, true, followLink('owningCollection')).pipe( + getFirstSucceededRemoteDataPayload(), + switchMap((item: Item) => item.owningCollection), + getFirstSucceededRemoteDataPayload(), + ); + + return owningCollection$.pipe( + switchMap((c: Collection) => this.vocabularyService.getVocabularyByMetadataAndCollection(mdField, c.uuid).pipe( + getFirstSucceededRemoteDataPayload(), + )), + ); + } else { + return observableOf(undefined); + } + } +} diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-text-field/dso-edit-metadata-text-field.component.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-text-field/dso-edit-metadata-text-field.component.html new file mode 100644 index 0000000000..a2c754044c --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-text-field/dso-edit-metadata-text-field.component.html @@ -0,0 +1,7 @@ + diff --git a/src/app/notifications/suggestions-notification/suggestions-notification.component.scss b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-text-field/dso-edit-metadata-text-field.component.scss similarity index 100% rename from src/app/notifications/suggestions-notification/suggestions-notification.component.scss rename to src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-text-field/dso-edit-metadata-text-field.component.scss diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-text-field/dso-edit-metadata-text-field.component.spec.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-text-field/dso-edit-metadata-text-field.component.spec.ts new file mode 100644 index 0000000000..59d9b77e45 --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-text-field/dso-edit-metadata-text-field.component.spec.ts @@ -0,0 +1,29 @@ +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; + +import { DsoEditMetadataTextFieldComponent } from './dso-edit-metadata-text-field.component'; + +describe('DsoEditMetadataTextFieldComponent', () => { + let component: DsoEditMetadataTextFieldComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + DsoEditMetadataTextFieldComponent, + TranslateModule.forRoot(), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(DsoEditMetadataTextFieldComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-text-field/dso-edit-metadata-text-field.component.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-text-field/dso-edit-metadata-text-field.component.ts new file mode 100644 index 0000000000..8524e494b5 --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-text-field/dso-edit-metadata-text-field.component.ts @@ -0,0 +1,23 @@ +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { TranslateModule } from '@ngx-translate/core'; + +import { DebounceDirective } from '../../../../shared/utils/debounce.directive'; +import { AbstractDsoEditMetadataValueFieldComponent } from '../abstract-dso-edit-metadata-value-field.component'; + +/** + * The component used to gather input for plain-text metadata fields + */ +@Component({ + selector: 'ds-dso-edit-metadata-text-field', + templateUrl: './dso-edit-metadata-text-field.component.html', + styleUrls: ['./dso-edit-metadata-text-field.component.scss'], + standalone: true, + imports: [ + DebounceDirective, + FormsModule, + TranslateModule, + ], +}) +export class DsoEditMetadataTextFieldComponent extends AbstractDsoEditMetadataValueFieldComponent { +} diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-value-field-loader/dso-edit-metadata-value-field-loader.component.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-value-field-loader/dso-edit-metadata-value-field-loader.component.ts new file mode 100644 index 0000000000..de44895556 --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-value-field-loader/dso-edit-metadata-value-field-loader.component.ts @@ -0,0 +1,89 @@ +import { + Component, + EventEmitter, + Input, + Output, +} from '@angular/core'; + +import { Context } from '../../../../core/shared/context.model'; +import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; +import { GenericConstructor } from '../../../../core/shared/generic-constructor'; +import { AbstractComponentLoaderComponent } from '../../../../shared/abstract-component-loader/abstract-component-loader.component'; +import { DynamicComponentLoaderDirective } from '../../../../shared/abstract-component-loader/dynamic-component-loader.directive'; +import { DsoEditMetadataValue } from '../../dso-edit-metadata-form'; +import { EditMetadataValueFieldType } from '../dso-edit-metadata-field-type.enum'; +import { getDsoEditMetadataValueFieldComponent } from './dso-edit-metadata-value-field.decorator'; + +/** + * A component responsible for dynamically loading and rendering the appropriate edit metadata value field components + * based on the type of the metadata field ({@link EditMetadataValueFieldType}) and the place where it's used + * ({@link Context}). + */ +@Component({ + selector: 'ds-dso-edit-metadata-value-field-loader', + templateUrl: '../../../../shared/abstract-component-loader/abstract-component-loader.component.html', + standalone: true, + imports: [ + DynamicComponentLoaderDirective, + ], +}) +export class DsoEditMetadataValueFieldLoaderComponent extends AbstractComponentLoaderComponent { + + /** + * The optional context + */ + @Input() context: Context; + + /** + * The {@link DSpaceObject} + */ + @Input() dso: DSpaceObject; + + /** + * The type of the DSO, used to determines i18n messages + */ + @Input() dsoType: string; + + /** + * The type of the field + */ + @Input() type: EditMetadataValueFieldType; + + /** + * The metadata field + */ + @Input() mdField: string; + + /** + * Editable metadata value to show + */ + @Input() mdValue: DsoEditMetadataValue; + + /** + * Emits when the user clicked confirm + */ + @Output() confirm: EventEmitter = new EventEmitter(); + + protected inputNamesDependentForComponent: (keyof this & string)[] = [ + 'context', + 'type', + ]; + + protected inputNames: (keyof this & string)[] = [ + 'context', + 'dso', + 'dsoType', + 'type', + 'mdField', + 'mdValue', + ]; + + protected outputNames: (keyof this & string)[] = [ + 'confirm', + ]; + + public getComponent(): GenericConstructor { + return getDsoEditMetadataValueFieldComponent(this.type, this.context, this.themeService.getThemeName()); + } + +} diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-value-field-loader/dso-edit-metadata-value-field.decorator.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-value-field-loader/dso-edit-metadata-value-field.decorator.ts new file mode 100644 index 0000000000..34decfceaa --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-value-field-loader/dso-edit-metadata-value-field.decorator.ts @@ -0,0 +1,59 @@ +import { Context } from '../../../../core/shared/context.model'; +import { hasValue } from '../../../../shared/empty.util'; +import { + DEFAULT_CONTEXT, + DEFAULT_THEME, + resolveTheme, +} from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; +import { DsoEditMetadataAuthorityFieldComponent } from '../dso-edit-metadata-authority-field/dso-edit-metadata-authority-field.component'; +import { DsoEditMetadataEntityFieldComponent } from '../dso-edit-metadata-entity-field/dso-edit-metadata-entity-field.component'; +import { EditMetadataValueFieldType } from '../dso-edit-metadata-field-type.enum'; +import { DsoEditMetadataTextFieldComponent } from '../dso-edit-metadata-text-field/dso-edit-metadata-text-field.component'; + +export type MetadataValueFieldComponent = + typeof DsoEditMetadataTextFieldComponent | + typeof DsoEditMetadataEntityFieldComponent | + typeof DsoEditMetadataAuthorityFieldComponent; + +export const map = new Map>>([ + [EditMetadataValueFieldType.PLAIN_TEXT, new Map([ + [DEFAULT_CONTEXT, new Map([[DEFAULT_THEME, DsoEditMetadataTextFieldComponent]])], + ])], + [EditMetadataValueFieldType.ENTITY_TYPE, new Map([ + [DEFAULT_CONTEXT, new Map([[DEFAULT_THEME, DsoEditMetadataEntityFieldComponent]])], + ])], + [EditMetadataValueFieldType.AUTHORITY, new Map([ + [DEFAULT_CONTEXT, new Map([[DEFAULT_THEME, DsoEditMetadataAuthorityFieldComponent]])], + ])], +]); + +export const DEFAULT_EDIT_METADATA_FIELD_TYPE = EditMetadataValueFieldType.PLAIN_TEXT; + +/** + * Getter to retrieve a matching component by entity type, metadata representation and context + * + * @param type The edit metadata field type + * @param context The context to match + * @param theme the theme to match + */ +export function getDsoEditMetadataValueFieldComponent(type: EditMetadataValueFieldType, context: Context = DEFAULT_CONTEXT, theme = DEFAULT_THEME) { + if (type) { + const mapForEntity = map.get(type); + if (hasValue(mapForEntity)) { + const contextMap = mapForEntity.get(context); + if (hasValue(contextMap)) { + const match = resolveTheme(contextMap, theme); + if (hasValue(match)) { + return match; + } + if (hasValue(contextMap.get(DEFAULT_THEME))) { + return contextMap.get(DEFAULT_THEME); + } + } + if (hasValue(mapForEntity.get(DEFAULT_CONTEXT)) && hasValue(mapForEntity.get(DEFAULT_CONTEXT).get(DEFAULT_THEME))) { + return mapForEntity.get(DEFAULT_CONTEXT).get(DEFAULT_THEME); + } + } + } + return map.get(DEFAULT_EDIT_METADATA_FIELD_TYPE).get(DEFAULT_CONTEXT).get(DEFAULT_THEME); +} diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html index fb765583b5..031e407e45 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html @@ -1,135 +1,94 @@ -
-
+@let isVirtual = metadataService.isVirtual(mdValue.newValue); +
+ @let mdRepresentation = (mdRepresentation$ | async); +
@if (!mdValue.editing && !mdRepresentation) {
{{ mdValue.newValue.value }}
} - @if (mdValue.editing && !mdRepresentation && ((isAuthorityControlled() | async) !== true || (enabledFreeTextEditing && (isSuggesterVocabulary() | async) !== true))) { - - } - @if (mdValue.editing && (isScrollableVocabulary() | async) && !enabledFreeTextEditing) { - - - } - @if (mdValue.editing && (((isHierarchicalVocabulary() | async) && !enabledFreeTextEditing) || (isSuggesterVocabulary() | async))) { - - - } - @if (mdValue.editing && ((isScrollableVocabulary() | async) || (isHierarchicalVocabulary() | async))) { - + @if (mdValue.editing && !mdRepresentation) { + + } @if (!isVirtual && !mdValue.editing && mdValue.newValue.authority && mdValue.newValue.confidence !== ConfidenceTypeEnum.CF_UNSET && mdValue.newValue.confidence !== ConfidenceTypeEnum.CF_NOVALUE) {
- + {{ dsoType + '.edit.metadata.authority.label' | translate }} {{ mdValue.newValue.authority }}
} - @if ( mdValue.editing && (isAuthorityControlled() | async) && (isSuggesterVocabulary() | async)) { -
-
- - - @if (!editingAuthority) { - - } - @if (editingAuthority) { - - } -
-
- } @if (mdRepresentation) { }
- @if (!mdValue.editing) { -
{{ mdValue.newValue.language }}
- } @if (mdValue.editing) { + [attr.aria-label]="(dsoType + '.edit.metadata.edit.language') | translate" + [dsDebounce]="300" (onDebounce)="confirm.emit(false)"/> + } @else { +
{{ mdValue.newValue.language }}
}
-
- @if (!mdValue.editing) { +
+ @let saving = (saving$ | async); + @if (mdValue.editing) { + + } @else { } - @if (mdValue.editing) { - - } -
diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.spec.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.spec.ts index 196a13ec4a..8ef3753554 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.spec.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.spec.ts @@ -8,42 +8,27 @@ import { waitForAsync, } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { RouterTestingModule } from '@angular/router/testing'; +import { RouterModule } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; import { of } from 'rxjs'; -import { MetadataField } from 'src/app/core/metadata/metadata-field.model'; -import { MetadataSchema } from 'src/app/core/metadata/metadata-schema.model'; -import { RegistryService } from 'src/app/core/registry/registry.service'; -import { ConfidenceType } from 'src/app/core/shared/confidence-type'; -import { Vocabulary } from 'src/app/core/submission/vocabularies/models/vocabulary.model'; -import { VocabularyService } from 'src/app/core/submission/vocabularies/vocabulary.service'; -import { DynamicOneboxModel } from 'src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.model'; -import { DynamicScrollableDropdownModel } from 'src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.model'; -import { NotificationsService } from 'src/app/shared/notifications/notifications.service'; -import { createPaginatedList } from 'src/app/shared/testing/utils.test'; -import { VocabularyServiceStub } from 'src/app/shared/testing/vocabulary-service.stub'; import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; -import { ItemDataService } from '../../../core/data/item-data.service'; import { RelationshipDataService } from '../../../core/data/relationship-data.service'; -import { Collection } from '../../../core/shared/collection.model'; -import { DSpaceObject } from '../../../core/shared/dspace-object.model'; -import { Item } from '../../../core/shared/item.model'; import { MetadataValue, VIRTUAL_METADATA_PREFIX, } from '../../../core/shared/metadata.models'; import { ItemMetadataRepresentation } from '../../../core/shared/metadata-representation/item/item-metadata-representation.model'; import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; -import { DsDynamicOneboxComponent } from '../../../shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component'; -import { DsDynamicScrollableDropdownComponent } from '../../../shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component'; import { ThemedTypeBadgeComponent } from '../../../shared/object-collection/shared/badges/type-badge/themed-type-badge.component'; -import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { DsoEditMetadataFieldServiceStub } from '../../../shared/testing/dso-edit-metadata-field.service.stub'; import { VarDirective } from '../../../shared/utils/var.directive'; import { DsoEditMetadataChangeType, DsoEditMetadataValue, } from '../dso-edit-metadata-form'; +import { DsoEditMetadataFieldService } from '../dso-edit-metadata-value-field/dso-edit-metadata-field.service'; +import { DsoEditMetadataValueFieldLoaderComponent } from '../dso-edit-metadata-value-field/dso-edit-metadata-value-field-loader/dso-edit-metadata-value-field-loader.component'; import { DsoEditMetadataValueComponent } from './dso-edit-metadata-value.component'; const EDIT_BTN = 'edit'; @@ -58,97 +43,12 @@ describe('DsoEditMetadataValueComponent', () => { let relationshipService: RelationshipDataService; let dsoNameService: DSONameService; - let vocabularyServiceStub: any; - let itemService: ItemDataService; - let registryService: RegistryService; - let notificationsService: NotificationsService; + let dsoEditMetadataFieldService: DsoEditMetadataFieldServiceStub; let editMetadataValue: DsoEditMetadataValue; let metadataValue: MetadataValue; - let dso: DSpaceObject; - - const collection = Object.assign(new Collection(), { - uuid: 'fake-uuid', - }); - - const item = Object.assign(new Item(), { - _links: { - self: { href: 'fake-item-url/item' }, - }, - id: 'item', - uuid: 'item', - owningCollection: createSuccessfulRemoteDataObject$(collection), - }); - - const mockVocabularyScrollable: Vocabulary = { - id: 'scrollable', - name: 'scrollable', - scrollable: true, - hierarchical: false, - preloadLevel: 0, - type: 'vocabulary', - _links: { - self: { - href: 'self', - }, - entries: { - href: 'entries', - }, - }, - }; - - const mockVocabularyHierarchical: Vocabulary = { - id: 'hierarchical', - name: 'hierarchical', - scrollable: false, - hierarchical: true, - preloadLevel: 2, - type: 'vocabulary', - _links: { - self: { - href: 'self', - }, - entries: { - href: 'entries', - }, - }, - }; - - const mockVocabularySuggester: Vocabulary = { - id: 'suggester', - name: 'suggester', - scrollable: false, - hierarchical: false, - preloadLevel: 0, - type: 'vocabulary', - _links: { - self: { - href: 'self', - }, - entries: { - href: 'entries', - }, - }, - }; - - let metadataSchema: MetadataSchema; - let metadataFields: MetadataField[]; function initServices(): void { - metadataSchema = Object.assign(new MetadataSchema(), { - id: 0, - prefix: 'metadata', - namespace: 'http://example.com/', - }); - metadataFields = [ - Object.assign(new MetadataField(), { - id: 0, - element: 'regular', - qualifier: null, - schema: createSuccessfulRemoteDataObject$(metadataSchema), - }), - ]; - relationshipService = jasmine.createSpyObj('relationshipService', { resolveMetadataRepresentation: of( new ItemMetadataRepresentation(metadataValue), @@ -157,14 +57,7 @@ describe('DsoEditMetadataValueComponent', () => { dsoNameService = jasmine.createSpyObj('dsoNameService', { getName: 'Related Name', }); - itemService = jasmine.createSpyObj('itemService', { - findByHref: createSuccessfulRemoteDataObject$(item), - }); - vocabularyServiceStub = new VocabularyServiceStub(); - registryService = jasmine.createSpyObj('registryService', { - queryMetadataFields: createSuccessfulRemoteDataObject$(createPaginatedList(metadataFields)), - }); - notificationsService = jasmine.createSpyObj('notificationsService', ['error', 'success']); + dsoEditMetadataFieldService = new DsoEditMetadataFieldServiceStub(); } beforeEach(waitForAsync(async () => { @@ -175,18 +68,13 @@ describe('DsoEditMetadataValueComponent', () => { authority: undefined, }); editMetadataValue = new DsoEditMetadataValue(metadataValue); - dso = Object.assign(new DSpaceObject(), { - _links: { - self: { href: 'fake-dso-url/dso' }, - }, - }); initServices(); await TestBed.configureTestingModule({ imports: [ TranslateModule.forRoot(), - RouterTestingModule.withRoutes([]), + RouterModule.forRoot([]), DsoEditMetadataValueComponent, VarDirective, BtnDisabledDirective, @@ -194,16 +82,16 @@ describe('DsoEditMetadataValueComponent', () => { providers: [ { provide: RelationshipDataService, useValue: relationshipService }, { provide: DSONameService, useValue: dsoNameService }, - { provide: VocabularyService, useValue: vocabularyServiceStub }, - { provide: ItemDataService, useValue: itemService }, - { provide: RegistryService, useValue: registryService }, - { provide: NotificationsService, useValue: notificationsService }, + { provide: DsoEditMetadataFieldService, useValue: dsoEditMetadataFieldService }, ], schemas: [NO_ERRORS_SCHEMA], }) .overrideComponent(DsoEditMetadataValueComponent, { remove: { - imports: [DsDynamicOneboxComponent, DsDynamicScrollableDropdownComponent, ThemedTypeBadgeComponent], + imports: [ + DsoEditMetadataValueFieldLoaderComponent, + ThemedTypeBadgeComponent, + ], }, }) .compileComponents(); @@ -213,7 +101,6 @@ describe('DsoEditMetadataValueComponent', () => { fixture = TestBed.createComponent(DsoEditMetadataValueComponent); component = fixture.componentInstance; component.mdValue = editMetadataValue; - component.dso = dso; component.saving$ = of(false); fixture.detectChanges(); }); @@ -299,219 +186,6 @@ describe('DsoEditMetadataValueComponent', () => { assertButton(DRAG_BTN, true, false); }); - describe('when the metadata field not uses a vocabulary and is editing', () => { - beforeEach(waitForAsync(() => { - spyOn(vocabularyServiceStub, 'getVocabularyByMetadataAndCollection').and.returnValue(createSuccessfulRemoteDataObject$(null, 204)); - metadataValue = Object.assign(new MetadataValue(), { - value: 'Regular value', - language: 'en', - place: 0, - authority: null, - }); - editMetadataValue = new DsoEditMetadataValue(metadataValue); - editMetadataValue.editing = true; - component.mdValue = editMetadataValue; - component.mdField = 'metadata.regular'; - component.ngOnInit(); - fixture.detectChanges(); - })); - - it('should render a textarea', () => { - expect(vocabularyServiceStub.getVocabularyByMetadataAndCollection).toHaveBeenCalled(); - expect(fixture.debugElement.query(By.css('textarea'))).toBeTruthy(); - }); - }); - - describe('when the metadata field uses a scrollable vocabulary and is editing', () => { - beforeEach(waitForAsync(() => { - spyOn(vocabularyServiceStub, 'getVocabularyByMetadataAndCollection').and.returnValue(createSuccessfulRemoteDataObject$(mockVocabularyScrollable)); - metadataValue = Object.assign(new MetadataValue(), { - value: 'Authority Controlled value', - language: 'en', - place: 0, - authority: null, - }); - editMetadataValue = new DsoEditMetadataValue(metadataValue); - editMetadataValue.editing = true; - component.mdValue = editMetadataValue; - component.mdField = 'metadata.scrollable'; - component.ngOnInit(); - fixture.detectChanges(); - })); - - it('should render the DsDynamicScrollableDropdownComponent', () => { - expect(vocabularyServiceStub.getVocabularyByMetadataAndCollection).toHaveBeenCalled(); - expect(fixture.debugElement.query(By.css('ds-dynamic-scrollable-dropdown'))).toBeTruthy(); - }); - - it('getModel should return a DynamicScrollableDropdownModel', () => { - const model = component.getModel(); - - expect(model instanceof DynamicScrollableDropdownModel).toBe(true); - expect(model.vocabularyOptions.name).toBe(mockVocabularyScrollable.name); - - }); - }); - - describe('when the metadata field uses a hierarchical vocabulary and is editing', () => { - beforeEach(waitForAsync(() => { - spyOn(vocabularyServiceStub, 'getVocabularyByMetadataAndCollection').and.returnValue(createSuccessfulRemoteDataObject$(mockVocabularyHierarchical)); - metadataValue = Object.assign(new MetadataValue(), { - value: 'Authority Controlled value', - language: 'en', - place: 0, - authority: null, - }); - editMetadataValue = new DsoEditMetadataValue(metadataValue); - editMetadataValue.editing = true; - component.mdValue = editMetadataValue; - component.mdField = 'metadata.hierarchical'; - component.ngOnInit(); - fixture.detectChanges(); - })); - - it('should render the DsDynamicOneboxComponent', () => { - expect(vocabularyServiceStub.getVocabularyByMetadataAndCollection).toHaveBeenCalled(); - expect(fixture.debugElement.query(By.css('ds-dynamic-onebox'))).toBeTruthy(); - }); - - it('getModel should return a DynamicOneboxModel', () => { - const model = component.getModel(); - - expect(model instanceof DynamicOneboxModel).toBe(true); - expect(model.vocabularyOptions.name).toBe(mockVocabularyHierarchical.name); - }); - }); - - describe('when the metadata field uses a suggester vocabulary and is editing', () => { - beforeEach(waitForAsync(() => { - spyOn(vocabularyServiceStub, 'getVocabularyByMetadataAndCollection').and.returnValue(createSuccessfulRemoteDataObject$(mockVocabularySuggester)); - spyOn(component.confirm, 'emit'); - metadataValue = Object.assign(new MetadataValue(), { - value: 'Authority Controlled value', - language: 'en', - place: 0, - authority: 'authority-key', - confidence: ConfidenceType.CF_UNCERTAIN, - }); - editMetadataValue = new DsoEditMetadataValue(metadataValue); - editMetadataValue.editing = true; - component.mdValue = editMetadataValue; - component.mdField = 'metadata.suggester'; - component.ngOnInit(); - fixture.detectChanges(); - })); - - it('should render the DsDynamicOneboxComponent', () => { - expect(vocabularyServiceStub.getVocabularyByMetadataAndCollection).toHaveBeenCalled(); - expect(fixture.debugElement.query(By.css('ds-dynamic-onebox'))).toBeTruthy(); - }); - - it('getModel should return a DynamicOneboxModel', () => { - const model = component.getModel(); - - expect(model instanceof DynamicOneboxModel).toBe(true); - expect(model.vocabularyOptions.name).toBe(mockVocabularySuggester.name); - }); - - describe('authority key edition', () => { - - it('should update confidence to CF_NOVALUE when authority is cleared', () => { - component.mdValue.newValue.authority = ''; - - component.onChangeAuthorityKey(); - - expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_NOVALUE); - expect(component.confirm.emit).toHaveBeenCalledWith(false); - }); - - it('should update confidence to CF_ACCEPTED when authority key is edited', () => { - component.mdValue.newValue.authority = 'newAuthority'; - component.mdValue.originalValue.authority = 'oldAuthority'; - - component.onChangeAuthorityKey(); - - expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_ACCEPTED); - expect(component.confirm.emit).toHaveBeenCalledWith(false); - }); - - it('should not update confidence when authority key remains the same', () => { - component.mdValue.newValue.authority = 'sameAuthority'; - component.mdValue.originalValue.authority = 'sameAuthority'; - - component.onChangeAuthorityKey(); - - expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_UNCERTAIN); - expect(component.confirm.emit).not.toHaveBeenCalled(); - }); - - it('should call onChangeEditingAuthorityStatus with true when clicking the lock button', () => { - spyOn(component, 'onChangeEditingAuthorityStatus'); - const lockButton = fixture.nativeElement.querySelector('#metadata-confirm-btn'); - - lockButton.click(); - - expect(component.onChangeEditingAuthorityStatus).toHaveBeenCalledWith(true); - }); - - it('should disable the input when editingAuthority is false', (done) => { - component.editingAuthority = false; - - fixture.detectChanges(); - - fixture.detectChanges(); - fixture.whenStable().then(() => { - const inputElement = fixture.nativeElement.querySelector('input[data-test="authority-input"]'); - expect(inputElement.disabled).toBeTruthy(); - done(); - }); - }); - - it('should enable the input when editingAuthority is true', (done) => { - component.editingAuthority = true; - - fixture.detectChanges(); - fixture.whenStable().then(() => { - const inputElement = fixture.nativeElement.querySelector('input[data-test="authority-input"]'); - expect(inputElement.disabled).toBeFalsy(); - done(); - }); - - - }); - - it('should update mdValue.newValue properties when authority is present', () => { - const event = { - value: 'Some value', - authority: 'Some authority', - }; - - component.onChangeAuthorityField(event); - - expect(component.mdValue.newValue.value).toBe(event.value); - expect(component.mdValue.newValue.authority).toBe(event.authority); - expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_ACCEPTED); - expect(component.confirm.emit).toHaveBeenCalledWith(false); - }); - - it('should update mdValue.newValue properties when authority is not present', () => { - const event = { - value: 'Some value', - authority: null, - }; - - component.onChangeAuthorityField(event); - - expect(component.mdValue.newValue.value).toBe(event.value); - expect(component.mdValue.newValue.authority).toBeNull(); - expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_UNSET); - expect(component.confirm.emit).toHaveBeenCalledWith(false); - }); - - }); - - }); - function assertButton(name: string, exists: boolean, disabled: boolean = false): void { describe(`${name} button`, () => { let btn: DebugElement; diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts index e718080eb6..5a0e6476c1 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts @@ -7,7 +7,6 @@ import { NgClass, } from '@angular/common'; import { - ChangeDetectorRef, Component, EventEmitter, Input, @@ -16,96 +15,67 @@ import { Output, SimpleChanges, } from '@angular/core'; -import { - FormsModule, - UntypedFormControl, - UntypedFormGroup, -} from '@angular/forms'; +import { FormsModule } from '@angular/forms'; import { RouterLink } from '@angular/router'; import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; import { - TranslateModule, - TranslateService, -} from '@ngx-translate/core'; -import { - BehaviorSubject, EMPTY, Observable, - of as observableOf, } from 'rxjs'; -import { - map, - switchMap, - take, - tap, -} from 'rxjs/operators'; -import { RegistryService } from 'src/app/core/registry/registry.service'; -import { VocabularyService } from 'src/app/core/submission/vocabularies/vocabulary.service'; -import { NotificationsService } from 'src/app/shared/notifications/notifications.service'; +import { map } from 'rxjs/operators'; import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; -import { ItemDataService } from '../../../core/data/item-data.service'; import { RelationshipDataService } from '../../../core/data/relationship-data.service'; import { MetadataService } from '../../../core/metadata/metadata.service'; -import { Collection } from '../../../core/shared/collection.model'; import { ConfidenceType } from '../../../core/shared/confidence-type'; +import { Context } from '../../../core/shared/context.model'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; -import { Item } from '../../../core/shared/item.model'; import { ItemMetadataRepresentation } from '../../../core/shared/metadata-representation/item/item-metadata-representation.model'; import { MetadataRepresentation, MetadataRepresentationType, } from '../../../core/shared/metadata-representation/metadata-representation.model'; -import { - getFirstCompletedRemoteData, - getFirstSucceededRemoteData, - getFirstSucceededRemoteDataPayload, - getRemoteDataPayload, - metadataFieldsToString, -} from '../../../core/shared/operators'; import { Vocabulary } from '../../../core/submission/vocabularies/models/vocabulary.model'; -import { VocabularyOptions } from '../../../core/submission/vocabularies/models/vocabulary-options.model'; import { getItemPageRoute } from '../../../item-page/item-page-routing-paths'; import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; -import { isNotEmpty } from '../../../shared/empty.util'; -import { DsDynamicOneboxComponent } from '../../../shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component'; -import { - DsDynamicOneboxModelConfig, - DynamicOneboxModel, -} from '../../../shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.model'; -import { DsDynamicScrollableDropdownComponent } from '../../../shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component'; -import { - DynamicScrollableDropdownModel, - DynamicScrollableDropdownModelConfig, -} from '../../../shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.model'; -import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model'; +import { hasValue } from '../../../shared/empty.util'; import { AuthorityConfidenceStateDirective } from '../../../shared/form/directives/authority-confidence-state.directive'; import { ThemedTypeBadgeComponent } from '../../../shared/object-collection/shared/badges/type-badge/themed-type-badge.component'; import { DebounceDirective } from '../../../shared/utils/debounce.directive'; -import { followLink } from '../../../shared/utils/follow-link-config.model'; -import { VarDirective } from '../../../shared/utils/var.directive'; import { DsoEditMetadataChangeType, DsoEditMetadataValue, } from '../dso-edit-metadata-form'; +import { DsoEditMetadataFieldService } from '../dso-edit-metadata-value-field/dso-edit-metadata-field.service'; +import { EditMetadataValueFieldType } from '../dso-edit-metadata-value-field/dso-edit-metadata-field-type.enum'; +import { DsoEditMetadataValueFieldLoaderComponent } from '../dso-edit-metadata-value-field/dso-edit-metadata-value-field-loader/dso-edit-metadata-value-field-loader.component'; @Component({ selector: 'ds-dso-edit-metadata-value', styleUrls: ['./dso-edit-metadata-value.component.scss', '../dso-edit-metadata-shared/dso-edit-metadata-cells.scss'], templateUrl: './dso-edit-metadata-value.component.html', standalone: true, - imports: [VarDirective, CdkDrag, NgClass, FormsModule, DebounceDirective, RouterLink, ThemedTypeBadgeComponent, NgbTooltipModule, CdkDragHandle, AsyncPipe, TranslateModule, DsDynamicScrollableDropdownComponent, DsDynamicOneboxComponent, AuthorityConfidenceStateDirective, BtnDisabledDirective], + imports: [CdkDrag, NgClass, FormsModule, DebounceDirective, RouterLink, ThemedTypeBadgeComponent, NgbTooltipModule, CdkDragHandle, AsyncPipe, TranslateModule, AuthorityConfidenceStateDirective, BtnDisabledDirective, DsoEditMetadataValueFieldLoaderComponent], }) /** * Component displaying a single editable row for a metadata value */ export class DsoEditMetadataValueComponent implements OnInit, OnChanges { + + @Input() context: Context; + /** * The parent {@link DSpaceObject} to display a metadata form for * Also used to determine metadata-representations in case of virtual metadata */ @Input() dso: DSpaceObject; + /** + * The metadata field that is being edited + */ + @Input() mdField: string; + /** * Editable metadata value to show */ @@ -129,11 +99,6 @@ export class DsoEditMetadataValueComponent implements OnInit, OnChanges { */ @Input() isOnlyValue = false; - /** - * MetadataField to edit - */ - @Input() mdField?: string; - /** * Emits when the user clicked edit */ @@ -165,12 +130,6 @@ export class DsoEditMetadataValueComponent implements OnInit, OnChanges { */ public DsoEditMetadataChangeTypeEnum = DsoEditMetadataChangeType; - /** - * The ConfidenceType enumeration for access in the component's template - * @type {ConfidenceType} - */ - public ConfidenceTypeEnum = ConfidenceType; - /** * The item this metadata value represents in case it's virtual (if any, otherwise null) */ @@ -187,56 +146,28 @@ export class DsoEditMetadataValueComponent implements OnInit, OnChanges { mdRepresentationName$: Observable; /** - * Whether or not the authority field is currently being edited + * The type of edit field that should be displayed */ - public editingAuthority = false; + fieldType$: Observable; - - /** - * Whether or not the free-text editing is enabled when scrollable dropdown or hierarchical vocabulary is used - */ - public enabledFreeTextEditing = false; - - /** - * Field group used by authority field - * @type {UntypedFormGroup} - */ - group = new UntypedFormGroup({ authorityField : new UntypedFormControl() }); - - /** - * Model to use for editing authorities values - */ - private model$: BehaviorSubject = new BehaviorSubject(null); - - /** - * Observable with information about the authority vocabulary used - */ - private vocabulary$: Observable; - - /** - * Observables with information about the authority vocabulary type used - */ - private isAuthorityControlled$: Observable; - private isHierarchicalVocabulary$: Observable; - private isScrollableVocabulary$: Observable; - private isSuggesterVocabulary$: Observable; + readonly ConfidenceTypeEnum = ConfidenceType; constructor( protected relationshipService: RelationshipDataService, protected dsoNameService: DSONameService, - protected vocabularyService: VocabularyService, - protected itemService: ItemDataService, - protected cdr: ChangeDetectorRef, - protected registryService: RegistryService, - protected notificationsService: NotificationsService, - protected translate: TranslateService, protected metadataService: MetadataService, + protected dsoEditMetadataFieldService: DsoEditMetadataFieldService, ) { } ngOnInit(): void { this.initVirtualProperties(); - this.initAuthorityProperties(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes.mdField) { + this.fieldType$ = this.getFieldType(); + } } /** @@ -259,252 +190,20 @@ export class DsoEditMetadataValueComponent implements OnInit, OnChanges { } /** - * Initialise potential properties of a authority controlled metadata field + * Retrieves the {@link EditMetadataValueFieldType} to be displayed for the current field while in edit mode. */ - initAuthorityProperties(): void { - - if (isNotEmpty(this.mdField)) { - - const owningCollection$: Observable = this.itemService.findByHref(this.dso._links.self.href, true, true, followLink('owningCollection')) - .pipe( - getFirstSucceededRemoteData(), - getRemoteDataPayload(), - switchMap((item: Item) => item.owningCollection), - getFirstSucceededRemoteData(), - getRemoteDataPayload(), - ); - - this.vocabulary$ = owningCollection$.pipe( - switchMap((c: Collection) => this.vocabularyService - .getVocabularyByMetadataAndCollection(this.mdField, c.uuid) - .pipe( - getFirstSucceededRemoteDataPayload(), - )), - ); - } else { - this.vocabulary$ = observableOf(undefined); - } - - this.isAuthorityControlled$ = this.vocabulary$.pipe( - // Create the model used by the authority fields to ensure its existence when the field is initialized - tap((v: Vocabulary) => this.model$.next(this.createModel(v))), - map((result: Vocabulary) => isNotEmpty(result)), - ); - - this.isHierarchicalVocabulary$ = this.vocabulary$.pipe( - map((result: Vocabulary) => isNotEmpty(result) && result.hierarchical), - ); - - this.isScrollableVocabulary$ = this.vocabulary$.pipe( - map((result: Vocabulary) => isNotEmpty(result) && result.scrollable), - ); - - this.isSuggesterVocabulary$ = this.vocabulary$.pipe( - map((result: Vocabulary) => isNotEmpty(result) && !result.hierarchical && !result.scrollable), - ); - - } - - /** - * Returns a {@link DynamicOneboxModel} or {@link DynamicScrollableDropdownModel} model based on the - * vocabulary used. - */ - private createModel(vocabulary: Vocabulary): DynamicOneboxModel | DynamicScrollableDropdownModel { - if (isNotEmpty(vocabulary)) { - let formFieldValue; - if (isNotEmpty(this.mdValue.newValue.value)) { - formFieldValue = new FormFieldMetadataValueObject(); - formFieldValue.value = this.mdValue.newValue.value; - formFieldValue.display = this.mdValue.newValue.value; - if (this.mdValue.newValue.authority) { - formFieldValue.authority = this.mdValue.newValue.authority; - formFieldValue.confidence = this.mdValue.newValue.confidence; + getFieldType(): Observable { + return this.dsoEditMetadataFieldService.findDsoFieldVocabulary(this.dso, this.mdField).pipe( + map((vocabulary: Vocabulary) => { + if (hasValue(vocabulary)) { + return EditMetadataValueFieldType.AUTHORITY; } - } else { - formFieldValue = this.mdValue.newValue.value; - } - - const vocabularyOptions = vocabulary ? { - closed: false, - name: vocabulary.name, - } as VocabularyOptions : null; - - if (!vocabulary.scrollable) { - const model: DsDynamicOneboxModelConfig = { - id: 'authorityField', - label: `${this.dsoType}.edit.metadata.edit.value`, - vocabularyOptions: vocabularyOptions, - metadataFields: [this.mdField], - value: formFieldValue, - repeatable: false, - submissionId: 'edit-metadata', - hasSelectableMetadata: false, - }; - return new DynamicOneboxModel(model); - } else { - const model: DynamicScrollableDropdownModelConfig = { - id: 'authorityField', - label: `${this.dsoType}.edit.metadata.edit.value`, - placeholder: `${this.dsoType}.edit.metadata.edit.value`, - vocabularyOptions: vocabularyOptions, - metadataFields: [this.mdField], - value: formFieldValue, - repeatable: false, - submissionId: 'edit-metadata', - hasSelectableMetadata: false, - maxOptions: 10, - }; - return new DynamicScrollableDropdownModel(model); - } - } else { - return null; - } - } - - /** - * Change callback for the component. Check if the mdField has changed to retrieve whether it is metadata - * that uses a controlled vocabulary and update the related properties - * - * @param {SimpleChanges} changes - */ - ngOnChanges(changes: SimpleChanges): void { - if (isNotEmpty(changes.mdField) && !changes.mdField.firstChange) { - if (isNotEmpty(changes.mdField.currentValue) ) { - if (isNotEmpty(changes.mdField.previousValue) && - changes.mdField.previousValue !== changes.mdField.currentValue) { - // Clear authority value in case it has been assigned with the previous metadataField used - this.mdValue.newValue.authority = null; - this.mdValue.newValue.confidence = ConfidenceType.CF_UNSET; - } - - // Only ask if the current mdField have a period character to reduce request - if (changes.mdField.currentValue.includes('.')) { - this.validateMetadataField().subscribe((isValid: boolean) => { - if (isValid) { - this.initAuthorityProperties(); - this.cdr.detectChanges(); - } - }); - } - } - } - } - - /** - * Validate the metadata field to check if it exists on the server and return an observable boolean for success/error - */ - validateMetadataField(): Observable { - return this.registryService.queryMetadataFields(this.mdField, null, true, false, followLink('schema')).pipe( - getFirstCompletedRemoteData(), - switchMap((rd) => { - if (rd.hasSucceeded) { - return observableOf(rd).pipe( - metadataFieldsToString(), - take(1), - map((fields: string[]) => fields.indexOf(this.mdField) > -1), - ); - } else { - this.notificationsService.error(this.translate.instant(`${this.dsoType}.edit.metadata.metadatafield.error`), rd.errorMessage); - return [false]; + if (this.mdField === 'dspace.entity.type') { + return EditMetadataValueFieldType.ENTITY_TYPE; } + return EditMetadataValueFieldType.PLAIN_TEXT; }), ); } - /** - * Checks if this field use a authority vocabulary - */ - isAuthorityControlled(): Observable { - return this.isAuthorityControlled$; - } - - /** - * Checks if configured vocabulary is Hierarchical or not - */ - isHierarchicalVocabulary(): Observable { - return this.isHierarchicalVocabulary$; - } - - /** - * Checks if configured vocabulary is Scrollable or not - */ - isScrollableVocabulary(): Observable { - return this.isScrollableVocabulary$; - } - - /** - * Checks if configured vocabulary is Suggester or not - * (a vocabulary not Scrollable and not Hierarchical that uses an autocomplete field) - */ - isSuggesterVocabulary(): Observable { - return this.isSuggesterVocabulary$; - } - - /** - * Process the change of authority field value updating the authority key and confidence as necessary - */ - onChangeAuthorityField(event): void { - if (event) { - this.mdValue.newValue.value = event.value; - if (event.authority) { - this.mdValue.newValue.authority = event.authority; - this.mdValue.newValue.confidence = ConfidenceType.CF_ACCEPTED; - } else { - this.mdValue.newValue.authority = null; - this.mdValue.newValue.confidence = ConfidenceType.CF_UNSET; - } - this.confirm.emit(false); - } else { - // The event is undefined when the user clears the selection in scrollable dropdown - this.mdValue.newValue.value = ''; - this.mdValue.newValue.authority = null; - this.mdValue.newValue.confidence = ConfidenceType.CF_UNSET; - this.confirm.emit(false); - } - } - - /** - * Returns the {@link DynamicOneboxModel} or {@link DynamicScrollableDropdownModel} model used - * for the authority field - */ - getModel(): DynamicOneboxModel | DynamicScrollableDropdownModel { - return this.model$.value; - } - - /** - * Change the status of the editingAuthority property - * @param status - */ - onChangeEditingAuthorityStatus(status: boolean) { - this.editingAuthority = status; - } - - /** - * Processes the change in authority value, updating the confidence as necessary. - * If the authority key is cleared, the confidence is set to {@link ConfidenceType.CF_NOVALUE}. - * If the authority key is edited and differs from the original, the confidence is set to {@link ConfidenceType.CF_ACCEPTED}. - */ - onChangeAuthorityKey() { - if (this.mdValue.newValue.authority === '') { - this.mdValue.newValue.confidence = ConfidenceType.CF_NOVALUE; - this.confirm.emit(false); - } else if (this.mdValue.newValue.authority !== this.mdValue.originalValue.authority) { - this.mdValue.newValue.confidence = ConfidenceType.CF_ACCEPTED; - this.confirm.emit(false); - } - } - - /** - * Toggles the free-text ediitng mode - */ - toggleFreeTextEdition() { - if (this.enabledFreeTextEditing) { - if (this.getModel().value !== this.mdValue.newValue.value) { - // Reload the model to adapt it to the new possible value modified during free text editing - this.initAuthorityProperties(); - } - } - this.enabledFreeTextEditing = !this.enabledFreeTextEditing; - } - } diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html index e8dfce3a7b..d9f62d2a5f 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html @@ -2,31 +2,31 @@