Merge remote-tracking branch 'upstream/main' into refactor-menu-resolvers-9.0

This commit is contained in:
Yana De Pauw
2025-03-28 18:10:02 +01:00
292 changed files with 14089 additions and 2288 deletions

View File

@@ -350,6 +350,8 @@ item:
# Rounded to the nearest size in the list of selectable sizes on the # Rounded to the nearest size in the list of selectable sizes on the
# settings menu. See pageSizeOptions in 'pagination-component-options.model.ts'. # settings menu. See pageSizeOptions in 'pagination-component-options.model.ts'.
pageSize: 5 pageSize: 5
# Show the bitstream access status label on the item page
showAccessStatuses: false
# Community Page Config # Community Page Config
community: community:

View File

@@ -1,5 +1,4 @@
import { testA11y } from 'cypress/support/utils'; import { testA11y } from 'cypress/support/utils';
import { Options } from 'cypress-axe';
describe('Admin Sidebar', () => { describe('Admin Sidebar', () => {
beforeEach(() => { beforeEach(() => {
@@ -16,13 +15,6 @@ describe('Admin Sidebar', () => {
cy.get('ds-expandable-admin-sidebar-section').click({ multiple: true }); cy.get('ds-expandable-admin-sidebar-section').click({ multiple: true });
// Analyze <ds-admin-sidebar> for accessibility // Analyze <ds-admin-sidebar> for accessibility
testA11y('ds-admin-sidebar', 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);
}); });
}); });

View File

@@ -1,10 +1,16 @@
{ {
"extends": "../tsconfig.json", "extends": "../tsconfig.json",
"include": [ "include": [
"**/*.ts" "**/*.ts",
"../cypress.config.ts"
], ],
"compilerOptions": { "compilerOptions": {
"sourceMap": false, "sourceMap": false,
"typeRoots": [
"../node_modules",
"../node_modules/@types",
"../src/typings.d.ts"
],
"types": [ "types": [
"cypress", "cypress",
"cypress-axe", "cypress-axe",

45
package-lock.json generated
View File

@@ -32,6 +32,7 @@
"@ngrx/store": "^18.1.1", "@ngrx/store": "^18.1.1",
"@ngx-translate/core": "^16.0.3", "@ngx-translate/core": "^16.0.3",
"@nicky-lenaers/ngx-scroll-to": "^14.0.0", "@nicky-lenaers/ngx-scroll-to": "^14.0.0",
"altcha": "^0.9.0",
"angulartics2": "^12.2.0", "angulartics2": "^12.2.0",
"axios": "^1.7.9", "axios": "^1.7.9",
"bootstrap": "^5.3", "bootstrap": "^5.3",
@@ -67,6 +68,7 @@
"ng2-file-upload": "7.0.1", "ng2-file-upload": "7.0.1",
"ng2-nouislider": "^2.0.0", "ng2-nouislider": "^2.0.0",
"ngx-infinite-scroll": "^18.0.0", "ngx-infinite-scroll": "^18.0.0",
"ngx-matomo-client": "^6.4.1",
"ngx-pagination": "6.0.3", "ngx-pagination": "6.0.3",
"ngx-skeleton-loader": "^9.0.0", "ngx-skeleton-loader": "^9.0.0",
"ngx-ui-switch": "^15.0.0", "ngx-ui-switch": "^15.0.0",
@@ -164,6 +166,12 @@
"version": "0.0.0", "version": "0.0.0",
"dev": true "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": { "node_modules/@ampproject/remapping": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
@@ -8895,6 +8903,31 @@
"ajv": "^8.8.2" "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": { "node_modules/angulartics2": {
"version": "12.2.1", "version": "12.2.1",
"resolved": "https://registry.npmjs.org/angulartics2/-/angulartics2-12.2.1.tgz", "resolved": "https://registry.npmjs.org/angulartics2/-/angulartics2-12.2.1.tgz",
@@ -17590,6 +17623,18 @@
"@angular/forms": ">=10.0.0" "@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": { "node_modules/ngx-pagination": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/ngx-pagination/-/ngx-pagination-6.0.3.tgz", "resolved": "https://registry.npmjs.org/ngx-pagination/-/ngx-pagination-6.0.3.tgz",

View File

@@ -114,6 +114,7 @@
"@ngrx/store": "^18.1.1", "@ngrx/store": "^18.1.1",
"@ngx-translate/core": "^16.0.3", "@ngx-translate/core": "^16.0.3",
"@nicky-lenaers/ngx-scroll-to": "^14.0.0", "@nicky-lenaers/ngx-scroll-to": "^14.0.0",
"altcha": "^0.9.0",
"angulartics2": "^12.2.0", "angulartics2": "^12.2.0",
"axios": "^1.7.9", "axios": "^1.7.9",
"bootstrap": "^5.3", "bootstrap": "^5.3",
@@ -149,6 +150,7 @@
"ng2-file-upload": "7.0.1", "ng2-file-upload": "7.0.1",
"ng2-nouislider": "^2.0.0", "ng2-nouislider": "^2.0.0",
"ngx-infinite-scroll": "^18.0.0", "ngx-infinite-scroll": "^18.0.0",
"ngx-matomo-client": "^6.4.1",
"ngx-pagination": "6.0.3", "ngx-pagination": "6.0.3",
"ngx-skeleton-loader": "^9.0.0", "ngx-skeleton-loader": "^9.0.0",
"ngx-ui-switch": "^15.0.0", "ngx-ui-switch": "^15.0.0",

View File

@@ -128,6 +128,22 @@
} }
</div> </div>
<!-- In the usesActorEmailId section -->
<div class="mb-5 mt-5">
<label class="status-label font-weight-bold" for="usesActorEmailId">{{ 'ldn-service-usesActorEmailId' | translate }}</label>
<div>
<input formControlName="usesActorEmailId" hidden id="usesActorEmailId"
name="usesActorEmailId" type="checkbox">
<div (click)="toggleUsesActorEmailId()"
[class.checked]="formModel.get('usesActorEmailId').value" class="toggle-switch">
<div class="slider"></div>
</div>
<div class="text-muted">
{{ 'ldn-service-usesActorEmailId-description' | translate }}
</div>
</div>
</div>
<!-- In the Inbound Patterns Labels section --> <!-- In the Inbound Patterns Labels section -->
@if (areControlsInitialized) { @if (areControlsInitialized) {

View File

@@ -125,6 +125,7 @@ export class LdnServiceFormComponent implements OnInit, OnDestroy {
score: ['', [Validators.required, Validators.pattern('^0*(\.[0-9]+)?$|^1(\.0+)?$')]], inboundPattern: [''], score: ['', [Validators.required, Validators.pattern('^0*(\.[0-9]+)?$|^1(\.0+)?$')]], inboundPattern: [''],
constraintPattern: [''], constraintPattern: [''],
enabled: [''], enabled: [''],
usesActorEmailId: [''],
type: LDN_SERVICE.value, type: LDN_SERVICE.value,
}); });
} }
@@ -178,7 +179,8 @@ export class LdnServiceFormComponent implements OnInit, OnDestroy {
return rest; 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); const ldnServiceData = this.ldnServicesService.create(values);
@@ -237,6 +239,7 @@ export class LdnServiceFormComponent implements OnInit, OnDestroy {
ldnUrl: this.ldnService.ldnUrl, ldnUrl: this.ldnService.ldnUrl,
type: this.ldnService.type, type: this.ldnService.type,
enabled: this.ldnService.enabled, enabled: this.ldnService.enabled,
usesActorEmailId: this.ldnService.usesActorEmailId,
lowerIp: this.ldnService.lowerIp, lowerIp: this.ldnService.lowerIp,
upperIp: this.ldnService.upperIp, 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 * Closes the modal
*/ */

View File

@@ -12,6 +12,7 @@ import { LdnService } from '../ldn-services-model/ldn-services.model';
export const mockLdnService: LdnService = { export const mockLdnService: LdnService = {
uuid: '1', uuid: '1',
enabled: false, enabled: false,
usesActorEmailId: false,
score: 0, score: 0,
id: 1, id: 1,
lowerIp: '192.0.2.146', lowerIp: '192.0.2.146',
@@ -49,6 +50,7 @@ export const mockLdnServiceRD$ = createSuccessfulRemoteDataObject$(mockLdnServic
export const mockLdnServices: LdnService[] = [{ export const mockLdnServices: LdnService[] = [{
uuid: '1', uuid: '1',
enabled: false, enabled: false,
usesActorEmailId: false,
score: 0, score: 0,
id: 1, id: 1,
lowerIp: '192.0.2.146', lowerIp: '192.0.2.146',
@@ -81,6 +83,7 @@ export const mockLdnServices: LdnService[] = [{
}, { }, {
uuid: '2', uuid: '2',
enabled: false, enabled: false,
usesActorEmailId: false,
score: 0, score: 0,
id: 2, id: 2,
lowerIp: '192.0.2.146', lowerIp: '192.0.2.146',

View File

@@ -52,6 +52,9 @@ export class LdnService extends CacheableObject {
@autoserialize @autoserialize
enabled: boolean; enabled: boolean;
@autoserialize
usesActorEmailId: boolean;
@autoserialize @autoserialize
ldnUrl: string; ldnUrl: string;

View File

@@ -1 +1 @@
<ds-publication-claim [source]="'openaire'"></ds-publication-claim> <ds-suggestion-sources></ds-suggestion-sources>

View File

@@ -6,8 +6,9 @@ import {
waitForAsync, waitForAsync,
} from '@angular/core/testing'; } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core'; 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'; import { AdminNotificationsPublicationClaimPageComponent } from './admin-notifications-publication-claim-page.component';
describe('AdminNotificationsPublicationClaimPageComponent', () => { describe('AdminNotificationsPublicationClaimPageComponent', () => {
@@ -20,17 +21,10 @@ describe('AdminNotificationsPublicationClaimPageComponent', () => {
CommonModule, CommonModule,
TranslateModule.forRoot(), TranslateModule.forRoot(),
AdminNotificationsPublicationClaimPageComponent, AdminNotificationsPublicationClaimPageComponent,
], MockComponent(SuggestionSourcesComponent),
providers: [
AdminNotificationsPublicationClaimPageComponent,
], ],
schemas: [NO_ERRORS_SCHEMA], schemas: [NO_ERRORS_SCHEMA],
}).overrideComponent(AdminNotificationsPublicationClaimPageComponent, { }).compileComponents();
remove: {
imports: [PublicationClaimComponent],
},
})
.compileComponents();
})); }));
beforeEach(() => { beforeEach(() => {

View File

@@ -1,14 +1,12 @@
import { Component } from '@angular/core'; 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({ @Component({
selector: 'ds-admin-notifications-publication-claim-page', selector: 'ds-admin-notifications-publication-claim-page',
templateUrl: './admin-notifications-publication-claim-page.component.html', templateUrl: './admin-notifications-publication-claim-page.component.html',
styleUrls: ['./admin-notifications-publication-claim-page.component.scss'], styleUrls: ['./admin-notifications-publication-claim-page.component.scss'],
imports: [ imports: [ SuggestionSourcesComponent ],
PublicationClaimComponent,
],
standalone: true, standalone: true,
}) })
export class AdminNotificationsPublicationClaimPageComponent { export class AdminNotificationsPublicationClaimPageComponent {

View File

@@ -2,7 +2,8 @@ import { Route } from '@angular/router';
import { authenticatedGuard } from '../../core/auth/authenticated.guard'; import { authenticatedGuard } from '../../core/auth/authenticated.guard';
import { i18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver'; 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 { 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 { 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'; 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, 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], canActivate: [authenticatedGuard],
path: `${QUALITY_ASSURANCE_EDIT_PATH}/:sourceId`, path: `${QUALITY_ASSURANCE_EDIT_PATH}/:sourceId`,
component: QualityAssuranceTopicsPageComponent, component: QualityAssuranceTopicsPageComponent,
pathMatch: 'full', pathMatch: 'full',
resolve: { resolve: {
breadcrumb: qualityAssuranceBreadcrumbResolver, breadcrumb: sourcesBreadcrumbResolver,
openaireQualityAssuranceTopicsParams: QualityAssuranceTopicsPageResolver, openaireQualityAssuranceTopicsParams: QualityAssuranceTopicsPageResolver,
}, },
data: { data: {
@@ -85,7 +101,7 @@ export const ROUTES: Route[] = [
component: QualityAssuranceEventsPageComponent, component: QualityAssuranceEventsPageComponent,
pathMatch: 'full', pathMatch: 'full',
resolve: { resolve: {
breadcrumb: qualityAssuranceBreadcrumbResolver, breadcrumb: sourcesBreadcrumbResolver,
openaireQualityAssuranceEventsParams: qualityAssuranceEventsPageResolver, openaireQualityAssuranceEventsParams: qualityAssuranceEventsPageResolver,
}, },
data: { data: {

View File

@@ -0,0 +1,8 @@
@if (shouldShowButton$ | async) {
<button class="export-button btn btn-dark btn-sm"
[ngbTooltip]="tooltipMsg | translate"
(click)="export()"
[title]="tooltipMsg | translate" [attr.aria-label]="tooltipMsg | translate">
<i class="fas fa-file-export fa-fw"></i>
</button>
}

View File

@@ -0,0 +1,4 @@
.export-button {
background: var(--ds-admin-sidebar-bg);
border-color: var(--ds-admin-sidebar-bg);
}

View File

@@ -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<FilteredItemsExportCsvComponent>;
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();
});
});
});

View File

@@ -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<boolean>;
/**
* 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<boolean> {
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<Process>) => {
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'));
}
});
}
}

View File

@@ -11,11 +11,16 @@
{{'admin.reports.items.section.collectionSelector' | translate}} {{'admin.reports.items.section.collectionSelector' | translate}}
</ng-template> </ng-template>
<ng-template ngbPanelContent> <ng-template ngbPanelContent>
<select id="collSel" name="collSel" class="form-control" multiple="multiple" size="10" formControlName="collections"> @if (loadingCollections$ | async) {
@for (item of collections; track item) { <ds-loading></ds-loading>
<option [value]="item.id" [disabled]="item.disabled">{{item.name$ | async}}</option> }
} @if ((loadingCollections$ | async) !== true) {
</select> <select id="collSel" name="collSel" class="form-control" multiple="multiple" size="10" formControlName="collections">
@for (item of collections; track item) {
<option [value]="item.id" [disabled]="item.disabled">{{item.name$ | async}}</option>
}
</select>
}
<div class="row"> <div class="row">
<span class="col-3"></span> <span class="col-3"></span>
<button class="btn btn-primary mt-1 col-6" (click)="submit()">{{'admin.reports.items.run' | translate}}</button> <button class="btn btn-primary mt-1 col-6" (click)="submit()">{{'admin.reports.items.run' | translate}}</button>
@@ -132,6 +137,10 @@
</select> </select>
</div> </div>
<div class="row"> <div class="row">
@if (csvExportEnabled$ | async) {
<span class="col-3"></span>
<div class="warning">{{ 'metadata-export-filtered-items.columns.warning' | translate }}</div>
}
<span class="col-3"></span> <span class="col-3"></span>
<button class="btn btn-primary mt-1 col-6" (click)="submit()">{{'admin.reports.items.run' | translate}}</button> <button class="btn btn-primary mt-1 col-6" (click)="submit()">{{'admin.reports.items.run' | translate}}</button>
</div> </div>
@@ -186,9 +195,9 @@
<div> <div>
<button id="prev" class="btn btn-light" (click)="prevPage()" [dsBtnDisabled]="!canNavigatePrevious()">{{'admin.reports.commons.previous-page' | translate}}</button> <button id="prev" class="btn btn-light" (click)="prevPage()" [dsBtnDisabled]="!canNavigatePrevious()">{{'admin.reports.commons.previous-page' | translate}}</button>
<button id="next" class="btn btn-light" (click)="nextPage()" [dsBtnDisabled]="!canNavigateNext()">{{'admin.reports.commons.next-page' | translate}}</button> <button id="next" class="btn btn-light" (click)="nextPage()" [dsBtnDisabled]="!canNavigateNext()">{{'admin.reports.commons.next-page' | translate}}</button>
<!-- <div style="float: right; margin-right: 60px;">
<button id="export">{{'admin.reports.commons.export' | translate}}</button> <ds-filtered-items-export-csv [reportParams]="queryForm"></ds-filtered-items-export-csv>
--> </div>
</div> </div>
<table id="itemtable" class="sortable"></table> <table id="itemtable" class="sortable"></table>
</ng-template> </ng-template>

View File

@@ -1,3 +1,10 @@
.num { .num {
text-align: center; text-align: center;
} }
.warning {
color: red;
font-style: italic;
text-align: center;
width: 100%;
}

View File

@@ -20,13 +20,16 @@ import {
TranslateService, TranslateService,
} from '@ngx-translate/core'; } from '@ngx-translate/core';
import { import {
BehaviorSubject,
map, map,
Observable, Observable,
} from 'rxjs'; } from 'rxjs';
import { CollectionDataService } from 'src/app/core/data/collection-data.service'; import { CollectionDataService } from 'src/app/core/data/collection-data.service';
import { CommunityDataService } from 'src/app/core/data/community-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 { MetadataFieldDataService } from 'src/app/core/data/metadata-field-data.service';
import { MetadataSchemaDataService } from 'src/app/core/data/metadata-schema-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 { RestRequestMethod } from 'src/app/core/data/rest-request-method';
import { DspaceRestService } from 'src/app/core/dspace-rest/dspace-rest.service'; import { DspaceRestService } from 'src/app/core/dspace-rest/dspace-rest.service';
import { RawRestResponse } from 'src/app/core/dspace-rest/raw-rest-response.model'; 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 { Community } from 'src/app/core/shared/community.model';
import { getFirstSucceededRemoteListPayload } from 'src/app/core/shared/operators'; import { getFirstSucceededRemoteListPayload } from 'src/app/core/shared/operators';
import { isEmpty } from 'src/app/shared/empty.util'; 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 { environment } from 'src/environments/environment';
import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive';
import { FiltersComponent } from '../filters-section/filters-section.component'; import { FiltersComponent } from '../filters-section/filters-section.component';
import { FilteredItemsExportCsvComponent } from './filtered-items-export-csv/filtered-items-export-csv.component';
import { import {
FilteredItem, FilteredItem,
FilteredItems, FilteredItems,
@@ -62,12 +67,19 @@ import { QueryPredicate } from './query-predicate.model';
AsyncPipe, AsyncPipe,
FiltersComponent, FiltersComponent,
BtnDisabledDirective, BtnDisabledDirective,
FilteredItemsExportCsvComponent,
ThemedLoadingComponent,
], ],
standalone: true, standalone: true,
}) })
export class FilteredItemsComponent implements OnInit { export class FilteredItemsComponent implements OnInit {
collections: OptionVO[]; collections: OptionVO[];
/**
* A Boolean representing if loading the list of collections is pending
*/
loadingCollections$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
presetQueries: PresetQuery[]; presetQueries: PresetQuery[];
metadataFields: OptionVO[]; metadataFields: OptionVO[];
metadataFieldsWithAny: OptionVO[]; metadataFieldsWithAny: OptionVO[];
@@ -79,6 +91,10 @@ export class FilteredItemsComponent implements OnInit {
results: FilteredItems = new FilteredItems(); results: FilteredItems = new FilteredItems();
results$: Observable<FilteredItem[]>; results$: Observable<FilteredItem[]>;
@ViewChild('acc') accordionComponent: NgbAccordion; @ViewChild('acc') accordionComponent: NgbAccordion;
/**
* Observable used to determine whether CSV export is enabled
*/
csvExportEnabled$: Observable<boolean>;
constructor( constructor(
private communityService: CommunityDataService, private communityService: CommunityDataService,
@@ -86,6 +102,8 @@ export class FilteredItemsComponent implements OnInit {
private metadataSchemaService: MetadataSchemaDataService, private metadataSchemaService: MetadataSchemaDataService,
private metadataFieldService: MetadataFieldDataService, private metadataFieldService: MetadataFieldDataService,
private translateService: TranslateService, private translateService: TranslateService,
private scriptDataService: ScriptDataService,
private authorizationDataService: AuthorizationDataService,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private restService: DspaceRestService) {} private restService: DspaceRestService) {}
@@ -100,6 +118,8 @@ export class FilteredItemsComponent implements OnInit {
new QueryPredicate().toFormGroup(this.formBuilder), new QueryPredicate().toFormGroup(this.formBuilder),
]; ];
this.csvExportEnabled$ = FilteredItemsExportCsvComponent.csvExportEnabled(this.scriptDataService, this.authorizationDataService);
this.queryForm = this.formBuilder.group({ this.queryForm = this.formBuilder.group({
collections: this.formBuilder.control([''], []), collections: this.formBuilder.control([''], []),
presetQuery: this.formBuilder.control('new', []), presetQuery: this.formBuilder.control('new', []),
@@ -111,6 +131,7 @@ export class FilteredItemsComponent implements OnInit {
} }
loadCollections(): void { loadCollections(): void {
this.loadingCollections$.next(true);
this.collections = []; this.collections = [];
const wholeRepo$ = this.translateService.stream('admin.reports.items.wholeRepo'); const wholeRepo$ = this.translateService.stream('admin.reports.items.wholeRepo');
this.collections.push(OptionVO.collectionLoc('', wholeRepo$)); this.collections.push(OptionVO.collectionLoc('', wholeRepo$));
@@ -132,6 +153,7 @@ export class FilteredItemsComponent implements OnInit {
const collVO = OptionVO.collection(collection.uuid, '' + collection.name); const collVO = OptionVO.collection(collection.uuid, '' + collection.name);
this.collections.push(collVO); 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).*$'), 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', [ 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', [ 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', [ PresetQuery.of('q12', 'admin.reports.items.preset.hasXmlEntityInMetadata', [
QueryPredicate.of('*', QueryPredicate.MATCHES, '^.*&#.*$'), QueryPredicate.of('*', QueryPredicate.MATCHES, '^.*&#.*$'),
@@ -344,13 +366,8 @@ export class FilteredItemsComponent implements OnInit {
const preds = this.queryForm.value.queryPredicates; const preds = this.queryForm.value.queryPredicates;
for (let i = 0; i < preds.length; i++) { for (let i = 0; i < preds.length; i++) {
const field = preds[i].field; const pred = encodeURIComponent(QueryPredicate.toString(preds[i]));
const op = preds[i].operator; params += `&queryPredicates=${pred}`;
const value = preds[i].value;
params += `&queryPredicates=${field}:${op}`;
if (value) {
params += `:${value}`;
}
} }
const filters = FiltersComponent.toQueryString(this.queryForm.value.filters); const filters = FiltersComponent.toQueryString(this.queryForm.value.filters);

View File

@@ -46,6 +46,16 @@ export class OptionVO {
subscriber.next(value); subscriber.next(value);
subscriber.complete(); subscriber.complete();
}); });
} }
static toString(obj: any): string {
if (obj) {
if (obj instanceof OptionVO && obj.id) {
return obj.id;
}
return obj as string;
}
return '';
}
} }

View File

@@ -29,6 +29,13 @@ export class QueryPredicate {
return pred; 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 { toFormGroup(formBuilder: FormBuilder): FormGroup {
return formBuilder.group({ return formBuilder.group({
field: new FormControl(this.field), field: new FormControl(this.field),

View File

@@ -10,7 +10,6 @@ import { TranslateModule } from '@ngx-translate/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { AuthService } from '../../../../../core/auth/auth.service'; 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 { BitstreamDataService } from '../../../../../core/data/bitstream-data.service';
import { AuthorizationDataService } from '../../../../../core/data/feature-authorization/authorization-data.service'; import { AuthorizationDataService } from '../../../../../core/data/feature-authorization/authorization-data.service';
import { RemoteData } from '../../../../../core/data/remote-data'; 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 { mockTruncatableService } from '../../../../../shared/mocks/mock-trucatable.service';
import { getMockThemeService } from '../../../../../shared/mocks/theme-service.mock'; import { getMockThemeService } from '../../../../../shared/mocks/theme-service.mock';
import { CollectionElementLinkType } from '../../../../../shared/object-collection/collection-element-link.type'; 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 { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model';
import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/remote-data.utils'; import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/remote-data.utils';
import { AuthServiceStub } from '../../../../../shared/testing/auth-service.stub'; import { AuthServiceStub } from '../../../../../shared/testing/auth-service.stub';
@@ -44,12 +42,6 @@ describe('ItemAdminSearchResultGridElementComponent', () => {
}, },
}; };
const mockAccessStatusDataService = {
findAccessStatusFor(item: Item): Observable<RemoteData<AccessStatusObject>> {
return createSuccessfulRemoteDataObject$(new AccessStatusObject());
},
};
const mockThemeService = getMockThemeService(); const mockThemeService = getMockThemeService();
function init() { function init() {
@@ -74,7 +66,6 @@ describe('ItemAdminSearchResultGridElementComponent', () => {
{ provide: TruncatableService, useValue: mockTruncatableService }, { provide: TruncatableService, useValue: mockTruncatableService },
{ provide: BitstreamDataService, useValue: mockBitstreamDataService }, { provide: BitstreamDataService, useValue: mockBitstreamDataService },
{ provide: ThemeService, useValue: mockThemeService }, { provide: ThemeService, useValue: mockThemeService },
{ provide: AccessStatusDataService, useValue: mockAccessStatusDataService },
{ provide: AuthService, useClass: AuthServiceStub }, { provide: AuthService, useClass: AuthServiceStub },
{ provide: FileService, useClass: FileServiceStub }, { provide: FileService, useClass: FileServiceStub },
{ provide: AuthorizationDataService, useClass: AuthorizationDataServiceStub }, { provide: AuthorizationDataService, useClass: AuthorizationDataServiceStub },

View File

@@ -261,6 +261,20 @@ export const APP_ROUTES: Route[] = [
.then((m) => m.ROUTES), .then((m) => m.ROUTES),
canActivate: [authenticatedGuard], 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 }, { path: '**', pathMatch: 'full', component: ThemedPageNotFoundComponent },
], ],
}, },

View File

@@ -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 const HOME_PAGE_PATH = 'home';
export function getHomePageRoute() { export function getHomePageRoute() {
@@ -128,6 +163,11 @@ export function getRequestCopyModulePath() {
return `/${REQUEST_COPY_MODULE_PATH}`; 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 HEALTH_PAGE_PATH = 'health';
export const SUBSCRIPTIONS_MODULE_PATH = 'subscriptions'; export const SUBSCRIPTIONS_MODULE_PATH = 'subscriptions';

View File

@@ -10,6 +10,7 @@ import {
import { import {
NoPreloading, NoPreloading,
provideRouter, provideRouter,
withComponentInputBinding,
withEnabledBlockingInitialNavigation, withEnabledBlockingInitialNavigation,
withInMemoryScrolling, withInMemoryScrolling,
withPreloading, withPreloading,
@@ -65,6 +66,7 @@ import {
import { ClientCookieService } from './core/services/client-cookie.service'; import { ClientCookieService } from './core/services/client-cookie.service';
import { ListableModule } from './core/shared/listable.module'; import { ListableModule } from './core/shared/listable.module';
import { XsrfInterceptor } from './core/xsrf/xsrf.interceptor'; 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 { RootModule } from './root.module';
import { AUTH_METHOD_FOR_DECORATOR_MAP } from './shared/log-in/methods/log-in.methods-decorator'; 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'; 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), withInMemoryScrolling(APP_ROUTING_SCROLL_CONF),
withEnabledBlockingInitialNavigation(), withEnabledBlockingInitialNavigation(),
withPreloading(NoPreloading), withPreloading(NoPreloading),
withComponentInputBinding(),
), ),
{ {
provide: APP_BASE_HREF, provide: APP_BASE_HREF,
@@ -168,6 +171,7 @@ export const commonAppConfig: ApplicationConfig = {
/* Use models object so all decorators are actually called */ /* Use models object so all decorators are actually called */
const modelList = models; const modelList = models;
const loginMethodForDecoratorMap = LOGIN_METHOD_FOR_DECORATOR_MAP;
const workflowTasks = WORKFLOW_TASK_OPTION_DECORATOR_MAP; const workflowTasks = WORKFLOW_TASK_OPTION_DECORATOR_MAP;
const advancedWorfklowTasks = ADVANCED_WORKFLOW_TASK_OPTION_DECORATOR_MAP; const advancedWorfklowTasks = ADVANCED_WORKFLOW_TASK_OPTION_DECORATOR_MAP;
const metadataRepresentations = METADATA_REPRESENTATION_COMPONENT_DECORATOR_MAP; const metadataRepresentations = METADATA_REPRESENTATION_COMPONENT_DECORATOR_MAP;

View File

@@ -1,4 +1,7 @@
import { CommonModule } from '@angular/common'; import {
CommonModule,
Location,
} from '@angular/common';
import { PLATFORM_ID } from '@angular/core'; import { PLATFORM_ID } from '@angular/core';
import { import {
ComponentFixture, ComponentFixture,
@@ -14,6 +17,7 @@ import { of as observableOf } from 'rxjs';
import { getForbiddenRoute } from '../../app-routing-paths'; import { getForbiddenRoute } from '../../app-routing-paths';
import { AuthService } from '../../core/auth/auth.service'; 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 { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { SignpostingDataService } from '../../core/data/signposting-data.service'; import { SignpostingDataService } from '../../core/data/signposting-data.service';
import { HardRedirectService } from '../../core/services/hard-redirect.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 { Bitstream } from '../../core/shared/bitstream.model';
import { FileService } from '../../core/shared/file.service'; import { FileService } from '../../core/shared/file.service';
import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils';
import { MatomoService } from '../../statistics/matomo.service';
import { BitstreamDownloadPageComponent } from './bitstream-download-page.component'; import { BitstreamDownloadPageComponent } from './bitstream-download-page.component';
describe('BitstreamDownloadPageComponent', () => { describe('BitstreamDownloadPageComponent', () => {
@@ -33,10 +38,13 @@ describe('BitstreamDownloadPageComponent', () => {
let hardRedirectService: HardRedirectService; let hardRedirectService: HardRedirectService;
let activatedRoute; let activatedRoute;
let router; let router;
let location: Location;
let dsoNameService: DSONameService;
let bitstream: Bitstream; let bitstream: Bitstream;
let serverResponseService: jasmine.SpyObj<ServerResponseService>; let serverResponseService: jasmine.SpyObj<ServerResponseService>;
let signpostingDataService: jasmine.SpyObj<SignpostingDataService>; let signpostingDataService: jasmine.SpyObj<SignpostingDataService>;
let matomoService: jasmine.SpyObj<MatomoService>;
const mocklink = { const mocklink = {
href: 'http://test.org', href: 'http://test.org',
@@ -54,6 +62,7 @@ describe('BitstreamDownloadPageComponent', () => {
authService = jasmine.createSpyObj('authService', { authService = jasmine.createSpyObj('authService', {
isAuthenticated: observableOf(true), isAuthenticated: observableOf(true),
setRedirectUrl: {}, setRedirectUrl: {},
getShortlivedToken: observableOf('token'),
}); });
authorizationService = jasmine.createSpyObj('authorizationSerivice', { authorizationService = jasmine.createSpyObj('authorizationSerivice', {
isAuthorized: observableOf(true), isAuthorized: observableOf(true),
@@ -63,9 +72,18 @@ describe('BitstreamDownloadPageComponent', () => {
retrieveFileDownloadLink: observableOf('content-url-with-headers'), retrieveFileDownloadLink: observableOf('content-url-with-headers'),
}); });
hardRedirectService = jasmine.createSpyObj('fileService', { hardRedirectService = jasmine.createSpyObj('hardRedirectService', {
redirect: {}, redirect: {},
}); });
location = jasmine.createSpyObj('location', {
back: {},
});
dsoNameService = jasmine.createSpyObj('dsoNameService', {
getName: 'Test Bitstream',
});
bitstream = Object.assign(new Bitstream(), { bitstream = Object.assign(new Bitstream(), {
uuid: 'bitstreamUuid', uuid: 'bitstreamUuid',
_links: { _links: {
@@ -73,16 +91,16 @@ describe('BitstreamDownloadPageComponent', () => {
self: { href: 'bitstream-self-link' }, self: { href: 'bitstream-self-link' },
}, },
}); });
activatedRoute = { activatedRoute = {
data: observableOf({ data: observableOf({
bitstream: createSuccessfulRemoteDataObject( bitstream: createSuccessfulRemoteDataObject(bitstream),
bitstream,
),
}), }),
params: observableOf({ params: observableOf({
id: 'testid', id: 'testid',
}), }),
queryParams: observableOf({
accessToken: undefined,
}),
}; };
router = jasmine.createSpyObj('router', ['navigateByUrl']); router = jasmine.createSpyObj('router', ['navigateByUrl']);
@@ -94,6 +112,8 @@ describe('BitstreamDownloadPageComponent', () => {
signpostingDataService = jasmine.createSpyObj('SignpostingDataService', { signpostingDataService = jasmine.createSpyObj('SignpostingDataService', {
getLinks: observableOf([mocklink, mocklink2]), getLinks: observableOf([mocklink, mocklink2]),
}); });
matomoService = jasmine.createSpyObj('MatomoService', ['appendVisitorId']);
matomoService.appendVisitorId.and.callFake((link) => observableOf(link));
} }
function initTestbed() { function initTestbed() {
@@ -108,7 +128,10 @@ describe('BitstreamDownloadPageComponent', () => {
{ provide: HardRedirectService, useValue: hardRedirectService }, { provide: HardRedirectService, useValue: hardRedirectService },
{ provide: ServerResponseService, useValue: serverResponseService }, { provide: ServerResponseService, useValue: serverResponseService },
{ provide: SignpostingDataService, useValue: signpostingDataService }, { provide: SignpostingDataService, useValue: signpostingDataService },
{ provide: MatomoService, useValue: matomoService },
{ provide: PLATFORM_ID, useValue: 'server' }, { provide: PLATFORM_ID, useValue: 'server' },
{ provide: Location, useValue: location },
{ provide: DSONameService, useValue: dsoNameService },
], ],
}) })
.compileComponents(); .compileComponents();
@@ -142,9 +165,11 @@ describe('BitstreamDownloadPageComponent', () => {
component = fixture.componentInstance; component = fixture.componentInstance;
fixture.detectChanges(); fixture.detectChanges();
}); });
it('should redirect to the content link', () => { it('should redirect to the content link', waitForAsync(() => {
expect(hardRedirectService.redirect).toHaveBeenCalledWith('bitstream-content-link'); fixture.whenStable().then(() => {
}); expect(hardRedirectService.redirect).toHaveBeenCalledWith('bitstream-content-link');
});
}));
it('should add the signposting links', () => { it('should add the signposting links', () => {
expect(serverResponseService.setHeader).toHaveBeenCalled(); expect(serverResponseService.setHeader).toHaveBeenCalled();
}); });
@@ -159,9 +184,11 @@ describe('BitstreamDownloadPageComponent', () => {
component = fixture.componentInstance; component = fixture.componentInstance;
fixture.detectChanges(); fixture.detectChanges();
}); });
it('should redirect to an updated content link', () => { it('should redirect to an updated content link', waitForAsync(() => {
expect(hardRedirectService.redirect).toHaveBeenCalledWith('content-url-with-headers'); fixture.whenStable().then(() => {
}); expect(hardRedirectService.redirect).toHaveBeenCalledWith('content-url-with-headers');
});
}));
}); });
describe('when the user is not authorized and logged in', () => { describe('when the user is not authorized and logged in', () => {
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
@@ -174,9 +201,11 @@ describe('BitstreamDownloadPageComponent', () => {
component = fixture.componentInstance; component = fixture.componentInstance;
fixture.detectChanges(); fixture.detectChanges();
}); });
it('should navigate to the forbidden route', () => { it('should navigate to the forbidden route', waitForAsync(() => {
expect(router.navigateByUrl).toHaveBeenCalledWith(getForbiddenRoute(), { skipLocationChange: true }); fixture.whenStable().then(() => {
}); expect(router.navigateByUrl).toHaveBeenCalledWith(getForbiddenRoute(), { skipLocationChange: true });
});
}));
}); });
describe('when the user is not authorized and not logged in', () => { describe('when the user is not authorized and not logged in', () => {
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
@@ -190,10 +219,12 @@ describe('BitstreamDownloadPageComponent', () => {
component = fixture.componentInstance; component = fixture.componentInstance;
fixture.detectChanges(); fixture.detectChanges();
}); });
it('should navigate to the login page', () => { it('should navigate to the login page', waitForAsync(() => {
expect(authService.setRedirectUrl).toHaveBeenCalled(); fixture.whenStable().then(() => {
expect(router.navigateByUrl).toHaveBeenCalledWith('login'); expect(authService.setRedirectUrl).toHaveBeenCalled();
}); expect(router.navigateByUrl).toHaveBeenCalledWith('login');
});
}));
}); });
}); });
}); });

View File

@@ -11,6 +11,7 @@ import {
} from '@angular/core'; } from '@angular/core';
import { import {
ActivatedRoute, ActivatedRoute,
Params,
Router, Router,
} from '@angular/router'; } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
@@ -44,6 +45,7 @@ import {
hasValue, hasValue,
isNotEmpty, isNotEmpty,
} from '../../shared/empty.util'; } from '../../shared/empty.util';
import { MatomoService } from '../../statistics/matomo.service';
@Component({ @Component({
selector: 'ds-bitstream-download-page', selector: 'ds-bitstream-download-page',
@@ -73,6 +75,7 @@ export class BitstreamDownloadPageComponent implements OnInit {
public dsoNameService: DSONameService, public dsoNameService: DSONameService,
private signpostingDataService: SignpostingDataService, private signpostingDataService: SignpostingDataService,
private responseService: ServerResponseService, private responseService: ServerResponseService,
private matomoService: MatomoService,
@Inject(PLATFORM_ID) protected platformId: string, @Inject(PLATFORM_ID) protected platformId: string,
) { ) {
this.initPageLinks(); this.initPageLinks();
@@ -83,6 +86,10 @@ export class BitstreamDownloadPageComponent implements OnInit {
} }
ngOnInit(): void { ngOnInit(): void {
const accessToken$: Observable<string> = this.route.queryParams.pipe(
map((queryParams: Params) => queryParams?.accessToken || null),
take(1),
);
this.bitstreamRD$ = this.route.data.pipe( this.bitstreamRD$ = this.route.data.pipe(
map((data) => data.bitstream)); map((data) => data.bitstream));
@@ -96,11 +103,11 @@ export class BitstreamDownloadPageComponent implements OnInit {
switchMap((bitstream: Bitstream) => { switchMap((bitstream: Bitstream) => {
const isAuthorized$ = this.authorizationService.isAuthorized(FeatureID.CanDownload, isNotEmpty(bitstream) ? bitstream.self : undefined); const isAuthorized$ = this.authorizationService.isAuthorized(FeatureID.CanDownload, isNotEmpty(bitstream) ? bitstream.self : undefined);
const isLoggedIn$ = this.auth.isAuthenticated(); 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), take(1),
switchMap(([isAuthorized, isLoggedIn, bitstream]: [boolean, boolean, Bitstream]) => { switchMap(([isAuthorized, isLoggedIn, accessToken, bitstream]: [boolean, boolean, string, Bitstream]) => {
if (isAuthorized && isLoggedIn) { if (isAuthorized && isLoggedIn) {
return this.fileService.retrieveFileDownloadLink(bitstream._links.content.href).pipe( return this.fileService.retrieveFileDownloadLink(bitstream._links.content.href).pipe(
filter((fileLink) => hasValue(fileLink)), filter((fileLink) => hasValue(fileLink)),
@@ -108,20 +115,34 @@ export class BitstreamDownloadPageComponent implements OnInit {
map((fileLink) => { map((fileLink) => {
return [isAuthorized, isLoggedIn, bitstream, fileLink]; return [isAuthorized, isLoggedIn, bitstream, fileLink];
})); }));
} else if (hasValue(accessToken)) {
return [[isAuthorized, !isLoggedIn, bitstream, '', accessToken]];
} else { } 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)) { if (isAuthorized && isLoggedIn && isNotEmpty(fileLink)) {
this.hardRedirectService.redirect(fileLink); this.hardRedirectService.redirect(fileLink);
} else if (isAuthorized && !isLoggedIn) { } else if (isAuthorized && !isLoggedIn && !hasValue(accessToken)) {
this.hardRedirectService.redirect(bitstream._links.content.href); this.hardRedirectService.redirect(fileLink);
} else if (!isAuthorized && isLoggedIn) { } else if (!isAuthorized) {
this.router.navigateByUrl(getForbiddenRoute(), { skipLocationChange: true }); // Either we have an access token, or we are logged in, or we are not logged in.
} else if (!isAuthorized && !isLoggedIn) { // For now, the access token does not care if we are logged in or not.
this.auth.setRedirectUrl(this.router.url); if (hasValue(accessToken)) {
this.router.navigateByUrl('login'); 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');
}
} }
}); });
} }

View File

@@ -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<ItemRequest> = (
route,
state,
router: Router = inject(Router),
authService: AuthService = inject(AuthService),
itemRequestDataService: ItemRequestDataService = inject(ItemRequestDataService),
): Observable<ItemRequest> => {
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<ItemRequest>) => 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;
}),
);
};

View File

@@ -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<AuthMethodType, AuthMethodTypeComponent>;
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<AuthMethodType, AuthMethodTypeComponent>();
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<AuthMethodType, AuthMethodTypeComponent>();
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);
});
});
});
});

View File

@@ -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<AppState>) {
}
/**
* 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<AuthMethodType, AuthMethodTypeComponent>,
excludedAuthMethod?: AuthMethodType,
): Observable<AuthMethod[]> {
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')),
);
}
}

View File

@@ -139,4 +139,5 @@ export abstract class AuthRequestService {
}), }),
); );
} }
} }

View File

@@ -62,6 +62,7 @@ import {
getFirstCompletedRemoteData, getFirstCompletedRemoteData,
} from '../shared/operators'; } from '../shared/operators';
import { PageInfo } from '../shared/page-info.model'; import { PageInfo } from '../shared/page-info.model';
import { URLCombiner } from '../url-combiner/url-combiner';
import { import {
CheckAuthenticationTokenAction, CheckAuthenticationTokenAction,
RefreshTokenAction, RefreshTokenAction,
@@ -278,7 +279,7 @@ export class AuthService {
if (status.hasSucceeded) { if (status.hasSucceeded) {
return status.payload.specialGroups; return status.payload.specialGroups;
} else { } 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 * Clear redirect url
*/ */
@@ -663,5 +689,4 @@ export class AuthService {
this.store.dispatch(new UnsetUserAsIdleAction()); this.store.dispatch(new UnsetUserAsIdleAction());
} }
} }
} }

View File

@@ -6,5 +6,5 @@ export enum AuthMethodType {
X509 = 'x509', X509 = 'x509',
Oidc = 'oidc', Oidc = 'oidc',
Orcid = 'orcid', Orcid = 'orcid',
Saml = 'saml' Saml = 'saml',
} }

View File

@@ -0,0 +1,4 @@
export enum AuthRegistrationType {
Orcid = 'ORCID',
Validation = 'VALIDATION_',
}

View File

@@ -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)],
});
});
});
});

View File

@@ -1,15 +1,17 @@
import { qualityAssuranceBreadcrumbResolver } from './quality-assurance-breadcrumb.resolver'; import { sourcesBreadcrumbResolver } from './sources-breadcrumb.resolver';
describe('qualityAssuranceBreadcrumbResolver', () => { describe('sourcesBreadcrumbResolver', () => {
describe('resolve', () => { describe('resolve', () => {
let resolver: any; let resolver: any;
let qualityAssuranceBreadcrumbService: any; let sourcesBreadcrumbService: any;
let route: any; let route: any;
const i18nKey = 'breadcrumbKey';
const fullPath = '/test/quality-assurance/'; const fullPath = '/test/quality-assurance/';
const expectedKey = 'testSourceId:testTopicId'; const expectedKey = 'breadcrumbKey:testSourceId:testTopicId';
beforeEach(() => { beforeEach(() => {
route = { route = {
data: { breadcrumbKey: i18nKey },
paramMap: { paramMap: {
get: function (param) { get: function (param) {
return this[param]; return this[param];
@@ -18,13 +20,13 @@ describe('qualityAssuranceBreadcrumbResolver', () => {
topicId: 'testTopicId', topicId: 'testTopicId',
}, },
}; };
qualityAssuranceBreadcrumbService = {}; sourcesBreadcrumbService = {};
resolver = qualityAssuranceBreadcrumbResolver; resolver = sourcesBreadcrumbResolver;
}); });
it('should resolve the breadcrumb config', () => { it('should resolve the breadcrumb config', () => {
const resolvedConfig = resolver(route as any, { url: fullPath + 'testSourceId' } as any, qualityAssuranceBreadcrumbService); const resolvedConfig = resolver(route as any, { url: fullPath + 'testSourceId' } as any, sourcesBreadcrumbService);
const expectedConfig = { provider: qualityAssuranceBreadcrumbService, key: expectedKey, url: fullPath }; const expectedConfig = { provider: sourcesBreadcrumbService, key: expectedKey, url: fullPath };
expect(resolvedConfig).toEqual(expectedConfig); expect(resolvedConfig).toEqual(expectedConfig);
}); });
}); });

View File

@@ -6,16 +6,17 @@ import {
} from '@angular/router'; } from '@angular/router';
import { BreadcrumbConfig } from '../../breadcrumbs/breadcrumb/breadcrumb-config.model'; 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<BreadcrumbConfig<string>> = ( export const sourcesBreadcrumbResolver: ResolveFn<BreadcrumbConfig<string>> = (
route: ActivatedRouteSnapshot, route: ActivatedRouteSnapshot,
state: RouterStateSnapshot, state: RouterStateSnapshot,
breadcrumbService: QualityAssuranceBreadcrumbService = inject(QualityAssuranceBreadcrumbService), breadcrumbService: SourcesBreadcrumbService = inject(SourcesBreadcrumbService),
): BreadcrumbConfig<string> => { ): BreadcrumbConfig<string> => {
const breadcrumbKey = route.data.breadcrumbKey;
const sourceId = route.paramMap.get('sourceId'); const sourceId = route.paramMap.get('sourceId');
const topicId = route.paramMap.get('topicId'); const topicId = route.paramMap.get('topicId');
let key = sourceId; let key = `${breadcrumbKey}:${sourceId}`;
if (topicId) { if (topicId) {
key += `:${topicId}`; key += `:${topicId}`;

View File

@@ -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)],
});
});
});
});

View File

@@ -14,9 +14,9 @@ import { BreadcrumbsProviderService } from './breadcrumbsProviderService';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class QualityAssuranceBreadcrumbService implements BreadcrumbsProviderService<string> { export class SourcesBreadcrumbService implements BreadcrumbsProviderService<string> {
private QUALITY_ASSURANCE_BREADCRUMB_KEY = 'admin.quality-assurance.breadcrumbs'; private BREADCRUMB_SUFFIX = '.breadcrumbs';
constructor( constructor(
private translationService: TranslateService, private translationService: TranslateService,
) { ) {
@@ -31,15 +31,16 @@ export class QualityAssuranceBreadcrumbService implements BreadcrumbsProviderSer
*/ */
getBreadcrumbs(key: string, url: string): Observable<Breadcrumb[]> { getBreadcrumbs(key: string, url: string): Observable<Breadcrumb[]> {
const args = key.split(':'); const args = key.split(':');
const sourceId = args[0]; const breadcrumbKey = args[0] + this.BREADCRUMB_SUFFIX;
const topicId = args.length > 2 ? args[args.length - 1] : args[1]; const sourceId = args[1];
const topicId = args.length > 3 ? args[args.length - 1] : args[2];
if (topicId) { 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(sourceId, `${url}${sourceId}`),
new Breadcrumb(topicId, undefined)]); new Breadcrumb(topicId, undefined)]);
} else { } 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}`)]); new Breadcrumb(sourceId, `${url}${sourceId}`)]);
} }

View File

@@ -46,11 +46,11 @@ describe('AccessStatusDataService', () => {
createService(); createService();
}); });
describe('when calling findAccessStatusFor', () => { describe('when calling findItemAccessStatusFor', () => {
let contentSource$; let contentSource$;
beforeEach(() => { beforeEach(() => {
contentSource$ = service.findAccessStatusFor(mockItem); contentSource$ = service.findItemAccessStatusFor(mockItem);
}); });
it('should send a new GetRequest', fakeAsync(() => { it('should send a new GetRequest', fakeAsync(() => {

View File

@@ -29,7 +29,7 @@ export class AccessStatusDataService extends BaseDataService<AccessStatusObject>
* Returns {@link RemoteData} of {@link AccessStatusObject} that is the access status of the given item * Returns {@link RemoteData} of {@link AccessStatusObject} that is the access status of the given item
* @param item Item we want the access status of * @param item Item we want the access status of
*/ */
findAccessStatusFor(item: Item): Observable<RemoteData<AccessStatusObject>> { findItemAccessStatusFor(item: Item): Observable<RemoteData<AccessStatusObject>> {
return this.findByHref(item._links.accessStatus.href); return this.findByHref(item._links.accessStatus.href);
} }
} }

View File

@@ -95,7 +95,7 @@ describe('EpersonRegistrationService', () => {
const expected = service.registerEmail('test@mail.org', 'afreshcaptchatoken'); const expected = service.registerEmail('test@mail.org', 'afreshcaptchatoken');
let headers = new HttpHeaders(); let headers = new HttpHeaders();
const options: HttpOptions = Object.create({}); const options: HttpOptions = Object.create({});
headers = headers.append('x-recaptcha-token', 'afreshcaptchatoken'); headers = headers.append('x-captcha-payload', 'afreshcaptchatoken');
options.headers = headers; options.headers = headers;
expect(requestService.send).toHaveBeenCalledWith(new PostRequest('request-id', 'rest-url/registrations', registration, options)); expect(requestService.send).toHaveBeenCalledWith(new PostRequest('request-id', 'rest-url/registrations', registration, options));
@@ -105,7 +105,7 @@ describe('EpersonRegistrationService', () => {
describe('searchByToken', () => { describe('searchByToken', () => {
it('should return a registration corresponding to the provided token', () => { 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|)', { expect(expected).toBeObservable(cold('(a|)', {
a: jasmine.objectContaining({ a: jasmine.objectContaining({
@@ -123,7 +123,7 @@ describe('EpersonRegistrationService', () => {
testScheduler.run(({ cold, expectObservable }) => { testScheduler.run(({ cold, expectObservable }) => {
rdbService.buildSingle.and.returnValue(cold('a', { a: rd })); rdbService.buildSingle.and.returnValue(cold('a', { a: rd }));
service.searchByToken('test-token'); service.searchByTokenAndUpdateData('test-token');
expect(requestService.send).toHaveBeenCalledWith( expect(requestService.send).toHaveBeenCalledWith(
jasmine.objectContaining({ jasmine.objectContaining({

View File

@@ -3,6 +3,7 @@ import {
HttpParams, HttpParams,
} from '@angular/common/http'; } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Operation } from 'fast-json-patch';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { import {
filter, filter,
@@ -18,6 +19,7 @@ import { RemoteDataBuildService } from '../cache/builders/remote-data-build.serv
import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { HttpOptions } from '../dspace-rest/dspace-rest.service';
import { GenericConstructor } from '../shared/generic-constructor'; import { GenericConstructor } from '../shared/generic-constructor';
import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointService } from '../shared/hal-endpoint.service';
import { NoContent } from '../shared/NoContent.model';
import { getFirstCompletedRemoteData } from '../shared/operators'; import { getFirstCompletedRemoteData } from '../shared/operators';
import { Registration } from '../shared/registration.model'; import { Registration } from '../shared/registration.model';
import { ResponseParsingService } from './parsing.service'; import { ResponseParsingService } from './parsing.service';
@@ -25,6 +27,7 @@ import { RegistrationResponseParsingService } from './registration-response-pars
import { RemoteData } from './remote-data'; import { RemoteData } from './remote-data';
import { import {
GetRequest, GetRequest,
PatchRequest,
PostRequest, PostRequest,
} from './request.models'; } from './request.models';
import { RequestService } from './request.service'; import { RequestService } from './request.service';
@@ -45,7 +48,6 @@ export class EpersonRegistrationService {
protected rdbService: RemoteDataBuildService, protected rdbService: RemoteDataBuildService,
protected halService: HALEndpointService, protected halService: HALEndpointService,
) { ) {
} }
/** /**
@@ -67,7 +69,7 @@ export class EpersonRegistrationService {
/** /**
* Register a new email address * Register a new email address
* @param email * @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<RemoteData<Registration>> { registerEmail(email: string, captchaToken: string = null, type?: string): Observable<RemoteData<Registration>> {
const registration = new Registration(); const registration = new Registration();
@@ -80,7 +82,7 @@ export class EpersonRegistrationService {
const options: HttpOptions = Object.create({}); const options: HttpOptions = Object.create({});
let headers = new HttpHeaders(); let headers = new HttpHeaders();
if (captchaToken) { if (captchaToken) {
headers = headers.append('x-recaptcha-token', captchaToken); headers = headers.append('x-captcha-payload', captchaToken);
} }
options.headers = headers; options.headers = headers;
@@ -103,10 +105,11 @@ export class EpersonRegistrationService {
} }
/** /**
* Search a registration based on the provided token * Searches for a registration based on the provided token.
* @param token * @param token The token to search for.
* @returns An observable of remote data containing the registration.
*/ */
searchByToken(token: string): Observable<RemoteData<Registration>> { searchByTokenAndUpdateData(token: string): Observable<RemoteData<Registration>> {
const requestId = this.requestService.generateRequestId(); const requestId = this.requestService.generateRequestId();
const href$ = this.getTokenSearchEndpoint(token).pipe( const href$ = this.getTokenSearchEndpoint(token).pipe(
@@ -126,7 +129,11 @@ export class EpersonRegistrationService {
return this.rdbService.buildSingle<Registration>(href$).pipe( return this.rdbService.buildSingle<Registration>(href$).pipe(
map((rd) => { map((rd) => {
if (rd.hasSucceeded && hasValue(rd.payload)) { 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 { } else {
return rd; 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<RemoteData<Registration>> {
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<ResponseParsingService> {
return RegistrationResponseParsingService;
},
});
this.requestService.send(request, true);
});
return this.rdbService.buildSingle<Registration>(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<RemoteData<NoContent>> {
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;
}
} }

View File

@@ -1,10 +1,17 @@
import { HttpHeaders } from '@angular/common/http';
import { of as observableOf } from 'rxjs'; import { of as observableOf } from 'rxjs';
import { RequestCopyEmail } from '../../request-copy/email-request-copy/request-copy-email.model'; 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 { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { ConfigurationProperty } from '../shared/configuration-property.model';
import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointService } from '../shared/hal-endpoint.service';
import { ItemRequest } from '../shared/item-request.model'; 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 { ItemRequestDataService } from './item-request-data.service';
import { PostRequest } from './request.models'; import { PostRequest } from './request.models';
import { RequestService } from './request.service'; import { RequestService } from './request.service';
@@ -16,12 +23,36 @@ describe('ItemRequestDataService', () => {
let requestService: RequestService; let requestService: RequestService;
let rdbService: RemoteDataBuildService; let rdbService: RemoteDataBuildService;
let halService: HALEndpointService; let halService: HALEndpointService;
let configService: ConfigurationDataService;
let authorizationDataService: AuthorizationDataService;
const restApiEndpoint = 'rest/api/endpoint/'; const restApiEndpoint = 'rest/api/endpoint/';
const requestId = 'request-id'; const requestId = 'request-id';
let itemRequest: ItemRequest; let itemRequest: ItemRequest;
beforeEach(() => { 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(), { itemRequest = Object.assign(new ItemRequest(), {
token: 'item-request-token', token: 'item-request-token',
}); });
@@ -36,13 +67,42 @@ describe('ItemRequestDataService', () => {
getEndpoint: observableOf(restApiEndpoint), 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', () => { describe('requestACopy', () => {
it('should send a POST request containing the provided item request', (done) => { it('should send a POST request containing the provided item request', (done) => {
service.requestACopy(itemRequest).subscribe(() => { const captchaPayload = 'payload';
expect(requestService.send).toHaveBeenCalledWith(new PostRequest(requestId, restApiEndpoint, itemRequest)); service.requestACopy(itemRequest, captchaPayload).subscribe(() => {
expect(requestService.send).toHaveBeenCalledWith(
new PostRequest(
requestId,
restApiEndpoint,
itemRequest,
{
headers: new HttpHeaders().set('x-captcha-payload', captchaPayload),
},
),
false,
);
done(); done();
}); });
}); });
@@ -56,14 +116,19 @@ describe('ItemRequestDataService', () => {
}); });
it('should send a PUT request containing the correct properties', (done) => { 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({ expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({
method: RestRequestMethod.PUT, method: RestRequestMethod.PUT,
href: `${restApiEndpoint}/${itemRequest.token}`,
body: JSON.stringify({ body: JSON.stringify({
acceptRequest: true, acceptRequest: true,
responseMessage: email.message, responseMessage: email.message,
subject: email.subject, subject: email.subject,
suggestOpenAccess: true, suggestOpenAccess: true,
accessPeriod: '+1DAY',
}),
options: jasmine.objectContaining({
headers: jasmine.any(HttpHeaders),
}), }),
})); }));
done(); done();
@@ -82,15 +147,70 @@ describe('ItemRequestDataService', () => {
service.deny(itemRequest.token, email).subscribe(() => { service.deny(itemRequest.token, email).subscribe(() => {
expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({ expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({
method: RestRequestMethod.PUT, method: RestRequestMethod.PUT,
href: `${restApiEndpoint}/${itemRequest.token}`,
body: JSON.stringify({ body: JSON.stringify({
acceptRequest: false, acceptRequest: false,
responseMessage: email.message, responseMessage: email.message,
subject: email.subject, subject: email.subject,
suggestOpenAccess: false, suggestOpenAccess: false,
accessPeriod: null,
}),
options: jasmine.objectContaining({
headers: jasmine.any(HttpHeaders),
}), }),
})); }));
done(); 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);
});
});
});
}); });

View File

@@ -13,14 +13,27 @@ import {
hasValue, hasValue,
isNotEmpty, isNotEmpty,
} from '../../shared/empty.util'; } from '../../shared/empty.util';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; 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 { ObjectCacheService } from '../cache/object-cache.service';
import { HttpOptions } from '../dspace-rest/dspace-rest.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 { HALEndpointService } from '../shared/hal-endpoint.service';
import { ItemRequest } from '../shared/item-request.model'; import { ItemRequest } from '../shared/item-request.model';
import { getFirstCompletedRemoteData } from '../shared/operators'; import { getFirstCompletedRemoteData } from '../shared/operators';
import { sendRequest } from '../shared/request.operators'; import { sendRequest } from '../shared/request.operators';
import { IdentifiableDataService } from './base/identifiable-data.service'; 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 { RemoteData } from './remote-data';
import { import {
PostRequest, PostRequest,
@@ -34,14 +47,20 @@ import { RequestService } from './request.service';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class ItemRequestDataService extends IdentifiableDataService<ItemRequest> { export class ItemRequestDataService extends IdentifiableDataService<ItemRequest> implements SearchData<ItemRequest> {
private searchData: SearchDataImpl<ItemRequest>;
constructor( constructor(
protected requestService: RequestService, protected requestService: RequestService,
protected rdbService: RemoteDataBuildService, protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService, protected objectCache: ObjectCacheService,
protected halService: HALEndpointService, protected halService: HALEndpointService,
protected configService: ConfigurationDataService,
protected authorizationService: AuthorizationDataService,
) { ) {
super('itemrequests', requestService, rdbService, objectCache, halService); super('itemrequests', requestService, rdbService, objectCache, halService);
this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
} }
getItemRequestEndpoint(): Observable<string> { getItemRequestEndpoint(): Observable<string> {
@@ -61,17 +80,26 @@ export class ItemRequestDataService extends IdentifiableDataService<ItemRequest>
/** /**
* Request a copy of an item * Request a copy of an item
* @param itemRequest * @param itemRequest
* @param captchaPayload payload of captcha verification
*/ */
requestACopy(itemRequest: ItemRequest): Observable<RemoteData<ItemRequest>> { requestACopy(itemRequest: ItemRequest, captchaPayload: string): Observable<RemoteData<ItemRequest>> {
const requestId = this.requestService.generateRequestId(); const requestId = this.requestService.generateRequestId();
const href$ = this.getItemRequestEndpoint(); 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( href$.pipe(
find((href: string) => hasValue(href)), find((href: string) => hasValue(href)),
map((href: string) => { map((href: string) => {
const request = new PostRequest(requestId, href, itemRequest); const request = new PostRequest(requestId, href, itemRequest, options);
this.requestService.send(request); this.requestService.send(request, false);
}), }),
).subscribe(); ).subscribe();
@@ -94,9 +122,10 @@ export class ItemRequestDataService extends IdentifiableDataService<ItemRequest>
* @param token Token of the {@link ItemRequest} * @param token Token of the {@link ItemRequest}
* @param email Email to send back to the user requesting the item * @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 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<RemoteData<ItemRequest>> { grant(token: string, email: RequestCopyEmail, suggestOpenAccess = false, accessPeriod: string = null): Observable<RemoteData<ItemRequest>> {
return this.process(token, email, true, suggestOpenAccess); return this.process(token, email, true, suggestOpenAccess, accessPeriod);
} }
/** /**
@@ -105,8 +134,9 @@ export class ItemRequestDataService extends IdentifiableDataService<ItemRequest>
* @param email Email to send back to the user requesting the item * @param email Email to send back to the user requesting the item
* @param grant Grant or deny the request (true = grant, false = deny) * @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 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<RemoteData<ItemRequest>> { process(token: string, email: RequestCopyEmail, grant: boolean, suggestOpenAccess = false, accessPeriod: string = null): Observable<RemoteData<ItemRequest>> {
const requestId = this.requestService.generateRequestId(); const requestId = this.requestService.generateRequestId();
this.getItemRequestEndpointByToken(token).pipe( this.getItemRequestEndpointByToken(token).pipe(
@@ -121,6 +151,7 @@ export class ItemRequestDataService extends IdentifiableDataService<ItemRequest>
responseMessage: email.message, responseMessage: email.message,
subject: email.subject, subject: email.subject,
suggestOpenAccess, suggestOpenAccess,
accessPeriod: accessPeriod,
}), options); }), options);
}), }),
sendRequest(this.requestService), sendRequest(this.requestService),
@@ -128,4 +159,81 @@ export class ItemRequestDataService extends IdentifiableDataService<ItemRequest>
return this.rdbService.buildFromRequestUUID(requestId); 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<RemoteData<ItemRequest>> {
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<ItemRequest>[]): Observable<RemoteData<PaginatedList<ItemRequest>>> {
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<string[]> {
return this.configService.findByPropertyName('request.item.grant.link.period').pipe(
getFirstCompletedRemoteData(),
map((propertyRD: RemoteData<ConfigurationProperty>) => 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<boolean> {
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<string>}
* 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<ItemRequest>[]): Observable<string> {
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<boolean>} true if user may download, false if not
*/
canDownload(bitstream: Bitstream): Observable<boolean> {
return this.authorizationService.isAuthorized(FeatureID.CanDownload, bitstream?.self);
}
} }

View File

@@ -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<string> {
return this.getEndpoint().pipe(
map((endpoint) => endpoint + '/challenge'),
);
}
/**
* Get the base CAPTCHA endpoint URL
* @protected
*/
protected getEndpoint(): Observable<string> {
return this.halService.getEndpoint(this.linkPath);
}
}

View File

@@ -4,5 +4,6 @@
export interface SignpostingLink { export interface SignpostingLink {
href?: string, href?: string,
rel?: string, rel?: string,
type?: string type?: string,
profile?: string
} }

View File

@@ -46,6 +46,7 @@ import { CoreState } from '../core-state.model';
import { ChangeAnalyzer } from '../data/change-analyzer'; import { ChangeAnalyzer } from '../data/change-analyzer';
import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service';
import { FindListOptions } from '../data/find-list-options.model'; import { FindListOptions } from '../data/find-list-options.model';
import { RemoteData } from '../data/remote-data';
import { import {
PatchRequest, PatchRequest,
PostRequest, 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<EPerson>) => {
expect(result.hasSucceeded).toBeTrue();
});
expect(service.mergeEPersonDataWithToken).toHaveBeenCalledWith(uuid, token, metadataKey);
});
});
}); });
class DummyChangeAnalyzer implements ChangeAnalyzer<Item> { class DummyChangeAnalyzer implements ChangeAnalyzer<Item> {

View File

@@ -394,6 +394,32 @@ export class EPersonDataService extends IdentifiableDataService<EPerson> impleme
return this.rdbService.buildFromRequestUUID(requestId); 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<RemoteData<EPerson>> {
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 * Create a new object on the server, and store the response in the object cache

View File

@@ -176,7 +176,6 @@ export const models =
ResearcherProfile, ResearcherProfile,
OrcidQueue, OrcidQueue,
OrcidHistory, OrcidHistory,
AccessStatusObject,
IdentifierData, IdentifierData,
Subscription, Subscription,
ItemRequest, ItemRequest,

View File

@@ -4,6 +4,8 @@ import {
inheritSerialization, inheritSerialization,
} from 'cerialize'; } from 'cerialize';
import { Observable } from 'rxjs'; 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 { import {
link, link,
@@ -52,6 +54,7 @@ export class Bitstream extends DSpaceObject implements ChildHALResource {
format: HALLink; format: HALLink;
content: HALLink; content: HALLink;
thumbnail: HALLink; thumbnail: HALLink;
accessStatus: HALLink;
}; };
/** /**
@@ -75,6 +78,13 @@ export class Bitstream extends DSpaceObject implements ChildHALResource {
@link(BUNDLE) @link(BUNDLE)
bundle?: Observable<RemoteData<Bundle>>; bundle?: Observable<RemoteData<Bundle>>;
/**
* 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<RemoteData<AccessStatusObject>>;
getParentLinkKey(): keyof this['_links'] { getParentLinkKey(): keyof this['_links'] {
return 'format'; return 'format';
} }

View File

@@ -44,4 +44,10 @@ export enum Context {
Bitstream = 'bitstream', Bitstream = 'bitstream',
CoarNotify = 'coarNotify', CoarNotify = 'coarNotify',
/**
* The Edit Metadata field Context values that are used in the Edit Item Metadata tab.
*/
AddMetadata = 'addMetadata',
EditMetadata = 'editMetadata',
} }

View File

@@ -80,7 +80,19 @@ export class ItemRequest implements CacheableObject {
*/ */
@autoserialize @autoserialize
bitstreamId: string; 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 * The {@link HALLink}s for this ItemRequest
*/ */

View File

@@ -130,7 +130,7 @@ export class Item extends DSpaceObject implements ChildHALResource, HandleObject
* The access status for this Item * The access status for this Item
* Will be undefined unless the access status {@link HALLink} has been resolved. * Will be undefined unless the access status {@link HALLink} has been resolved.
*/ */
@link(ACCESS_STATUS) @link(ACCESS_STATUS, false, 'accessStatus')
accessStatus?: Observable<RemoteData<AccessStatusObject>>; accessStatus?: Observable<RemoteData<AccessStatusObject>>;
/** /**

View File

@@ -23,4 +23,9 @@ export class MediaViewerItem {
* Incoming Bitsream thumbnail * Incoming Bitsream thumbnail
*/ */
thumbnail: string; thumbnail: string;
/**
* Access token, if accessed via a Request-a-Copy link
*/
accessToken: string;
} }

View File

@@ -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 { typedObject } from '../cache/builders/build-decorators';
import { MetadataValue } from './metadata.models';
import { REGISTRATION } from './registration.resource-type'; import { REGISTRATION } from './registration.resource-type';
import { ResourceType } from './resource-type'; import { ResourceType } from './resource-type';
import { UnCacheableObject } from './uncacheable-object.model'; import { UnCacheableObject } from './uncacheable-object.model';
export class RegistrationDataMetadataMap {
[key: string]: RegistrationDataMetadataValue[];
}
export class RegistrationDataMetadataValue extends MetadataValue {
overrides?: string;
}
@typedObject @typedObject
export class Registration implements UnCacheableObject { export class Registration implements UnCacheableObject {
static type = REGISTRATION; static type = REGISTRATION;
/**
* The unique identifier of this registration data
*/
id: string;
/** /**
* The object type * The object type
*/ */
@@ -29,8 +45,24 @@ export class Registration implements UnCacheableObject {
* The token linked to the registration * The token linked to the registration
*/ */
groupNames: string[]; groupNames: string[];
/** /**
* The token linked to the registration * The token linked to the registration
*/ */
groups: string[]; 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;
} }

View File

@@ -37,6 +37,7 @@ import { SearchService } from './search.service';
import { SearchConfigurationService } from './search-configuration.service'; import { SearchConfigurationService } from './search-configuration.service';
import anything = jasmine.anything; import anything = jasmine.anything;
@Component({ @Component({
template: '', template: '',
standalone: true, standalone: true,

View File

@@ -367,7 +367,7 @@ export class SearchService {
const appliedFilter = appliedFilters[i]; const appliedFilter = appliedFilters[i];
filters.push(appliedFilter); filters.push(appliedFilter);
} }
this.angulartics2.eventTrack.next({ const searchTrackObject = {
action: 'search', action: 'search',
properties: { properties: {
searchOptions: config, searchOptions: config,
@@ -384,7 +384,9 @@ export class SearchService {
filters: filters, filters: filters,
clickedObject, clickedObject,
}, },
}); };
this.angulartics2.eventTrack.next(searchTrackObject);
} }
/** /**

View File

@@ -2,17 +2,18 @@
<ds-dso-edit-metadata-value-headers role="presentation" [dsoType]="dsoType"></ds-dso-edit-metadata-value-headers> <ds-dso-edit-metadata-value-headers role="presentation" [dsoType]="dsoType"></ds-dso-edit-metadata-value-headers>
@for (mdValue of form.fields[mdField]; track mdValue; let idx = $index) { @for (mdValue of form.fields[mdField]; track mdValue; let idx = $index) {
<ds-dso-edit-metadata-value role="presentation" <ds-dso-edit-metadata-value role="presentation"
[dso]="dso" [dso]="dso"
[mdValue]="mdValue" [context]="Context.EditMetadata"
[mdField]="mdField" [mdValue]="mdValue"
[dsoType]="dsoType" [mdField]="mdField"
[saving$]="saving$" [dsoType]="dsoType"
[isOnlyValue]="form.fields[mdField].length === 1" [saving$]="saving$"
(edit)="mdValue.editing = true" [isOnlyValue]="form.fields[mdField].length === 1"
(confirm)="mdValue.confirmChanges($event); form.resetReinstatable(); valueSaved.emit()" (edit)="mdValue.editing = true"
(remove)="mdValue.change === DsoEditMetadataChangeTypeEnum.ADD ? form.remove(mdField, idx) : mdValue.change = DsoEditMetadataChangeTypeEnum.REMOVE; form.resetReinstatable(); valueSaved.emit()" (confirm)="mdValue.confirmChanges($event); form.resetReinstatable(); valueSaved.emit()"
(undo)="mdValue.change === DsoEditMetadataChangeTypeEnum.ADD ? form.remove(mdField, idx) : mdValue.discard(); valueSaved.emit()" (remove)="mdValue.change === DsoEditMetadataChangeTypeEnum.ADD ? form.remove(mdField, idx) : mdValue.change = DsoEditMetadataChangeTypeEnum.REMOVE; form.resetReinstatable(); valueSaved.emit()"
(dragging)="$event ? draggingMdField$.next(mdField) : draggingMdField$.next(null)"> (undo)="mdValue.change === DsoEditMetadataChangeTypeEnum.ADD ? form.remove(mdField, idx) : mdValue.discard(); valueSaved.emit()"
(dragging)="$event ? draggingMdField$.next(mdField) : draggingMdField$.next(null)">
</ds-dso-edit-metadata-value> </ds-dso-edit-metadata-value>
} }
</div> </div>

View File

@@ -15,6 +15,7 @@ import {
Observable, Observable,
} from 'rxjs'; } from 'rxjs';
import { Context } from '../../../core/shared/context.model';
import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { DSpaceObject } from '../../../core/shared/dspace-object.model';
import { import {
DsoEditMetadataChangeType, DsoEditMetadataChangeType,
@@ -78,6 +79,8 @@ export class DsoEditMetadataFieldValuesComponent {
*/ */
public DsoEditMetadataChangeTypeEnum = DsoEditMetadataChangeType; public DsoEditMetadataChangeTypeEnum = DsoEditMetadataChangeType;
public readonly Context = Context;
/** /**
* Drop a value into a new position * Drop a value into a new position
* Update the form's value array for the current field to match the dropped position * Update the form's value array for the current field to match the dropped position

View File

@@ -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<boolean> = new EventEmitter();
}

View File

@@ -0,0 +1,61 @@
@if (mdValue.editing) {
@if ((isAuthorityControlled$ | async) !== true || (enabledFreeTextEditing && (isSuggesterVocabulary$ | async) !== true)) {
<textarea class="form-control" rows="5" [(ngModel)]="mdValue.newValue.value"
[attr.aria-label]="(dsoType + '.edit.metadata.edit.value') | translate"
[dsDebounce]="300" (onDebounce)="confirm.emit(false)">
</textarea>
}
@if ((isScrollableVocabulary$ | async) && !enabledFreeTextEditing) {
<ds-dynamic-scrollable-dropdown [bindId]="mdField"
[group]="group"
[model]="getModel()"
(change)="onChangeAuthorityField($event)">
</ds-dynamic-scrollable-dropdown>
}
@if (((isHierarchicalVocabulary$ | async) && !enabledFreeTextEditing) || (isSuggesterVocabulary$ | async)) {
<ds-dynamic-onebox
[group]="group"
[model]="getModel()"
(change)="onChangeAuthorityField($event)">
</ds-dynamic-onebox>
}
@if ((isHierarchicalVocabulary$ | async) || (isScrollableVocabulary$ | async)) {
<button class="btn btn-secondary w-100 mt-2"
[title]="enabledFreeTextEditing ? dsoType + '.edit.metadata.edit.buttons.disable-free-text-editing' : dsoType + '.edit.metadata.edit.buttons.enable-free-text-editing' | translate"
(click)="toggleFreeTextEdition()">
<i class="fas fa-fw" [ngClass]="enabledFreeTextEditing ? 'fa-lock' : 'fa-unlock'"></i>
{{ (enabledFreeTextEditing ? dsoType + '.edit.metadata.edit.buttons.disable-free-text-editing' : dsoType + '.edit.metadata.edit.buttons.enable-free-text-editing') | translate }}
</button>
}
@if ((isAuthorityControlled$ | async) && (isSuggesterVocabulary$ | async)) {
<div class="mt-2">
<div class="btn-group w-75">
<i dsAuthorityConfidenceState
class="fas fa-fw p-0 me-1 mt-auto mb-auto"
aria-hidden="true"
[authorityValue]="mdValue.newValue.confidence"
[iconMode]="true"
></i>
<input class="form-control form-outline" data-test="authority-input" [(ngModel)]="mdValue.newValue.authority"
[disabled]="!editingAuthority"
[attr.aria-label]="(dsoType + '.edit.metadata.edit.authority.key') | translate"
(change)="onChangeAuthorityKey()"/>
@if (editingAuthority) {
<button class="btn btn-outline-success btn-sm ng-star-inserted" id="metadata-confirm-btn"
[title]="dsoType + '.edit.metadata.edit.buttons.close-authority-edition' | translate"
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.close-authority-edition' | translate }}"
(click)="onChangeEditingAuthorityStatus(false)">
<i class="fas fa-lock-open fa-fw"></i>
</button>
} @else {
<button class="btn btn-outline-secondary btn-sm ng-star-inserted" id="metadata-confirm-btn"
[title]="dsoType + '.edit.metadata.edit.buttons.open-authority-edition' | translate"
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.open-authority-edition' | translate }}"
(click)="onChangeEditingAuthorityStatus(true)">
<i class="fas fa-lock fa-fw"></i>
</button>
}
</div>
</div>
}
}

View File

@@ -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<DsoEditMetadataAuthorityFieldComponent>;
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);
});
});
});
});

View File

@@ -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<DynamicOneboxModel | DynamicScrollableDropdownModel> = new BehaviorSubject(null);
/**
* Observable with information about the authority vocabulary used
*/
private vocabulary$: Observable<Vocabulary>;
/**
* Observables with information about the authority vocabulary type used
*/
isAuthorityControlled$: Observable<boolean>;
isHierarchicalVocabulary$: Observable<boolean>;
isScrollableVocabulary$: Observable<boolean>;
isSuggesterVocabulary$: Observable<boolean>;
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<boolean> {
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;
}
}

View File

@@ -0,0 +1,8 @@
<select class="form-select" [(ngModel)]="mdValue?.newValue.value" (ngModelChange)="confirm.emit(false)"
[attr.aria-label]="(dsoType + '.edit.metadata.edit.value') | translate">
@for (entity of (entities$ | async); track entity.label) {
<option [value]="entity.label === 'none' ? undefined : entity.label">
{{ entity.label }}
</option>
}
</select>

View File

@@ -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<DsoEditMetadataEntityFieldComponent>;
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();
});
});

View File

@@ -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<ItemType[]>;
constructor(
protected entityTypeService: EntityTypeDataService,
) {
super();
}
ngOnInit(): void {
this.entities$ = this.entityTypeService.findAll({ elementsPerPage: 100, currentPage: 1 }).pipe(
getFirstSucceededRemoteListPayload(),
);
}
}

View File

@@ -0,0 +1,8 @@
/**
* The edit metadata field tab types
*/
export enum EditMetadataValueFieldType {
PLAIN_TEXT = 'PLAIN_TEXT',
ENTITY_TYPE = 'ENTITY_TYPE',
AUTHORITY = 'AUTHORITY',
}

View File

@@ -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();
});
});

View File

@@ -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<Vocabulary> {
if (isNotEmpty(mdField)) {
const owningCollection$: Observable<Collection> = 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);
}
}
}

View File

@@ -0,0 +1,7 @@
<textarea [(ngModel)]="mdValue?.newValue.value"
[attr.aria-label]="(dsoType + '.edit.metadata.edit.value') | translate"
[dsDebounce]="300"
(onDebounce)="confirm.emit(false)"
class="form-control"
rows="5">
</textarea>

View File

@@ -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<DsoEditMetadataTextFieldComponent>;
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();
});
});

View File

@@ -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 {
}

View File

@@ -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<Component> {
/**
* 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<boolean> = 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<Component> {
return getDsoEditMetadataValueFieldComponent(this.type, this.context, this.themeService.getThemeName());
}
}

View File

@@ -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, Map<Context, Map<string, MetadataValueFieldComponent>>>([
[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);
}

View File

@@ -1,135 +1,94 @@
<div class="d-flex flex-row ds-value-row" *ngVar="metadataService.isVirtual(mdValue.newValue) as isVirtual" role="row" @let isVirtual = metadataService.isVirtual(mdValue.newValue);
cdkDrag (cdkDragStarted)="dragging.emit(true)" (cdkDragEnded)="dragging.emit(false)" <div class="d-flex flex-row ds-value-row" role="row"
[ngClass]="{ 'ds-warning': mdValue.reordered || mdValue.change === DsoEditMetadataChangeTypeEnum.UPDATE, 'ds-danger': mdValue.change === DsoEditMetadataChangeTypeEnum.REMOVE, 'ds-success': mdValue.change === DsoEditMetadataChangeTypeEnum.ADD, 'h-100': isOnlyValue }"> cdkDrag (cdkDragStarted)="dragging.emit(true)" (cdkDragEnded)="dragging.emit(false)"
<div class="flex-grow-1 ds-flex-cell ds-value-cell d-flex flex-column" *ngVar="(mdRepresentation$ | async) as mdRepresentation" role="cell"> [ngClass]="{ 'ds-warning': mdValue.reordered || mdValue.change === DsoEditMetadataChangeTypeEnum.UPDATE, 'ds-danger': mdValue.change === DsoEditMetadataChangeTypeEnum.REMOVE, 'ds-success': mdValue.change === DsoEditMetadataChangeTypeEnum.ADD, 'h-100': isOnlyValue }">
@let mdRepresentation = (mdRepresentation$ | async);
<div class="flex-grow-1 ds-flex-cell ds-value-cell d-flex flex-column" role="cell">
@if (!mdValue.editing && !mdRepresentation) { @if (!mdValue.editing && !mdRepresentation) {
<div class="dont-break-out preserve-line-breaks">{{ mdValue.newValue.value }}</div> <div class="dont-break-out preserve-line-breaks">{{ mdValue.newValue.value }}</div>
} }
@if (mdValue.editing && !mdRepresentation && ((isAuthorityControlled() | async) !== true || (enabledFreeTextEditing && (isSuggesterVocabulary() | async) !== true))) { @if (mdValue.editing && !mdRepresentation) {
<textarea class="form-control" rows="5" [(ngModel)]="mdValue.newValue.value" <ds-dso-edit-metadata-value-field-loader [context]="context"
[attr.aria-label]="(dsoType + '.edit.metadata.edit.value') | translate" [dso]="dso"
[dsDebounce]="300" (onDebounce)="confirm.emit(false)"></textarea> [dsoType]="dsoType"
} [mdField]="mdField"
@if (mdValue.editing && (isScrollableVocabulary() | async) && !enabledFreeTextEditing) { [mdValue]="mdValue"
<ds-dynamic-scrollable-dropdown [type]="fieldType$ | async"
[bindId]="mdField" (confirm)="confirm.emit($event)"
[group]="group" class="w-100">
[model]="getModel()" </ds-dso-edit-metadata-value-field-loader>
(change)="onChangeAuthorityField($event)">
</ds-dynamic-scrollable-dropdown>
}
@if (mdValue.editing && (((isHierarchicalVocabulary() | async) && !enabledFreeTextEditing) || (isSuggesterVocabulary() | async))) {
<ds-dynamic-onebox
[group]="group"
[model]="getModel()"
(change)="onChangeAuthorityField($event)">
</ds-dynamic-onebox>
}
@if (mdValue.editing && ((isScrollableVocabulary() | async) || (isHierarchicalVocabulary() | async))) {
<button class="btn btn-secondary mt-2"
[title]="enabledFreeTextEditing ? dsoType + '.edit.metadata.edit.buttons.disable-free-text-editing' : dsoType + '.edit.metadata.edit.buttons.enable-free-text-editing' | translate"
(click)="toggleFreeTextEdition()">
<i class="fas fa-fw" [ngClass]="enabledFreeTextEditing ? 'fa-lock' : 'fa-unlock'"></i>
{{ (enabledFreeTextEditing ? dsoType + '.edit.metadata.edit.buttons.disable-free-text-editing' : dsoType + '.edit.metadata.edit.buttons.enable-free-text-editing') | translate }}
</button>
} }
@if (!isVirtual && !mdValue.editing && mdValue.newValue.authority && mdValue.newValue.confidence !== ConfidenceTypeEnum.CF_UNSET && mdValue.newValue.confidence !== ConfidenceTypeEnum.CF_NOVALUE) { @if (!isVirtual && !mdValue.editing && mdValue.newValue.authority && mdValue.newValue.confidence !== ConfidenceTypeEnum.CF_UNSET && mdValue.newValue.confidence !== ConfidenceTypeEnum.CF_NOVALUE) {
<div> <div>
<span class="badge bg-light border" > <span class="badge bg-light border">
<i dsAuthorityConfidenceState <i dsAuthorityConfidenceState
class="fas fa-fw p-0" class="fas fa-fw p-0"
aria-hidden="true" aria-hidden="true"
[authorityValue]="mdValue.newValue" [authorityValue]="mdValue.newValue"
[iconMode]="true" [iconMode]="true"
></i> ></i>
{{ dsoType + '.edit.metadata.authority.label' | translate }} {{ mdValue.newValue.authority }} {{ dsoType + '.edit.metadata.authority.label' | translate }} {{ mdValue.newValue.authority }}
</span> </span>
</div> </div>
} }
@if ( mdValue.editing && (isAuthorityControlled() | async) && (isSuggesterVocabulary() | async)) {
<div class="mt-2">
<div class="btn-group w-75">
<i dsAuthorityConfidenceState
class="fas fa-fw p-0 me-1 mt-auto mb-auto"
aria-hidden="true"
[authorityValue]="mdValue.newValue.confidence"
[iconMode]="true"
></i>
<input class="form-control form-outline" data-test="authority-input" [(ngModel)]="mdValue.newValue.authority" [disabled]="!editingAuthority"
[attr.aria-label]="(dsoType + '.edit.metadata.edit.authority.key') | translate"
(change)="onChangeAuthorityKey()" />
@if (!editingAuthority) {
<button class="btn btn-outline-secondary btn-sm ng-star-inserted" id="metadata-confirm-btn"
[title]="dsoType + '.edit.metadata.edit.buttons.open-authority-edition' | translate"
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.open-authority-edition' | translate }}"
(click)="onChangeEditingAuthorityStatus(true)">
<i class="fas fa-lock fa-fw"></i>
</button>
}
@if (editingAuthority) {
<button class="btn btn-outline-success btn-sm ng-star-inserted" id="metadata-confirm-btn"
[title]="dsoType + '.edit.metadata.edit.buttons.close-authority-edition' | translate"
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.close-authority-edition' | translate }}"
(click)="onChangeEditingAuthorityStatus(false)">
<i class="fas fa-lock-open fa-fw"></i>
</button>
}
</div>
</div>
}
@if (mdRepresentation) { @if (mdRepresentation) {
<div class="d-flex"> <div class="d-flex">
<a class="me-2" target="_blank" [routerLink]="mdRepresentationItemRoute$ | async">{{ mdRepresentationName$ | async }}</a> <a class="me-2" target="_blank"
[routerLink]="mdRepresentationItemRoute$ | async">{{ mdRepresentationName$ | async }}</a>
<ds-type-badge [object]="mdRepresentation"></ds-type-badge> <ds-type-badge [object]="mdRepresentation"></ds-type-badge>
</div> </div>
} }
</div> </div>
<div class="ds-flex-cell ds-lang-cell" role="cell"> <div class="ds-flex-cell ds-lang-cell" role="cell">
@if (!mdValue.editing) {
<div class="dont-break-out preserve-line-breaks">{{ mdValue.newValue.language }}</div>
}
@if (mdValue.editing) { @if (mdValue.editing) {
<input class="form-control" type="text" [(ngModel)]="mdValue.newValue.language" <input class="form-control" type="text" [(ngModel)]="mdValue.newValue.language"
[attr.aria-label]="(dsoType + '.edit.metadata.edit.language') | translate" [attr.aria-label]="(dsoType + '.edit.metadata.edit.language') | translate"
[dsDebounce]="300" (onDebounce)="confirm.emit(false)" /> [dsDebounce]="300" (onDebounce)="confirm.emit(false)"/>
} @else {
<div class="dont-break-out preserve-line-breaks">{{ mdValue.newValue.language }}</div>
} }
</div> </div>
<div class="text-center ds-flex-cell ds-edit-cell" role="cell"> <div class="text-center ds-flex-cell ds-edit-cell" role="cell">
<div class="btn-group"> <div class="btn-group">
<div class="edit-field"> <div class="edit-field">
<div class="btn-group edit-buttons" [ngbTooltip]="isVirtual ? (dsoType + '.edit.metadata.edit.buttons.virtual' | translate) : null"> <div class="btn-group edit-buttons"
@if (!mdValue.editing) { [ngbTooltip]="isVirtual ? (dsoType + '.edit.metadata.edit.buttons.virtual' | translate) : null">
@let saving = (saving$ | async);
@if (mdValue.editing) {
<button class="btn btn-outline-success btn-sm ng-star-inserted" data-test="metadata-confirm-btn"
[title]="dsoType + '.edit.metadata.edit.buttons.confirm' | translate"
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.confirm' | translate }}"
[dsBtnDisabled]="isVirtual || saving" (click)="confirm.emit(true)">
<i class="fas fa-check fa-fw"></i>
</button>
} @else {
<button class="btn btn-outline-primary btn-sm ng-star-inserted" data-test="metadata-edit-btn" <button class="btn btn-outline-primary btn-sm ng-star-inserted" data-test="metadata-edit-btn"
[title]="dsoType + '.edit.metadata.edit.buttons.edit' | translate" [title]="dsoType + '.edit.metadata.edit.buttons.edit' | translate"
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.edit' | translate }}" ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.edit' | translate }}"
[dsBtnDisabled]="isVirtual || mdValue.change === DsoEditMetadataChangeTypeEnum.REMOVE || (saving$ | async)" (click)="edit.emit()"> [dsBtnDisabled]="isVirtual || mdValue.change === DsoEditMetadataChangeTypeEnum.REMOVE || saving"
(click)="edit.emit()">
<i class="fas fa-edit fa-fw"></i> <i class="fas fa-edit fa-fw"></i>
</button> </button>
} }
@if (mdValue.editing) {
<button class="btn btn-outline-success btn-sm ng-star-inserted" data-test="metadata-confirm-btn"
[title]="dsoType + '.edit.metadata.edit.buttons.confirm' | translate"
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.confirm' | translate }}"
[dsBtnDisabled]="isVirtual || (saving$ | async)" (click)="confirm.emit(true)">
<i class="fas fa-check fa-fw"></i>
</button>
}
<button class="btn btn-outline-danger btn-sm" data-test="metadata-remove-btn" <button class="btn btn-outline-danger btn-sm" data-test="metadata-remove-btn"
[title]="dsoType + '.edit.metadata.edit.buttons.remove' | translate" [title]="dsoType + '.edit.metadata.edit.buttons.remove' | translate"
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.remove' | translate }}" ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.remove' | translate }}"
[dsBtnDisabled]="isVirtual || (mdValue.change && mdValue.change !== DsoEditMetadataChangeTypeEnum.ADD) || mdValue.editing || (saving$ | async)" (click)="remove.emit()"> [dsBtnDisabled]="isVirtual || (mdValue.change && mdValue.change !== DsoEditMetadataChangeTypeEnum.ADD) || mdValue.editing || saving"
(click)="remove.emit()">
<i class="fas fa-trash-alt fa-fw"></i> <i class="fas fa-trash-alt fa-fw"></i>
</button> </button>
<button class="btn btn-outline-warning btn-sm" data-test="metadata-undo-btn" <button class="btn btn-outline-warning btn-sm" data-test="metadata-undo-btn"
[title]="dsoType + '.edit.metadata.edit.buttons.undo' | translate" [title]="dsoType + '.edit.metadata.edit.buttons.undo' | translate"
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.undo' | translate }}" ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.undo' | translate }}"
[dsBtnDisabled]="isVirtual || (!mdValue.change && mdValue.reordered) || (!mdValue.change && !mdValue.editing) || (saving$ | async)" (click)="undo.emit()"> [dsBtnDisabled]="isVirtual || (!mdValue.change && mdValue.reordered) || (!mdValue.change && !mdValue.editing) || saving"
(click)="undo.emit()">
<i class="fas fa-undo-alt fa-fw"></i> <i class="fas fa-undo-alt fa-fw"></i>
</button> </button>
<button class="btn btn-outline-secondary ds-drag-handle btn-sm" data-test="metadata-drag-btn" *ngVar="(isOnlyValue || (saving$ | async)) as disabled" <button class="btn btn-outline-secondary ds-drag-handle btn-sm" data-test="metadata-drag-btn"
cdkDragHandle [cdkDragHandleDisabled]="disabled" [ngClass]="{'disabled': disabled}" [dsBtnDisabled]="disabled" cdkDragHandle [cdkDragHandleDisabled]="isOnlyValue || saving"
[title]="dsoType + '.edit.metadata.edit.buttons.drag' | translate" [class.disabled]="isOnlyValue || saving" [dsBtnDisabled]="isOnlyValue || saving"
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.drag' | translate }}"> [title]="dsoType + '.edit.metadata.edit.buttons.drag' | translate"
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.drag' | translate }}">
<i class="fas fa-grip-vertical fa-fw"></i> <i class="fas fa-grip-vertical fa-fw"></i>
</button> </button>
</div> </div>

View File

@@ -8,42 +8,27 @@ import {
waitForAsync, waitForAsync,
} from '@angular/core/testing'; } from '@angular/core/testing';
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
import { RouterTestingModule } from '@angular/router/testing'; import { RouterModule } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { of } from 'rxjs'; 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 { DSONameService } from '../../../core/breadcrumbs/dso-name.service';
import { ItemDataService } from '../../../core/data/item-data.service';
import { RelationshipDataService } from '../../../core/data/relationship-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 { import {
MetadataValue, MetadataValue,
VIRTUAL_METADATA_PREFIX, VIRTUAL_METADATA_PREFIX,
} from '../../../core/shared/metadata.models'; } from '../../../core/shared/metadata.models';
import { ItemMetadataRepresentation } from '../../../core/shared/metadata-representation/item/item-metadata-representation.model'; import { ItemMetadataRepresentation } from '../../../core/shared/metadata-representation/item/item-metadata-representation.model';
import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; 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 { 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 { VarDirective } from '../../../shared/utils/var.directive';
import { import {
DsoEditMetadataChangeType, DsoEditMetadataChangeType,
DsoEditMetadataValue, DsoEditMetadataValue,
} from '../dso-edit-metadata-form'; } 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'; import { DsoEditMetadataValueComponent } from './dso-edit-metadata-value.component';
const EDIT_BTN = 'edit'; const EDIT_BTN = 'edit';
@@ -58,97 +43,12 @@ describe('DsoEditMetadataValueComponent', () => {
let relationshipService: RelationshipDataService; let relationshipService: RelationshipDataService;
let dsoNameService: DSONameService; let dsoNameService: DSONameService;
let vocabularyServiceStub: any; let dsoEditMetadataFieldService: DsoEditMetadataFieldServiceStub;
let itemService: ItemDataService;
let registryService: RegistryService;
let notificationsService: NotificationsService;
let editMetadataValue: DsoEditMetadataValue; let editMetadataValue: DsoEditMetadataValue;
let metadataValue: MetadataValue; 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 { 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', { relationshipService = jasmine.createSpyObj('relationshipService', {
resolveMetadataRepresentation: of( resolveMetadataRepresentation: of(
new ItemMetadataRepresentation(metadataValue), new ItemMetadataRepresentation(metadataValue),
@@ -157,14 +57,7 @@ describe('DsoEditMetadataValueComponent', () => {
dsoNameService = jasmine.createSpyObj('dsoNameService', { dsoNameService = jasmine.createSpyObj('dsoNameService', {
getName: 'Related Name', getName: 'Related Name',
}); });
itemService = jasmine.createSpyObj('itemService', { dsoEditMetadataFieldService = new DsoEditMetadataFieldServiceStub();
findByHref: createSuccessfulRemoteDataObject$(item),
});
vocabularyServiceStub = new VocabularyServiceStub();
registryService = jasmine.createSpyObj('registryService', {
queryMetadataFields: createSuccessfulRemoteDataObject$(createPaginatedList(metadataFields)),
});
notificationsService = jasmine.createSpyObj('notificationsService', ['error', 'success']);
} }
beforeEach(waitForAsync(async () => { beforeEach(waitForAsync(async () => {
@@ -175,18 +68,13 @@ describe('DsoEditMetadataValueComponent', () => {
authority: undefined, authority: undefined,
}); });
editMetadataValue = new DsoEditMetadataValue(metadataValue); editMetadataValue = new DsoEditMetadataValue(metadataValue);
dso = Object.assign(new DSpaceObject(), {
_links: {
self: { href: 'fake-dso-url/dso' },
},
});
initServices(); initServices();
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [ imports: [
TranslateModule.forRoot(), TranslateModule.forRoot(),
RouterTestingModule.withRoutes([]), RouterModule.forRoot([]),
DsoEditMetadataValueComponent, DsoEditMetadataValueComponent,
VarDirective, VarDirective,
BtnDisabledDirective, BtnDisabledDirective,
@@ -194,16 +82,16 @@ describe('DsoEditMetadataValueComponent', () => {
providers: [ providers: [
{ provide: RelationshipDataService, useValue: relationshipService }, { provide: RelationshipDataService, useValue: relationshipService },
{ provide: DSONameService, useValue: dsoNameService }, { provide: DSONameService, useValue: dsoNameService },
{ provide: VocabularyService, useValue: vocabularyServiceStub }, { provide: DsoEditMetadataFieldService, useValue: dsoEditMetadataFieldService },
{ provide: ItemDataService, useValue: itemService },
{ provide: RegistryService, useValue: registryService },
{ provide: NotificationsService, useValue: notificationsService },
], ],
schemas: [NO_ERRORS_SCHEMA], schemas: [NO_ERRORS_SCHEMA],
}) })
.overrideComponent(DsoEditMetadataValueComponent, { .overrideComponent(DsoEditMetadataValueComponent, {
remove: { remove: {
imports: [DsDynamicOneboxComponent, DsDynamicScrollableDropdownComponent, ThemedTypeBadgeComponent], imports: [
DsoEditMetadataValueFieldLoaderComponent,
ThemedTypeBadgeComponent,
],
}, },
}) })
.compileComponents(); .compileComponents();
@@ -213,7 +101,6 @@ describe('DsoEditMetadataValueComponent', () => {
fixture = TestBed.createComponent(DsoEditMetadataValueComponent); fixture = TestBed.createComponent(DsoEditMetadataValueComponent);
component = fixture.componentInstance; component = fixture.componentInstance;
component.mdValue = editMetadataValue; component.mdValue = editMetadataValue;
component.dso = dso;
component.saving$ = of(false); component.saving$ = of(false);
fixture.detectChanges(); fixture.detectChanges();
}); });
@@ -299,219 +186,6 @@ describe('DsoEditMetadataValueComponent', () => {
assertButton(DRAG_BTN, true, false); 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 { function assertButton(name: string, exists: boolean, disabled: boolean = false): void {
describe(`${name} button`, () => { describe(`${name} button`, () => {
let btn: DebugElement; let btn: DebugElement;

View File

@@ -7,7 +7,6 @@ import {
NgClass, NgClass,
} from '@angular/common'; } from '@angular/common';
import { import {
ChangeDetectorRef,
Component, Component,
EventEmitter, EventEmitter,
Input, Input,
@@ -16,96 +15,67 @@ import {
Output, Output,
SimpleChanges, SimpleChanges,
} from '@angular/core'; } from '@angular/core';
import { import { FormsModule } from '@angular/forms';
FormsModule,
UntypedFormControl,
UntypedFormGroup,
} from '@angular/forms';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { TranslateModule } from '@ngx-translate/core';
import { import {
TranslateModule,
TranslateService,
} from '@ngx-translate/core';
import {
BehaviorSubject,
EMPTY, EMPTY,
Observable, Observable,
of as observableOf,
} from 'rxjs'; } from 'rxjs';
import { import { map } from 'rxjs/operators';
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 { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; 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 { RelationshipDataService } from '../../../core/data/relationship-data.service';
import { MetadataService } from '../../../core/metadata/metadata.service'; import { MetadataService } from '../../../core/metadata/metadata.service';
import { Collection } from '../../../core/shared/collection.model';
import { ConfidenceType } from '../../../core/shared/confidence-type'; import { ConfidenceType } from '../../../core/shared/confidence-type';
import { Context } from '../../../core/shared/context.model';
import { DSpaceObject } from '../../../core/shared/dspace-object.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 { ItemMetadataRepresentation } from '../../../core/shared/metadata-representation/item/item-metadata-representation.model';
import { import {
MetadataRepresentation, MetadataRepresentation,
MetadataRepresentationType, MetadataRepresentationType,
} from '../../../core/shared/metadata-representation/metadata-representation.model'; } 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 { 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 { getItemPageRoute } from '../../../item-page/item-page-routing-paths';
import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive';
import { isNotEmpty } from '../../../shared/empty.util'; import { hasValue } 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 { AuthorityConfidenceStateDirective } from '../../../shared/form/directives/authority-confidence-state.directive';
import { ThemedTypeBadgeComponent } from '../../../shared/object-collection/shared/badges/type-badge/themed-type-badge.component'; import { ThemedTypeBadgeComponent } from '../../../shared/object-collection/shared/badges/type-badge/themed-type-badge.component';
import { DebounceDirective } from '../../../shared/utils/debounce.directive'; import { DebounceDirective } from '../../../shared/utils/debounce.directive';
import { followLink } from '../../../shared/utils/follow-link-config.model';
import { VarDirective } from '../../../shared/utils/var.directive';
import { import {
DsoEditMetadataChangeType, DsoEditMetadataChangeType,
DsoEditMetadataValue, DsoEditMetadataValue,
} from '../dso-edit-metadata-form'; } 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({ @Component({
selector: 'ds-dso-edit-metadata-value', selector: 'ds-dso-edit-metadata-value',
styleUrls: ['./dso-edit-metadata-value.component.scss', '../dso-edit-metadata-shared/dso-edit-metadata-cells.scss'], styleUrls: ['./dso-edit-metadata-value.component.scss', '../dso-edit-metadata-shared/dso-edit-metadata-cells.scss'],
templateUrl: './dso-edit-metadata-value.component.html', templateUrl: './dso-edit-metadata-value.component.html',
standalone: true, 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 * Component displaying a single editable row for a metadata value
*/ */
export class DsoEditMetadataValueComponent implements OnInit, OnChanges { export class DsoEditMetadataValueComponent implements OnInit, OnChanges {
@Input() context: Context;
/** /**
* The parent {@link DSpaceObject} to display a metadata form for * The parent {@link DSpaceObject} to display a metadata form for
* Also used to determine metadata-representations in case of virtual metadata * Also used to determine metadata-representations in case of virtual metadata
*/ */
@Input() dso: DSpaceObject; @Input() dso: DSpaceObject;
/**
* The metadata field that is being edited
*/
@Input() mdField: string;
/** /**
* Editable metadata value to show * Editable metadata value to show
*/ */
@@ -129,11 +99,6 @@ export class DsoEditMetadataValueComponent implements OnInit, OnChanges {
*/ */
@Input() isOnlyValue = false; @Input() isOnlyValue = false;
/**
* MetadataField to edit
*/
@Input() mdField?: string;
/** /**
* Emits when the user clicked edit * Emits when the user clicked edit
*/ */
@@ -165,12 +130,6 @@ export class DsoEditMetadataValueComponent implements OnInit, OnChanges {
*/ */
public DsoEditMetadataChangeTypeEnum = DsoEditMetadataChangeType; 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) * 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<string | null>; mdRepresentationName$: Observable<string | null>;
/** /**
* Whether or not the authority field is currently being edited * The type of edit field that should be displayed
*/ */
public editingAuthority = false; fieldType$: Observable<EditMetadataValueFieldType>;
readonly ConfidenceTypeEnum = ConfidenceType;
/**
* 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<DynamicOneboxModel | DynamicScrollableDropdownModel> = new BehaviorSubject(null);
/**
* Observable with information about the authority vocabulary used
*/
private vocabulary$: Observable<Vocabulary>;
/**
* Observables with information about the authority vocabulary type used
*/
private isAuthorityControlled$: Observable<boolean>;
private isHierarchicalVocabulary$: Observable<boolean>;
private isScrollableVocabulary$: Observable<boolean>;
private isSuggesterVocabulary$: Observable<boolean>;
constructor( constructor(
protected relationshipService: RelationshipDataService, protected relationshipService: RelationshipDataService,
protected dsoNameService: DSONameService, 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 metadataService: MetadataService,
protected dsoEditMetadataFieldService: DsoEditMetadataFieldService,
) { ) {
} }
ngOnInit(): void { ngOnInit(): void {
this.initVirtualProperties(); 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 { getFieldType(): Observable<EditMetadataValueFieldType> {
return this.dsoEditMetadataFieldService.findDsoFieldVocabulary(this.dso, this.mdField).pipe(
if (isNotEmpty(this.mdField)) { map((vocabulary: Vocabulary) => {
if (hasValue(vocabulary)) {
const owningCollection$: Observable<Collection> = this.itemService.findByHref(this.dso._links.self.href, true, true, followLink('owningCollection')) return EditMetadataValueFieldType.AUTHORITY;
.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;
} }
} else { if (this.mdField === 'dspace.entity.type') {
formFieldValue = this.mdValue.newValue.value; return EditMetadataValueFieldType.ENTITY_TYPE;
}
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<boolean> {
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];
} }
return EditMetadataValueFieldType.PLAIN_TEXT;
}), }),
); );
} }
/**
* Checks if this field use a authority vocabulary
*/
isAuthorityControlled(): Observable<boolean> {
return this.isAuthorityControlled$;
}
/**
* Checks if configured vocabulary is Hierarchical or not
*/
isHierarchicalVocabulary(): Observable<boolean> {
return this.isHierarchicalVocabulary$;
}
/**
* Checks if configured vocabulary is Scrollable or not
*/
isScrollableVocabulary(): Observable<boolean> {
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<boolean> {
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;
}
} }

View File

@@ -2,31 +2,31 @@
<div class="item-metadata"> <div class="item-metadata">
<div class="button-row top d-flex my-2 space-children-mr ms-gap"> <div class="button-row top d-flex my-2 space-children-mr ms-gap">
<button class="me-auto btn btn-success" id="dso-add-btn" [dsBtnDisabled]="form.newValue || (saving$ | async)" <button class="me-auto btn btn-success" id="dso-add-btn" [dsBtnDisabled]="form.newValue || (saving$ | async)"
[attr.aria-label]="dsoType + '.edit.metadata.add-button' | translate" [attr.aria-label]="dsoType + '.edit.metadata.add-button' | translate"
[title]="dsoType + '.edit.metadata.add-button' | translate" [title]="dsoType + '.edit.metadata.add-button' | translate"
(click)="add()"><i class="fas fa-plus" aria-hidden="true"></i> (click)="add()"><i class="fas fa-plus" aria-hidden="true"></i>
<span class="d-none d-sm-inline">&nbsp;{{ dsoType + '.edit.metadata.add-button' | translate }}</span> <span class="d-none d-sm-inline">&nbsp;{{ dsoType + '.edit.metadata.add-button' | translate }}</span>
</button> </button>
@if (isReinstatable) { @if (isReinstatable) {
<button class="btn btn-warning ms-1" id="dso-reinstate-btn" [dsBtnDisabled]="(saving$ | async)" <button class="btn btn-warning ms-1" id="dso-reinstate-btn" [dsBtnDisabled]="(saving$ | async)"
[attr.aria-label]="dsoType + '.edit.metadata.reinstate-button' | translate" [attr.aria-label]="dsoType + '.edit.metadata.reinstate-button' | translate"
[title]="dsoType + '.edit.metadata.reinstate-button' | translate" [title]="dsoType + '.edit.metadata.reinstate-button' | translate"
(click)="reinstate()"><i class="fas fa-undo-alt" aria-hidden="true"></i> (click)="reinstate()"><i class="fas fa-undo-alt" aria-hidden="true"></i>
<span class="d-none d-sm-inline">&nbsp;{{ dsoType + '.edit.metadata.reinstate-button' | translate }}</span> <span class="d-none d-sm-inline">&nbsp;{{ dsoType + '.edit.metadata.reinstate-button' | translate }}</span>
</button> </button>
} }
<button class="btn btn-primary ms-1" id="dso-save-btn" [dsBtnDisabled]="!hasChanges || (saving$ | async)" <button class="btn btn-primary ms-1" id="dso-save-btn" [dsBtnDisabled]="!hasChanges || (saving$ | async)"
[attr.aria-label]="dsoType + '.edit.metadata.save-button' | translate" [attr.aria-label]="dsoType + '.edit.metadata.save-button' | translate"
[title]="dsoType + '.edit.metadata.save-button' | translate" [title]="dsoType + '.edit.metadata.save-button' | translate"
(click)="submit()"><i class="fas fa-save" aria-hidden="true"></i> (click)="submit()"><i class="fas fa-save" aria-hidden="true"></i>
<span class="d-none d-sm-inline">&nbsp;{{ dsoType + '.edit.metadata.save-button' | translate }}</span> <span class="d-none d-sm-inline">&nbsp;{{ dsoType + '.edit.metadata.save-button' | translate }}</span>
</button> </button>
@if (!isReinstatable) { @if (!isReinstatable) {
<button class="btn btn-danger ms-1" id="dso-discard-btn" <button class="btn btn-danger ms-1" id="dso-discard-btn"
[attr.aria-label]="dsoType + '.edit.metadata.discard-button' | translate" [attr.aria-label]="dsoType + '.edit.metadata.discard-button' | translate"
[title]="dsoType + '.edit.metadata.discard-button' | translate" [title]="dsoType + '.edit.metadata.discard-button' | translate"
[dsBtnDisabled]="!hasChanges || (saving$ | async)" [dsBtnDisabled]="!hasChanges || (saving$ | async)"
(click)="discard()"><i class="fas fa-times" aria-hidden="true"></i> (click)="discard()"><i class="fas fa-times" aria-hidden="true"></i>
<span class="d-none d-sm-inline">&nbsp;{{ dsoType + '.edit.metadata.discard-button' | translate }}</span> <span class="d-none d-sm-inline">&nbsp;{{ dsoType + '.edit.metadata.discard-button' | translate }}</span>
</button> </button>
} }
@@ -37,22 +37,24 @@
<div class="d-flex flex-row ds-field-row" role="row"> <div class="d-flex flex-row ds-field-row" role="row">
<div class="lbl-cell ds-success" role="rowheader"> <div class="lbl-cell ds-success" role="rowheader">
<ds-metadata-field-selector [dsoType]="dsoType" <ds-metadata-field-selector [dsoType]="dsoType"
[(mdField)]="newMdField" [(mdField)]="newMdField"
[autofocus]="true"> [autofocus]="true">
</ds-metadata-field-selector> </ds-metadata-field-selector>
</div> </div>
<div class="flex-grow-1 ds-drop-list" role="cell"> <div class="flex-grow-1 ds-drop-list" role="cell">
<div role="table"> <div role="table">
<ds-dso-edit-metadata-value-headers role="presentation" [dsoType]="dsoType"></ds-dso-edit-metadata-value-headers> <ds-dso-edit-metadata-value-headers role="presentation"
[dsoType]="dsoType"></ds-dso-edit-metadata-value-headers>
<ds-dso-edit-metadata-value [dso]="dso" <ds-dso-edit-metadata-value [dso]="dso"
[mdValue]="form.newValue" [context]="Context.AddMetadata"
[dsoType]="dsoType" [mdValue]="form.newValue"
[saving$]="savingOrLoadingFieldValidation$" [dsoType]="dsoType"
[isOnlyValue]="true" [saving$]="savingOrLoadingFieldValidation$"
[mdField]="newMdField" [isOnlyValue]="true"
(confirm)="confirmNewValue($event)" [mdField]="newMdField"
(remove)="form.newValue = undefined" (confirm)="confirmNewValue($event)"
(undo)="form.newValue = undefined"> (remove)="form.newValue = undefined"
(undo)="form.newValue = undefined">
</ds-dso-edit-metadata-value> </ds-dso-edit-metadata-value>
</div> </div>
</div> </div>
@@ -64,19 +66,19 @@
<span class="dont-break-out preserve-line-breaks">{{ mdField }}</span> <span class="dont-break-out preserve-line-breaks">{{ mdField }}</span>
@if (form.hasOrderChanges(mdField)) { @if (form.hasOrderChanges(mdField)) {
<div class="btn btn-warning reset-order-button mt-2 w-100" <div class="btn btn-warning reset-order-button mt-2 w-100"
(click)="form.resetOrder(mdField); onValueSaved()"> (click)="form.resetOrder(mdField); onValueSaved()">
{{ dsoType + '.edit.metadata.reset-order-button' | translate }} {{ dsoType + '.edit.metadata.reset-order-button' | translate }}
</div> </div>
} }
</div> </div>
<ds-dso-edit-metadata-field-values class="flex-grow-1" role="cell" <ds-dso-edit-metadata-field-values class="flex-grow-1" role="cell"
[dso]="dso" [dso]="dso"
[form]="form" [form]="form"
[dsoType]="dsoType" [dsoType]="dsoType"
[saving$]="saving$" [saving$]="saving$"
[draggingMdField$]="draggingMdField$" [draggingMdField$]="draggingMdField$"
[mdField]="mdField" [mdField]="mdField"
(valueSaved)="onValueSaved()"> (valueSaved)="onValueSaved()">
</ds-dso-edit-metadata-field-values> </ds-dso-edit-metadata-field-values>
</div> </div>
} }
@@ -90,24 +92,25 @@
<div class="mt-2 float-end space-children-mr ms-gap"> <div class="mt-2 float-end space-children-mr ms-gap">
@if (isReinstatable) { @if (isReinstatable) {
<button class="btn btn-warning" [dsBtnDisabled]="(saving$ | async)" <button class="btn btn-warning" [dsBtnDisabled]="(saving$ | async)"
[attr.aria-label]="dsoType + '.edit.metadata.reinstate-button' | translate" [attr.aria-label]="dsoType + '.edit.metadata.reinstate-button' | translate"
[title]="dsoType + '.edit.metadata.reinstate-button' | translate" [title]="dsoType + '.edit.metadata.reinstate-button' | translate"
(click)="reinstate()"> (click)="reinstate()">
<i class="fas fa-undo-alt" aria-hidden="true"></i> {{ dsoType + '.edit.metadata.reinstate-button' | translate }} <i class="fas fa-undo-alt"
aria-hidden="true"></i> {{ dsoType + '.edit.metadata.reinstate-button' | translate }}
</button> </button>
} }
<button class="btn btn-primary" [dsBtnDisabled]="!hasChanges || (saving$ | async)" <button class="btn btn-primary" [dsBtnDisabled]="!hasChanges || (saving$ | async)"
[attr.aria-label]="dsoType + '.edit.metadata.save-button' | translate" [attr.aria-label]="dsoType + '.edit.metadata.save-button' | translate"
[title]="dsoType + '.edit.metadata.save-button' | translate" [title]="dsoType + '.edit.metadata.save-button' | translate"
(click)="submit()"> (click)="submit()">
<i class="fas fa-save" aria-hidden="true"></i> {{ dsoType + '.edit.metadata.save-button' | translate }} <i class="fas fa-save" aria-hidden="true"></i> {{ dsoType + '.edit.metadata.save-button' | translate }}
</button> </button>
@if (!isReinstatable) { @if (!isReinstatable) {
<button class="btn btn-danger" <button class="btn btn-danger"
[attr.aria-label]="dsoType + '.edit.metadata.discard-button' | translate" [attr.aria-label]="dsoType + '.edit.metadata.discard-button' | translate"
[title]="dsoType + '.edit.metadata.discard-button' | translate" [title]="dsoType + '.edit.metadata.discard-button' | translate"
[dsBtnDisabled]="!hasChanges || (saving$ | async)" [dsBtnDisabled]="!hasChanges || (saving$ | async)"
(click)="discard()"> (click)="discard()">
<i class="fas fa-times" aria-hidden="true"></i> {{ dsoType + '.edit.metadata.discard-button' | translate }} <i class="fas fa-times" aria-hidden="true"></i> {{ dsoType + '.edit.metadata.discard-button' | translate }}
</button> </button>
} }

View File

@@ -38,6 +38,7 @@ import { ArrayMoveChangeAnalyzer } from '../../core/data/array-move-change-analy
import { RemoteData } from '../../core/data/remote-data'; import { RemoteData } from '../../core/data/remote-data';
import { UpdateDataService } from '../../core/data/update-data.service'; import { UpdateDataService } from '../../core/data/update-data.service';
import { lazyDataService } from '../../core/lazy-data-service'; import { lazyDataService } from '../../core/lazy-data-service';
import { Context } from '../../core/shared/context.model';
import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { getFirstCompletedRemoteData } from '../../core/shared/operators'; import { getFirstCompletedRemoteData } from '../../core/shared/operators';
import { ResourceType } from '../../core/shared/resource-type'; import { ResourceType } from '../../core/shared/resource-type';
@@ -142,6 +143,8 @@ export class DsoEditMetadataComponent implements OnInit, OnDestroy {
*/ */
dsoUpdateSubscription: Subscription; dsoUpdateSubscription: Subscription;
public readonly Context = Context;
constructor(protected route: ActivatedRoute, constructor(protected route: ActivatedRoute,
protected notificationsService: NotificationsService, protected notificationsService: NotificationsService,
protected translateService: TranslateService, protected translateService: TranslateService,

View File

@@ -0,0 +1,34 @@
import { AuthRegistrationType } from 'src/app/core/auth/models/auth.registration-type';
import { OrcidConfirmationComponent } from '../registration-types/orcid-confirmation/orcid-confirmation.component';
export type ExternalLoginTypeComponent =
typeof OrcidConfirmationComponent;
export const LOGIN_METHOD_FOR_DECORATOR_MAP = new Map<AuthRegistrationType, ExternalLoginTypeComponent>([
[AuthRegistrationType.Orcid, OrcidConfirmationComponent],
]);
/**
* Decorator to register the external login confirmation component for the given auth method type
* @param authMethodType the type of the external login method
*/
export function renderExternalLoginConfirmationFor(
authMethodType: AuthRegistrationType,
) {
return function decorator(objectElement: any) {
if (!objectElement) {
return;
}
LOGIN_METHOD_FOR_DECORATOR_MAP.set(authMethodType, objectElement);
};
}
/**
* Get the external login confirmation component for the given auth method type
* @param authMethodType the type of the external login method
*/
export function getExternalLoginConfirmationType(
authMethodType: AuthRegistrationType,
) {
return LOGIN_METHOD_FOR_DECORATOR_MAP.get(authMethodType);
}

View File

@@ -0,0 +1,20 @@
import { Inject } from '@angular/core';
import { Registration } from '../../core/shared/registration.model';
/**
* This component renders a form to complete the registration process
*/
export abstract class ExternalLoginMethodEntryComponent {
/**
* The registration data object
*/
public registrationData: Registration;
protected constructor(
@Inject('registrationDataProvider') protected injectedRegistrationDataObject: Registration,
) {
this.registrationData = injectedRegistrationDataObject;
}
}

View File

@@ -0,0 +1,37 @@
<h4>
{{ "external-login.confirm-email.header" | translate }}
</h4>
<form [formGroup]="emailForm" (ngSubmit)="submitForm()">
<div class="form-group">
<div class="form-row">
<div class="col-12 my-2">
<input
type="email"
id="email"
formControlName="email"
placeholder="profile.email@example.com"
class="form-control form-control-lg position-relative"
[attr.aria-label]="'external-login.confirmation.email-label' | translate"
/>
@if (emailForm.get('email').hasError('required') && emailForm.get('email').touched) {
<div class="text-danger">
{{ "external-login.confirmation.email-required" | translate }}
</div>
}
@if (emailForm.get('email').hasError('email') && emailForm.get('email').touched) {
<div class="text-danger">
{{ "external-login.confirmation.email-invalid" | translate }}
</div>
}
</div>
</div>
<div class="form-row">
<div class="col-12">
<button type="submit" class="btn btn-lg btn-primary w-100">
{{ "external-login.confirm.button.label" | translate }}
</button>
</div>
</div>
</div>
</form>

View File

@@ -0,0 +1,177 @@
import { CommonModule } from '@angular/common';
import {
EventEmitter,
NO_ERRORS_SCHEMA,
} from '@angular/core';
import {
ComponentFixture,
TestBed,
} from '@angular/core/testing';
import {
FormBuilder,
ReactiveFormsModule,
} from '@angular/forms';
import { By } from '@angular/platform-browser';
import {
TranslateLoader,
TranslateModule,
TranslateService,
} from '@ngx-translate/core';
import { of } from 'rxjs';
import { AuthService } from '../../../core/auth/auth.service';
import { AuthMethodType } from '../../../core/auth/models/auth.method-type';
import { EPersonDataService } from '../../../core/eperson/eperson-data.service';
import { EPerson } from '../../../core/eperson/models/eperson.model';
import { HardRedirectService } from '../../../core/services/hard-redirect.service';
import { NativeWindowService } from '../../../core/services/window.service';
import { Registration } from '../../../core/shared/registration.model';
import {
MockWindow,
NativeWindowMockFactory,
} from '../../../shared/mocks/mock-native-window-ref';
import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { ExternalLoginService } from '../../services/external-login.service';
import { ConfirmEmailComponent } from './confirm-email.component';
describe('ConfirmEmailComponent', () => {
let component: ConfirmEmailComponent;
let fixture: ComponentFixture<ConfirmEmailComponent>;
let externalLoginServiceSpy: jasmine.SpyObj<ExternalLoginService>;
let epersonDataServiceSpy: jasmine.SpyObj<EPersonDataService>;
let notificationServiceSpy: jasmine.SpyObj<NotificationsService>;
let authServiceSpy: jasmine.SpyObj<AuthService>;
let hardRedirectService: HardRedirectService;
const translateServiceStub = {
get: () => of(''),
onLangChange: new EventEmitter(),
onTranslationChange: new EventEmitter(),
onDefaultLangChange: new EventEmitter(),
};
beforeEach(async () => {
externalLoginServiceSpy = jasmine.createSpyObj('ExternalLoginService', [
'patchUpdateRegistration',
'getExternalAuthLocation',
]);
epersonDataServiceSpy = jasmine.createSpyObj('EPersonDataService', [
'createEPersonForToken',
]);
notificationServiceSpy = jasmine.createSpyObj('NotificationsService', [
'error',
]);
authServiceSpy = jasmine.createSpyObj('AuthService', ['getRedirectUrl', 'setRedirectUrl', 'getExternalServerRedirectUrl']);
hardRedirectService = jasmine.createSpyObj('HardRedirectService', {
redirect: {},
});
await TestBed.configureTestingModule({
providers: [
FormBuilder,
{ provide: NativeWindowService, useFactory: NativeWindowMockFactory },
{ provide: ExternalLoginService, useValue: externalLoginServiceSpy },
{ provide: EPersonDataService, useValue: epersonDataServiceSpy },
{ provide: NotificationsService, useValue: notificationServiceSpy },
{ provide: AuthService, useValue: authServiceSpy },
{ provide: TranslateService, useValue: translateServiceStub },
{ provide: HardRedirectService, useValue: hardRedirectService },
],
imports: [
CommonModule,
ConfirmEmailComponent,
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: TranslateLoaderMock,
},
}),
ReactiveFormsModule,
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(ConfirmEmailComponent);
component = fixture.componentInstance;
component.registrationData = Object.assign(new Registration(), {
id: '123',
email: 'test@example.com',
netId: 'test-netid',
registrationMetadata: {},
registrationType: AuthMethodType.Orcid,
});
component.token = 'test-token';
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should show email from registration data', () => {
fixture.detectChanges();
const emailInput = fixture.debugElement.query(By.css('input[id=email]'));
expect(emailInput).toBeTruthy();
expect(emailInput.nativeElement.value).toBe('test@example.com');
});
describe('submitForm', () => {
it('should call postCreateAccountFromToken if email is confirmed', () => {
component.emailForm.setValue({ email: 'test@example.com' });
spyOn(component as any, 'postCreateAccountFromToken');
component.submitForm();
expect(
(component as any).postCreateAccountFromToken,
).toHaveBeenCalledWith('test-token', component.registrationData);
});
it('should call patchUpdateRegistration if email is not confirmed', () => {
component.emailForm.setValue({ email: 'new-email@example.com' });
spyOn(component as any, 'patchUpdateRegistration');
component.submitForm();
expect((component as any).patchUpdateRegistration).toHaveBeenCalledWith([
'new-email@example.com',
]);
});
it('should not call any methods if form is invalid', () => {
component.emailForm.setValue({ email: 'invalid-email' });
spyOn(component as any, 'postCreateAccountFromToken');
spyOn(component as any, 'patchUpdateRegistration');
component.submitForm();
expect(
(component as any).postCreateAccountFromToken,
).not.toHaveBeenCalled();
expect((component as any).patchUpdateRegistration).not.toHaveBeenCalled();
});
});
describe('postCreateAccountFromToken', () => {
it('should call NotificationsService.error if the registration data does not have a netId', () => {
component.registrationData.netId = undefined;
(component as any).postCreateAccountFromToken('test-token', component.registrationData);
expect(notificationServiceSpy.error).toHaveBeenCalled();
});
it('should call EPersonDataService.createEPersonForToken and ExternalLoginService.getExternalAuthLocation if the registration data has a netId', () => {
externalLoginServiceSpy.getExternalAuthLocation.and.returnValue(of('test-location'));
authServiceSpy.getRedirectUrl.and.returnValue(of('/test-redirect'));
authServiceSpy.getExternalServerRedirectUrl.and.returnValue('test-external-url');
epersonDataServiceSpy.createEPersonForToken.and.returnValue(createSuccessfulRemoteDataObject$(new EPerson()));
(component as any).postCreateAccountFromToken('test-token', component.registrationData);
expect(epersonDataServiceSpy.createEPersonForToken).toHaveBeenCalled();
expect(externalLoginServiceSpy.getExternalAuthLocation).toHaveBeenCalledWith(AuthMethodType.Orcid);
expect(authServiceSpy.getRedirectUrl).toHaveBeenCalled();
expect(authServiceSpy.setRedirectUrl).toHaveBeenCalledWith('/profile');
expect(authServiceSpy.getExternalServerRedirectUrl).toHaveBeenCalledWith(MockWindow.origin,'/test-redirect', 'test-location');
expect(hardRedirectService.redirect).toHaveBeenCalledWith('test-external-url');
});
});
afterEach(() => {
fixture.destroy();
});
});

View File

@@ -0,0 +1,197 @@
import {
ChangeDetectionStrategy,
Component,
Inject,
Input,
OnDestroy,
OnInit,
} from '@angular/core';
import {
FormBuilder,
FormGroup,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import {
TranslateModule,
TranslateService,
} from '@ngx-translate/core';
import isEqual from 'lodash/isEqual';
import {
combineLatest,
Subscription,
take,
} from 'rxjs';
import { AuthService } from '../../../core/auth/auth.service';
import { EPersonDataService } from '../../../core/eperson/eperson-data.service';
import { EPerson } from '../../../core/eperson/models/eperson.model';
import { HardRedirectService } from '../../../core/services/hard-redirect.service';
import {
NativeWindowRef,
NativeWindowService,
} from '../../../core/services/window.service';
import {
getFirstCompletedRemoteData,
getRemoteDataPayload,
} from '../../../core/shared/operators';
import { Registration } from '../../../core/shared/registration.model';
import {
hasNoValue,
hasValue,
} from '../../../shared/empty.util';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { ExternalLoginService } from '../../services/external-login.service';
@Component({
selector: 'ds-confirm-email',
templateUrl: './confirm-email.component.html',
styleUrls: ['./confirm-email.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
TranslateModule,
ReactiveFormsModule,
],
})
/**
* Email confirmation component that will check for user email confirmation after account creation.
*/
export class ConfirmEmailComponent implements OnInit, OnDestroy {
/**
* The form containing the email input
*/
emailForm: FormGroup;
/**
* The registration data object
*/
@Input() registrationData: Registration;
/**
* The token to be used to confirm the registration
*/
@Input() token: string;
/**
* The subscriptions to unsubscribe from
*/
subs: Subscription[] = [];
externalLocation: string;
constructor(
@Inject(NativeWindowService) protected _window: NativeWindowRef,
private formBuilder: FormBuilder,
private externalLoginService: ExternalLoginService,
private epersonDataService: EPersonDataService,
private notificationService: NotificationsService,
private translate: TranslateService,
private authService: AuthService,
private hardRedirectService: HardRedirectService,
) {
}
ngOnInit() {
this.emailForm = this.formBuilder.group({
email: [this.registrationData.email, [Validators.required, Validators.email]],
});
}
/**
* Submits the email form and performs appropriate actions based on the form's validity and user input.
* If the form is valid and the confirmed email matches the registration email, calls the postCreateAccountFromToken method with the token and registration data.
* If the form is valid but the confirmed email does not match the registration email, calls the patchUpdateRegistration method with the confirmed email.
*/
submitForm() {
this.emailForm.markAllAsTouched();
if (this.emailForm.valid) {
const confirmedEmail = this.emailForm.get('email').value;
if (confirmedEmail && isEqual(this.registrationData.email, confirmedEmail.trim())) {
this.postCreateAccountFromToken(this.token, this.registrationData);
} else {
this.patchUpdateRegistration([confirmedEmail]);
}
}
}
/**
* Sends a PATCH request to update the user's registration with the given values.
* @param values - The values to update the user's registration with.
* @returns An Observable that emits the updated registration data.
*/
private patchUpdateRegistration(values: string[]) {
this.subs.push(
this.externalLoginService.patchUpdateRegistration(values, 'email', this.registrationData.id, this.token, 'replace')
.pipe(getRemoteDataPayload())
.subscribe());
}
/**
* Creates a new user from a given token and registration data.
* Based on the registration data, the user will be created with the following properties:
* - email: the email address from the registration data
* - metadata: all metadata values from the registration data, except for the email metadata key (ePerson object does not have an email metadata field)
* - canLogIn: true
* - requireCertificate: false
* @param token The token used to create the user.
* @param registrationData The registration data used to create the user.
* @returns An Observable that emits a boolean indicating whether the user creation was successful.
*/
private postCreateAccountFromToken(
token: string,
registrationData: Registration,
) {
// check if the netId is present
// in order to create an account, the netId is required (since the user is created without a password)
if (hasNoValue(this.registrationData.netId)) {
this.notificationService.error(this.translate.get('external-login-page.confirm-email.create-account.notifications.error.no-netId'));
return;
}
const metadataValues = {};
for (const [key, value] of Object.entries(registrationData.registrationMetadata)) {
// exclude the email metadata key, since the ePerson object does not have an email metadata field
if (hasValue(value[0]?.value) && key !== 'email') {
metadataValues[key] = value;
}
}
const eperson = new EPerson();
eperson.email = registrationData.email;
eperson.netid = registrationData.netId;
eperson.metadata = metadataValues;
eperson.canLogIn = true;
eperson.requireCertificate = false;
eperson.selfRegistered = true;
this.subs.push(
combineLatest([
this.epersonDataService.createEPersonForToken(eperson, token).pipe(
getFirstCompletedRemoteData(),
),
this.externalLoginService.getExternalAuthLocation(this.registrationData.registrationType),
this.authService.getRedirectUrl().pipe(take(1)),
])
.subscribe(([rd, location, redirectRoute]) => {
if (rd.hasFailed) {
this.notificationService.error(
this.translate.get('external-login-page.provide-email.create-account.notifications.error.header'),
this.translate.get('external-login-page.provide-email.create-account.notifications.error.content'),
);
} else if (rd.hasSucceeded) {
// set Redirect URL to User profile, so the user is redirected to the profile page after logging in
this.authService.setRedirectUrl('/profile');
const externalServerUrl = this.authService.getExternalServerRedirectUrl(
this._window.nativeWindow.origin,
redirectRoute,
location,
);
// redirect to external registration type authentication url
this.hardRedirectService.redirect(externalServerUrl);
}
}),
);
}
ngOnDestroy(): void {
this.subs.filter(sub => hasValue(sub)).forEach((sub) => sub.unsubscribe());
}
}

View File

@@ -0,0 +1,5 @@
<h4>
{{ "external-login.confirm-email-sent.header" | translate }}
</h4>
<p class="mt-4" [innerHTML]="'external-login.confirm-email-sent.info' | translate"></p>

View File

@@ -0,0 +1,73 @@
import { CommonModule } from '@angular/common';
import {
CUSTOM_ELEMENTS_SCHEMA,
EventEmitter,
} from '@angular/core';
import {
ComponentFixture,
TestBed,
} from '@angular/core/testing';
import {
TranslateLoader,
TranslateModule,
TranslateService,
} from '@ngx-translate/core';
import { of } from 'rxjs';
import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock';
import { ConfirmationSentComponent } from './confirmation-sent.component';
describe('ConfirmationSentComponent', () => {
let component: ConfirmationSentComponent;
let fixture: ComponentFixture<ConfirmationSentComponent>;
let compiledTemplate: HTMLElement;
const translateServiceStub = {
get: () => of('Mocked Translation Text'),
instant: (key: any) => 'Mocked Translation Text',
onLangChange: new EventEmitter(),
onTranslationChange: new EventEmitter(),
onDefaultLangChange: new EventEmitter(),
};
beforeEach(async () => {
await TestBed.configureTestingModule({
providers: [
{ provide: TranslateService, useValue: translateServiceStub },
],
imports: [
CommonModule,
ConfirmationSentComponent,
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: TranslateLoaderMock,
},
}),
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(ConfirmationSentComponent);
component = fixture.componentInstance;
compiledTemplate = fixture.nativeElement;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should render translated header', () => {
const headerElement = compiledTemplate.querySelector('h4');
expect(headerElement.textContent).toContain('Mocked Translation Text');
});
it('should render translated info paragraph', () => {
const infoParagraphElement = compiledTemplate.querySelector('p');
expect(infoParagraphElement.innerHTML).toBeTruthy();
});
});

View File

@@ -0,0 +1,19 @@
import {
ChangeDetectionStrategy,
Component,
} from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
@Component({
selector: 'ds-confirmation-sent',
templateUrl: './confirmation-sent.component.html',
styleUrls: ['./confirmation-sent.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TranslateModule],
standalone: true,
})
/**
* Simple component that shows up a confirmation to the user.
*/
export class ConfirmationSentComponent { }

View File

@@ -0,0 +1,37 @@
<h4>
{{ "external-login.provide-email.header" | translate }}
</h4>
<form [formGroup]="emailForm" (ngSubmit)="submitForm()">
<div class="form-group">
<div class="form-row">
<div class="col-12 my-2">
<input
type="email"
id="email"
formControlName="email"
class="form-control form-control-lg position-relative"
[attr.aria-label]="'external-login.confirmation.email' | translate"
/>
@if (emailForm.get('email').hasError('required') && emailForm.get('email').touched) {
<div class="text-danger">
{{ "external-login.confirmation.email-required" | translate }}
</div>
}
@if (emailForm.get('email').hasError('email') && emailForm.get('email').touched) {
<div class="text-danger">
{{ "external-login.confirmation.email-invalid" | translate }}
</div>
}
</div>
</div>
<div class="form-row">
<div class="col-12">
<button type="submit" class="btn btn-lg btn-primary w-100">
{{ "external-login.provide-email.button.label" | translate }}
</button>
</div>
</div>
</div>
</form>

View File

@@ -0,0 +1,68 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import {
ComponentFixture,
TestBed,
} from '@angular/core/testing';
import { FormBuilder } from '@angular/forms';
import {
TranslateLoader,
TranslateModule,
} from '@ngx-translate/core';
import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock';
import { ExternalLoginService } from '../../services/external-login.service';
import { ProvideEmailComponent } from './provide-email.component';
describe('ProvideEmailComponent', () => {
let component: ProvideEmailComponent;
let fixture: ComponentFixture<ProvideEmailComponent>;
let externalLoginServiceSpy: jasmine.SpyObj<ExternalLoginService>;
beforeEach(async () => {
const externalLoginService = jasmine.createSpyObj('ExternalLoginService', ['patchUpdateRegistration']);
await TestBed.configureTestingModule({
providers: [
FormBuilder,
{ provide: ExternalLoginService, useValue: externalLoginService },
],
imports: [
CommonModule,
ProvideEmailComponent,
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: TranslateLoaderMock,
},
}),
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(ProvideEmailComponent);
component = fixture.componentInstance;
externalLoginServiceSpy = TestBed.inject(ExternalLoginService) as jasmine.SpyObj<ExternalLoginService>;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should call externalLoginService.patchUpdateRegistration when form is submitted with valid email', () => {
const email = 'test@example.com';
component.emailForm.setValue({ email });
component.registrationId = '123';
component.token = '456';
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('button[type="submit"]');
button.click();
expect(externalLoginServiceSpy.patchUpdateRegistration).toHaveBeenCalledWith([email], 'email', component.registrationId, component.token, 'add');
});
});

View File

@@ -0,0 +1,76 @@
import {
ChangeDetectionStrategy,
Component,
Input,
OnDestroy,
} from '@angular/core';
import {
FormBuilder,
FormGroup,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { TranslateModule } from '@ngx-translate/core';
import { Subscription } from 'rxjs';
import { hasValue } from '../../../shared/empty.util';
import { ExternalLoginService } from '../../services/external-login.service';
@Component({
selector: 'ds-provide-email',
templateUrl: './provide-email.component.html',
styleUrls: ['./provide-email.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
TranslateModule,
ReactiveFormsModule,
],
standalone: true,
})
/**
* This component provides a proper field to submit the email that will be updated for its registration token
*/
export class ProvideEmailComponent implements OnDestroy {
/**
* The form group for the email input
*/
emailForm: FormGroup;
/**
* The registration id
*/
@Input() registrationId: string;
/**
* The token from the URL
*/
@Input() token: string;
/**
* The subscriptions to unsubscribe from
*/
subs: Subscription[] = [];
constructor(
private formBuilder: FormBuilder,
private externalLoginService: ExternalLoginService,
) {
this.emailForm = this.formBuilder.group({
email: ['', [Validators.required, Validators.email]],
});
}
/**
* Updates the user's email in the registration data.
* @returns void
*/
submitForm() {
this.emailForm.markAllAsTouched();
if (this.emailForm.valid) {
const email = this.emailForm.get('email').value;
this.subs.push(this.externalLoginService.patchUpdateRegistration([email], 'email', this.registrationId, this.token, 'add')
.subscribe());
}
}
ngOnDestroy(): void {
this.subs.filter(sub => hasValue(sub)).forEach((sub) => sub.unsubscribe());
}
}

View File

@@ -0,0 +1,52 @@
<div class="row">
<h4>{{ 'external-login.confirmation.header' | translate }}</h4>
</div>
<div class="row justify-content-center">
<ng-container *ngComponentOutlet="getExternalLoginConfirmationType(); injector: objectInjector;">
</ng-container>
</div>
<ds-alert class="container mt-2" [type]="AlertTypeEnum.Info" [attr.data-test]="'info-text'">
{{ informationText }}
</ds-alert>
<div class="row d-flex justify-content-center">
<div class="col-6 d-flex">
<div class="col d-flex justify-content-center align-items-center">
@if (registrationData.email) {
<ds-confirm-email [registrationData]="registrationData" [token]="token"></ds-confirm-email>
} @else {
<ds-provide-email [registrationId]="registrationData.id" [token]="token"></ds-provide-email>
}
</div>
@if (hasAuthMethodTypes | async) {
<div class="col-1 d-flex justify-content-center align-items-center">
<h4 class="mt-2">{{ 'external-login.component.or' | translate }}</h4>
</div>
<div class="col d-flex justify-content-center align-items-center">
<button data-test="open-modal" class="btn block btn-lg btn-primary" (click)="openLoginModal(loginModal)">
{{ 'external-login.connect-to-existing-account.label' | translate }}
</button>
</div>
}
</div>
</div>
<ng-template #loginModal let-c="close" let-d="dismiss">
<div class="modal-header">
<h4 class="modal-title text-info"> {{ 'external-login.connect-to-existing-account.label' | translate }}</h4>
</div>
<div class="modal-body">
<div class="row justify-content-center">
<ds-log-in
[excludedAuthMethod]="relatedAuthMethod"
[showRegisterLink]="false"
[isStandalonePage]="true"
></ds-log-in>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-primary btn-sm" (click)="c('Close click');clearRedirectUrl()">
<i class="fa fa-times" aria-hidden="true"></i>
{{ 'external-login.modal.label.close' | translate }}
</button>
</div>
</ng-template>

Some files were not shown because too many files have changed in this diff Show More