[DSC-389] merged with main branch

This commit is contained in:
Pratik Rajkotiya
2022-02-02 19:11:06 +05:30
parent e4d099df43
commit 57ff37ec7f
78 changed files with 9098 additions and 291 deletions

View File

@@ -127,6 +127,9 @@ languages:
- code: fr
label: Français
active: true
- code: gd
label: Gàidhlig
active: true
- code: lv
label: Latviešu
active: true

View File

@@ -32,6 +32,12 @@ export function getBitstreamRequestACopyRoute(item, bitstream): { routerLink: st
};
}
export const HOME_PAGE_PATH = 'admin';
export function getHomePageRoute() {
return `/${HOME_PAGE_PATH}`;
}
export const ADMIN_MODULE_PATH = 'admin';
export function getAdminModuleRoute() {

View File

@@ -12,7 +12,7 @@
</div>
</div>
</div>
<ds-form [formId]="'edit-bitstream-form-id'"
<ds-form *ngIf="formGroup" [formId]="'edit-bitstream-form-id'"
[formGroup]="formGroup"
[formModel]="formModel"
[formLayout]="formLayout"

View File

@@ -6,3 +6,7 @@
}
}
}
:host ::ng-deep ds-dynamic-form-control-container > div > label {
margin-top: 1.75rem;
}

View File

@@ -22,6 +22,8 @@ import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } f
import { getEntityEditRoute } from '../../item-page/item-page-routing-paths';
import { createPaginatedList } from '../../shared/testing/utils.test';
import { Item } from '../../core/shared/item.model';
import { MetadataValueFilter } from '../../core/shared/metadata.models';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
const infoNotification: INotification = new Notification('id', NotificationType.Info, 'info');
const warningNotification: INotification = new Notification('id', NotificationType.Warning, 'warning');
@@ -31,24 +33,27 @@ let notificationsService: NotificationsService;
let formService: DynamicFormService;
let bitstreamService: BitstreamDataService;
let bitstreamFormatService: BitstreamFormatDataService;
let dsoNameService: DSONameService;
let bitstream: Bitstream;
let selectedFormat: BitstreamFormat;
let allFormats: BitstreamFormat[];
let router: Router;
describe('EditBitstreamPageComponent', () => {
let comp: EditBitstreamPageComponent;
let fixture: ComponentFixture<EditBitstreamPageComponent>;
let comp: EditBitstreamPageComponent;
let fixture: ComponentFixture<EditBitstreamPageComponent>;
beforeEach(waitForAsync(() => {
describe('EditBitstreamPageComponent', () => {
beforeEach(() => {
allFormats = [
Object.assign({
id: '1',
shortDescription: 'Unknown',
description: 'Unknown format',
supportLevel: BitstreamFormatSupportLevel.Unknown,
mimetype: 'application/octet-stream',
_links: {
self: { href: 'format-selflink-1' }
self: {href: 'format-selflink-1'}
}
}),
Object.assign({
@@ -56,8 +61,9 @@ describe('EditBitstreamPageComponent', () => {
shortDescription: 'PNG',
description: 'Portable Network Graphics',
supportLevel: BitstreamFormatSupportLevel.Known,
mimetype: 'image/png',
_links: {
self: { href: 'format-selflink-2' }
self: {href: 'format-selflink-2'}
}
}),
Object.assign({
@@ -65,19 +71,14 @@ describe('EditBitstreamPageComponent', () => {
shortDescription: 'GIF',
description: 'Graphics Interchange Format',
supportLevel: BitstreamFormatSupportLevel.Known,
mimetype: 'image/gif',
_links: {
self: { href: 'format-selflink-3' }
self: {href: 'format-selflink-3'}
}
})
] as BitstreamFormat[];
selectedFormat = allFormats[1];
notificationsService = jasmine.createSpyObj('notificationsService',
{
info: infoNotification,
warning: warningNotification,
success: successNotification
}
);
formService = Object.assign({
createFormGroup: (fModel: DynamicFormControlModel[]) => {
const controls = {};
@@ -90,156 +91,418 @@ describe('EditBitstreamPageComponent', () => {
return undefined;
}
});
bitstream = Object.assign(new Bitstream(), {
metadata: {
'dc.description': [
{
value: 'Bitstream description'
}
],
'dc.title': [
{
value: 'Bitstream title'
}
]
},
format: createSuccessfulRemoteDataObject$(selectedFormat),
_links: {
self: 'bitstream-selflink'
},
bundle: createSuccessfulRemoteDataObject$({
item: createSuccessfulRemoteDataObject$(Object.assign(new Item(), {
uuid: 'some-uuid'
}))
})
});
bitstreamService = jasmine.createSpyObj('bitstreamService', {
findById: createSuccessfulRemoteDataObject$(bitstream),
update: createSuccessfulRemoteDataObject$(bitstream),
updateFormat: createSuccessfulRemoteDataObject$(bitstream),
commitUpdates: {},
patch: {}
});
bitstreamFormatService = jasmine.createSpyObj('bitstreamFormatService', {
findAll: createSuccessfulRemoteDataObject$(createPaginatedList(allFormats))
});
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), RouterTestingModule],
declarations: [EditBitstreamPageComponent, FileSizePipe, VarDirective],
providers: [
{ provide: NotificationsService, useValue: notificationsService },
{ provide: DynamicFormService, useValue: formService },
{ provide: ActivatedRoute, useValue: { data: observableOf({ bitstream: createSuccessfulRemoteDataObject(bitstream) }), snapshot: { queryParams: {} } } },
{ provide: BitstreamDataService, useValue: bitstreamService },
{ provide: BitstreamFormatDataService, useValue: bitstreamFormatService },
ChangeDetectorRef
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(EditBitstreamPageComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
router = TestBed.inject(Router);
spyOn(router, 'navigate');
notificationsService = jasmine.createSpyObj('notificationsService',
{
info: infoNotification,
warning: warningNotification,
success: successNotification
}
);
});
describe('on startup', () => {
let rawForm;
describe('EditBitstreamPageComponent no IIIF fields', () => {
beforeEach(waitForAsync(() => {
const bundleName = 'ORIGINAL';
bitstream = Object.assign(new Bitstream(), {
metadata: {
'dc.description': [
{
value: 'Bitstream description'
}
],
'dc.title': [
{
value: 'Bitstream title'
}
]
},
format: createSuccessfulRemoteDataObject$(selectedFormat),
_links: {
self: 'bitstream-selflink'
},
bundle: createSuccessfulRemoteDataObject$({
item: createSuccessfulRemoteDataObject$(Object.assign(new Item(), {
uuid: 'some-uuid',
firstMetadataValue(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter): string {
return undefined;
},
}))
})
});
bitstreamService = jasmine.createSpyObj('bitstreamService', {
findById: createSuccessfulRemoteDataObject$(bitstream),
update: createSuccessfulRemoteDataObject$(bitstream),
updateFormat: createSuccessfulRemoteDataObject$(bitstream),
commitUpdates: {},
patch: {}
});
bitstreamFormatService = jasmine.createSpyObj('bitstreamFormatService', {
findAll: createSuccessfulRemoteDataObject$(createPaginatedList(allFormats))
});
dsoNameService = jasmine.createSpyObj('dsoNameService', {
getName: bundleName
});
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), RouterTestingModule],
declarations: [EditBitstreamPageComponent, FileSizePipe, VarDirective],
providers: [
{provide: NotificationsService, useValue: notificationsService},
{provide: DynamicFormService, useValue: formService},
{provide: ActivatedRoute,
useValue: {
data: observableOf({bitstream: createSuccessfulRemoteDataObject(bitstream)}),
snapshot: {queryParams: {}}
}
},
{provide: BitstreamDataService, useValue: bitstreamService},
{provide: DSONameService, useValue: dsoNameService},
{provide: BitstreamFormatDataService, useValue: bitstreamFormatService},
ChangeDetectorRef
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
rawForm = comp.formGroup.getRawValue();
fixture = TestBed.createComponent(EditBitstreamPageComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
router = TestBed.inject(Router);
spyOn(router, 'navigate');
});
it('should fill in the bitstream\'s title', () => {
expect(rawForm.fileNamePrimaryContainer.fileName).toEqual(bitstream.name);
});
describe('on startup', () => {
let rawForm;
it('should fill in the bitstream\'s description', () => {
expect(rawForm.descriptionContainer.description).toEqual(bitstream.firstMetadataValue('dc.description'));
});
it('should select the correct format', () => {
expect(rawForm.formatContainer.selectedFormat).toEqual(selectedFormat.id);
});
it('should put the \"New Format\" input on invisible', () => {
expect(comp.formLayout.newFormat.grid.host).toContain('invisible');
});
});
describe('when an unknown format is selected', () => {
beforeEach(() => {
comp.updateNewFormatLayout(allFormats[0].id);
});
it('should remove the invisible class from the \"New Format\" input', () => {
expect(comp.formLayout.newFormat.grid.host).not.toContain('invisible');
});
});
describe('onSubmit', () => {
describe('when selected format hasn\'t changed', () => {
beforeEach(() => {
comp.onSubmit();
rawForm = comp.formGroup.getRawValue();
});
it('should call update', () => {
expect(bitstreamService.update).toHaveBeenCalled();
it('should fill in the bitstream\'s title', () => {
expect(rawForm.fileNamePrimaryContainer.fileName).toEqual(bitstream.name);
});
it('should commit the updates', () => {
expect(bitstreamService.commitUpdates).toHaveBeenCalled();
it('should fill in the bitstream\'s description', () => {
expect(rawForm.descriptionContainer.description).toEqual(bitstream.firstMetadataValue('dc.description'));
});
it('should select the correct format', () => {
expect(rawForm.formatContainer.selectedFormat).toEqual(selectedFormat.id);
});
it('should put the \"New Format\" input on invisible', () => {
expect(comp.formLayout.newFormat.grid.host).toContain('invisible');
});
});
describe('when selected format has changed', () => {
describe('when an unknown format is selected', () => {
beforeEach(() => {
comp.formGroup.patchValue({
formatContainer: {
selectedFormat: allFormats[2].id
}
comp.updateNewFormatLayout(allFormats[0].id);
});
it('should remove the invisible class from the \"New Format\" input', () => {
expect(comp.formLayout.newFormat.grid.host).not.toContain('invisible');
});
});
describe('onSubmit', () => {
describe('when selected format hasn\'t changed', () => {
beforeEach(() => {
comp.onSubmit();
});
it('should call update', () => {
expect(bitstreamService.update).toHaveBeenCalled();
});
it('should commit the updates', () => {
expect(bitstreamService.commitUpdates).toHaveBeenCalled();
});
});
describe('when selected format has changed', () => {
beforeEach(() => {
comp.formGroup.patchValue({
formatContainer: {
selectedFormat: allFormats[2].id
}
});
fixture.detectChanges();
comp.onSubmit();
});
it('should call update', () => {
expect(bitstreamService.update).toHaveBeenCalled();
});
it('should call updateFormat', () => {
expect(bitstreamService.updateFormat).toHaveBeenCalled();
});
it('should commit the updates', () => {
expect(bitstreamService.commitUpdates).toHaveBeenCalled();
});
});
});
describe('when the cancel button is clicked', () => {
it('should call navigateToItemEditBitstreams method', () => {
spyOn(comp, 'navigateToItemEditBitstreams');
comp.onCancel();
expect(comp.navigateToItemEditBitstreams).toHaveBeenCalled();
});
});
describe('when navigateToItemEditBitstreams is called, and the component has an itemId', () => {
it('should redirect to the item edit page on the bitstreams tab with the itemId from the component', () => {
comp.itemId = 'some-uuid1';
comp.navigateToItemEditBitstreams();
expect(router.navigate).toHaveBeenCalledWith([getEntityEditRoute(null, 'some-uuid1'), 'bitstreams']);
});
});
describe('when navigateToItemEditBitstreams is called, and the component does not have an itemId', () => {
it('should redirect to the item edit page on the bitstreams tab with the itemId from the bundle links ', () => {
comp.itemId = undefined;
comp.navigateToItemEditBitstreams();
expect(router.navigate).toHaveBeenCalledWith([getEntityEditRoute(null, 'some-uuid'), 'bitstreams']);
});
});
});
describe('EditBitstreamPageComponent with IIIF fields', () => {
const bundleName = 'ORIGINAL';
beforeEach(waitForAsync(() => {
bitstream = Object.assign(new Bitstream(), {
metadata: {
'dc.description': [
{
value: 'Bitstream description'
}
],
'dc.title': [
{
value: 'Bitstream title'
}
],
'iiif.label': [
{
value: 'chapter one'
}
],
'iiif.toc': [
{
value: 'chapter one'
}
],
'iiif.image.width': [
{
value: '2400'
}
],
'iiif.image.height': [
{
value: '2800'
}
],
},
format: createSuccessfulRemoteDataObject$(allFormats[1]),
_links: {
self: 'bitstream-selflink'
},
bundle: createSuccessfulRemoteDataObject$({
item: createSuccessfulRemoteDataObject$(Object.assign(new Item(), {
uuid: 'some-uuid',
firstMetadataValue(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter): string {
return 'True';
}
}))
})
});
bitstreamService = jasmine.createSpyObj('bitstreamService', {
findById: createSuccessfulRemoteDataObject$(bitstream),
update: createSuccessfulRemoteDataObject$(bitstream),
updateFormat: createSuccessfulRemoteDataObject$(bitstream),
commitUpdates: {},
patch: {}
});
dsoNameService = jasmine.createSpyObj('dsoNameService', {
getName: bundleName
});
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), RouterTestingModule],
declarations: [EditBitstreamPageComponent, FileSizePipe, VarDirective],
providers: [
{provide: NotificationsService, useValue: notificationsService},
{provide: DynamicFormService, useValue: formService},
{
provide: ActivatedRoute,
useValue: {
data: observableOf({bitstream: createSuccessfulRemoteDataObject(bitstream)}),
snapshot: {queryParams: {}}
}
},
{provide: BitstreamDataService, useValue: bitstreamService},
{provide: DSONameService, useValue: dsoNameService},
{provide: BitstreamFormatDataService, useValue: bitstreamFormatService},
ChangeDetectorRef
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(EditBitstreamPageComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
router = TestBed.inject(Router);
spyOn(router, 'navigate');
});
describe('on startup', () => {
let rawForm;
beforeEach(() => {
rawForm = comp.formGroup.getRawValue();
});
it('should set isIIIF to true', () => {
expect(comp.isIIIF).toBeTrue();
});
it('should fill in the iiif label', () => {
expect(rawForm.iiifLabelContainer.iiifLabel).toEqual('chapter one');
});
it('should fill in the iiif toc', () => {
expect(rawForm.iiifTocContainer.iiifToc).toEqual('chapter one');
});
it('should fill in the iiif width', () => {
expect(rawForm.iiifWidthContainer.iiifWidth).toEqual('2400');
});
it('should fill in the iiif height', () => {
expect(rawForm.iiifHeightContainer.iiifHeight).toEqual('2800');
});
});
});
describe('ignore OTHERCONTENT bundle', () => {
const bundleName = 'OTHERCONTENT';
beforeEach(waitForAsync(() => {
bitstream = Object.assign(new Bitstream(), {
metadata: {
'dc.description': [
{
value: 'Bitstream description'
}
],
'dc.title': [
{
value: 'Bitstream title'
}
],
'iiif.label': [
{
value: 'chapter one'
}
],
'iiif.toc': [
{
value: 'chapter one'
}
],
'iiif.image.width': [
{
value: '2400'
}
],
'iiif.image.height': [
{
value: '2800'
}
],
},
format: createSuccessfulRemoteDataObject$(allFormats[2]),
_links: {
self: 'bitstream-selflink'
},
bundle: createSuccessfulRemoteDataObject$({
item: createSuccessfulRemoteDataObject$(Object.assign(new Item(), {
uuid: 'some-uuid',
firstMetadataValue(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter): string {
return 'True';
}
}))
})
});
bitstreamService = jasmine.createSpyObj('bitstreamService', {
findById: createSuccessfulRemoteDataObject$(bitstream),
update: createSuccessfulRemoteDataObject$(bitstream),
updateFormat: createSuccessfulRemoteDataObject$(bitstream),
commitUpdates: {},
patch: {}
});
dsoNameService = jasmine.createSpyObj('dsoNameService', {
getName: bundleName
});
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), RouterTestingModule],
declarations: [EditBitstreamPageComponent, FileSizePipe, VarDirective],
providers: [
{provide: NotificationsService, useValue: notificationsService},
{provide: DynamicFormService, useValue: formService},
{provide: ActivatedRoute,
useValue: {
data: observableOf({bitstream: createSuccessfulRemoteDataObject(bitstream)}),
snapshot: {queryParams: {}}
}
},
{provide: BitstreamDataService, useValue: bitstreamService},
{provide: DSONameService, useValue: dsoNameService},
{provide: BitstreamFormatDataService, useValue: bitstreamFormatService},
ChangeDetectorRef
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(EditBitstreamPageComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
comp.onSubmit();
router = TestBed.inject(Router);
spyOn(router, 'navigate');
});
it('should call update', () => {
expect(bitstreamService.update).toHaveBeenCalled();
});
describe('EditBitstreamPageComponent with IIIF fields', () => {
let rawForm;
it('should call updateFormat', () => {
expect(bitstreamService.updateFormat).toHaveBeenCalled();
});
beforeEach(() => {
rawForm = comp.formGroup.getRawValue();
});
it('should commit the updates', () => {
expect(bitstreamService.commitUpdates).toHaveBeenCalled();
it('should NOT set isIIIF to true', () => {
expect(comp.isIIIF).toBeFalse();
});
it('should put the \"IIIF Label\" input not to be shown', () => {
expect(rawForm.iiifLabelContainer).toBeFalsy();
});
});
});
});
describe('when the cancel button is clicked', () => {
it('should call navigateToItemEditBitstreams method', () => {
spyOn(comp, 'navigateToItemEditBitstreams');
comp.onCancel();
expect(comp.navigateToItemEditBitstreams).toHaveBeenCalled();
});
});
describe('when navigateToItemEditBitstreams is called, and the component has an itemId', () => {
it('should redirect to the item edit page on the bitstreams tab with the itemId from the component', () => {
comp.itemId = 'some-uuid1';
comp.navigateToItemEditBitstreams();
expect(router.navigate).toHaveBeenCalledWith([getEntityEditRoute(null, 'some-uuid1'), 'bitstreams']);
});
});
describe('when navigateToItemEditBitstreams is called, and the component does not have an itemId', () => {
it('should redirect to the item edit page on the bitstreams tab with the itemId from the bundle links ', () => {
comp.itemId = undefined;
comp.navigateToItemEditBitstreams();
expect(router.navigate).toHaveBeenCalledWith([getEntityEditRoute(null, 'some-uuid'), 'bitstreams']);
});
});
});

View File

@@ -1,16 +1,27 @@
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
OnDestroy,
OnInit
} from '@angular/core';
import { Bitstream } from '../../core/shared/bitstream.model';
import { ActivatedRoute, Router } from '@angular/router';
import { map, mergeMap, switchMap } from 'rxjs/operators';
import { combineLatest as observableCombineLatest, Observable, of as observableOf, Subscription } from 'rxjs';
import {
combineLatest,
combineLatest as observableCombineLatest,
Observable,
of as observableOf,
Subscription
} from 'rxjs';
import {
DynamicFormControlModel,
DynamicFormGroupModel,
DynamicFormLayout,
DynamicFormService,
DynamicInputModel,
DynamicSelectModel,
DynamicTextAreaModel
DynamicSelectModel
} from '@ng-dynamic-forms/core';
import { FormGroup } from '@angular/forms';
import { TranslateService } from '@ngx-translate/core';
@@ -28,13 +39,19 @@ import { NotificationsService } from '../../shared/notifications/notifications.s
import { BitstreamFormatDataService } from '../../core/data/bitstream-format-data.service';
import { BitstreamFormat } from '../../core/shared/bitstream-format.model';
import { BitstreamFormatSupportLevel } from '../../core/shared/bitstream-format-support-level';
import { hasValue, isNotEmpty } from '../../shared/empty.util';
import { hasValue, isNotEmpty, isEmpty } from '../../shared/empty.util';
import { Metadata } from '../../core/shared/metadata.utils';
import { Location } from '@angular/common';
import { RemoteData } from '../../core/data/remote-data';
import { PaginatedList } from '../../core/data/paginated-list.model';
import { getEntityEditRoute, getItemEditRoute } from '../../item-page/item-page-routing-paths';
import { Bundle } from '../../core/shared/bundle.model';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { Item } from '../../core/shared/item.model';
import {
DsDynamicInputModel
} from '../../shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model';
import { DsDynamicTextAreaModel } from '../../shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-textarea.model';
@Component({
selector: 'ds-edit-bitstream-page',
@@ -94,6 +111,26 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy {
*/
NOTIFICATIONS_PREFIX = 'bitstream.edit.notifications.';
/**
* IIIF image width metadata key
*/
IMAGE_WIDTH_METADATA = 'iiif.image.width';
/**
* IIIF image height metadata key
*/
IMAGE_HEIGHT_METADATA = 'iiif.image.height';
/**
* IIIF table of contents metadata key
*/
IIIF_TOC_METADATA = 'iiif.toc';
/**
* IIIF label metadata key
*/
IIIF_LABEL_METADATA = 'iiif.label';
/**
* Options for fetching all bitstream formats
*/
@@ -102,7 +139,8 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy {
/**
* The Dynamic Input Model for the file's name
*/
fileNameModel = new DynamicInputModel({
fileNameModel = new DsDynamicInputModel({
hasSelectableMetadata: false, metadataFields: [], repeatable: false, submissionId: '',
id: 'fileName',
name: 'fileName',
required: true,
@@ -118,14 +156,16 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy {
* The Dynamic Switch Model for the file's name
*/
primaryBitstreamModel = new DynamicCustomSwitchModel({
id: 'primaryBitstream',
name: 'primaryBitstream'
});
id: 'primaryBitstream',
name: 'primaryBitstream'
}
);
/**
* The Dynamic TextArea Model for the file's description
*/
descriptionModel = new DynamicTextAreaModel({
descriptionModel = new DsDynamicTextAreaModel({
hasSelectableMetadata: false, metadataFields: [], repeatable: false, submissionId: '',
id: 'description',
name: 'description',
rows: 10
@@ -147,10 +187,87 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy {
name: 'newFormat'
});
/**
* The Dynamic Input Model for the iiif label
*/
iiifLabelModel = new DsDynamicInputModel({
hasSelectableMetadata: false, metadataFields: [], repeatable: false, submissionId: '',
id: 'iiifLabel',
name: 'iiifLabel'
},
{
grid: {
host: 'col col-lg-6 d-inline-block'
}
});
iiifLabelContainer = new DynamicFormGroupModel({
id: 'iiifLabelContainer',
group: [this.iiifLabelModel]
},{
grid: {
host: 'form-row'
}
});
iiifTocModel = new DsDynamicInputModel({
hasSelectableMetadata: false, metadataFields: [], repeatable: false, submissionId: '',
id: 'iiifToc',
name: 'iiifToc',
},{
grid: {
host: 'col col-lg-6 d-inline-block'
}
});
iiifTocContainer = new DynamicFormGroupModel({
id: 'iiifTocContainer',
group: [this.iiifTocModel]
},{
grid: {
host: 'form-row'
}
});
iiifWidthModel = new DsDynamicInputModel({
hasSelectableMetadata: false, metadataFields: [], repeatable: false, submissionId: '',
id: 'iiifWidth',
name: 'iiifWidth',
},{
grid: {
host: 'col col-lg-6 d-inline-block'
}
});
iiifWidthContainer = new DynamicFormGroupModel({
id: 'iiifWidthContainer',
group: [this.iiifWidthModel]
},{
grid: {
host: 'form-row'
}
});
iiifHeightModel = new DsDynamicInputModel({
hasSelectableMetadata: false, metadataFields: [], repeatable: false, submissionId: '',
id: 'iiifHeight',
name: 'iiifHeight'
},{
grid: {
host: 'col col-lg-6 d-inline-block'
}
});
iiifHeightContainer = new DynamicFormGroupModel({
id: 'iiifHeightContainer',
group: [this.iiifHeightModel]
},{
grid: {
host: 'form-row'
}
});
/**
* All input models in a simple array for easier iterations
*/
inputModels = [this.fileNameModel, this.primaryBitstreamModel, this.descriptionModel, this.selectedFormatModel, this.newFormatModel];
inputModels = [this.fileNameModel, this.primaryBitstreamModel, this.descriptionModel, this.selectedFormatModel,
this.newFormatModel];
/**
* The dynamic form fields used for editing the information of a bitstream
@@ -163,7 +280,11 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy {
this.fileNameModel,
this.primaryBitstreamModel
]
}),
},{
grid: {
host: 'form-row'
}
}),
new DynamicFormGroupModel({
id: 'descriptionContainer',
group: [
@@ -254,18 +375,27 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy {
*/
entityType: string;
/**
* Set to true when the parent item supports IIIF.
*/
isIIIF = false;
/**
* Array to track all subscriptions and unsubscribe them onDestroy
* @type {Array}
*/
protected subs: Subscription[] = [];
constructor(private route: ActivatedRoute,
private router: Router,
private changeDetectorRef: ChangeDetectorRef,
private location: Location,
private formService: DynamicFormService,
private translate: TranslateService,
private bitstreamService: BitstreamDataService,
private dsoNameService: DSONameService,
private notificationsService: NotificationsService,
private bitstreamFormatService: BitstreamFormatDataService) {
}
@@ -277,7 +407,6 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy {
* - Translate the form labels and hints
*/
ngOnInit(): void {
this.formGroup = this.formService.createFormGroup(this.formModel);
this.itemId = this.route.snapshot.queryParams.itemId;
this.entityType = this.route.snapshot.queryParams.entityType;
@@ -301,13 +430,10 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy {
).subscribe(([bitstream, allFormats]) => {
this.bitstream = bitstream as Bitstream;
this.formats = allFormats.page;
this.updateFormatModel();
this.updateForm(this.bitstream);
this.setIiifStatus(this.bitstream);
})
);
this.updateFieldTranslations();
this.subs.push(
this.translate.onLangChange
.subscribe(() => {
@@ -316,6 +442,16 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy {
);
}
/**
* Initializes the form.
*/
setForm() {
this.formGroup = this.formService.createFormGroup(this.formModel);
this.updateFormatModel();
this.updateForm(this.bitstream);
this.updateFieldTranslations();
}
/**
* Update the current form values with bitstream properties
* @param bitstream
@@ -333,6 +469,22 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy {
newFormat: hasValue(bitstream.firstMetadata('dc.format')) ? bitstream.firstMetadata('dc.format').value : undefined
}
});
if (this.isIIIF) {
this.formGroup.patchValue({
iiifLabelContainer: {
iiifLabel: bitstream.firstMetadataValue(this.IIIF_LABEL_METADATA)
},
iiifTocContainer: {
iiifToc: bitstream.firstMetadataValue(this.IIIF_TOC_METADATA)
},
iiifWidthContainer: {
iiifWidth: bitstream.firstMetadataValue(this.IMAGE_WIDTH_METADATA)
},
iiifHeightContainer: {
iiifHeight: bitstream.firstMetadataValue(this.IMAGE_HEIGHT_METADATA)
}
});
}
this.bitstream.format.pipe(
getAllSucceededRemoteDataPayload()
).subscribe((format: BitstreamFormat) => {
@@ -467,6 +619,32 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy {
const primary = rawForm.fileNamePrimaryContainer.primaryBitstream;
Metadata.setFirstValue(newMetadata, 'dc.title', rawForm.fileNamePrimaryContainer.fileName);
Metadata.setFirstValue(newMetadata, 'dc.description', rawForm.descriptionContainer.description);
if (this.isIIIF) {
// It's helpful to remove these metadata elements entirely when the form value is empty.
// This avoids potential issues on the REST side and makes it possible to do things like
// remove an existing "table of contents" entry.
if (isEmpty(rawForm.iiifLabelContainer.iiifLabel)) {
delete newMetadata[this.IIIF_LABEL_METADATA];
} else {
Metadata.setFirstValue(newMetadata, this.IIIF_LABEL_METADATA, rawForm.iiifLabelContainer.iiifLabel);
}
if (isEmpty(rawForm.iiifTocContainer.iiifToc)) {
delete newMetadata[this.IIIF_TOC_METADATA];
} else {
Metadata.setFirstValue(newMetadata, this.IIIF_TOC_METADATA, rawForm.iiifTocContainer.iiifToc);
}
if (isEmpty(rawForm.iiifWidthContainer.iiifWidth)) {
delete newMetadata[this.IMAGE_WIDTH_METADATA];
} else {
Metadata.setFirstValue(newMetadata, this.IMAGE_WIDTH_METADATA, rawForm.iiifWidthContainer.iiifWidth);
}
if (isEmpty(rawForm.iiifHeightContainer.iiifHeight)) {
delete newMetadata[this.IMAGE_HEIGHT_METADATA];
} else {
Metadata.setFirstValue(newMetadata, this.IMAGE_HEIGHT_METADATA, rawForm.iiifHeightContainer.iiifHeight);
}
}
if (isNotEmpty(rawForm.formatContainer.newFormat)) {
Metadata.setFirstValue(newMetadata, 'dc.format', rawForm.formatContainer.newFormat);
}
@@ -497,6 +675,58 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy {
}
}
/**
* Verifies that the parent item is iiif-enabled. Checks bitstream mimetype to be
* sure it's an image, excluding bitstreams in the THUMBNAIL or OTHERCONTENT bundles.
* @param bitstream
*/
setIiifStatus(bitstream: Bitstream) {
const regexExcludeBundles = /OTHERCONTENT|THUMBNAIL|LICENSE/;
const regexIIIFItem = /true|yes/i;
const isImage$ = this.bitstream.format.pipe(
getFirstSucceededRemoteData(),
map((format: RemoteData<BitstreamFormat>) => format.payload.mimetype.includes('image/')));
const isIIIFBundle$ = this.bitstream.bundle.pipe(
getFirstSucceededRemoteData(),
map((bundle: RemoteData<Bundle>) =>
this.dsoNameService.getName(bundle.payload).match(regexExcludeBundles) == null));
const isEnabled$ = this.bitstream.bundle.pipe(
getFirstSucceededRemoteData(),
map((bundle: RemoteData<Bundle>) => bundle.payload.item.pipe(
getFirstSucceededRemoteData(),
map((item: RemoteData<Item>) =>
(item.payload.firstMetadataValue('dspace.iiif.enabled') &&
item.payload.firstMetadataValue('dspace.iiif.enabled').match(regexIIIFItem) !== null)
))));
const iiifSub = combineLatest(
isImage$,
isIIIFBundle$,
isEnabled$
).subscribe(([isImage, isIIIFBundle, isEnabled]) => {
if (isImage && isIIIFBundle && isEnabled) {
this.isIIIF = true;
this.inputModels.push(this.iiifLabelModel);
this.formModel.push(this.iiifLabelContainer);
this.inputModels.push(this.iiifTocModel);
this.formModel.push(this.iiifTocContainer);
this.inputModels.push(this.iiifWidthModel);
this.formModel.push(this.iiifWidthContainer);
this.inputModels.push(this.iiifHeightModel);
this.formModel.push(this.iiifHeightContainer);
}
this.setForm();
this.changeDetectorRef.detectChanges();
});
this.subs.push(iiifSub);
}
/**
* Unsubscribe from open subscriptions
*/

View File

@@ -3,5 +3,6 @@ export enum AuthMethodType {
Shibboleth = 'shibboleth',
Ldap = 'ldap',
Ip = 'ip',
X509 = 'x509'
X509 = 'x509',
Oidc = 'oidc'
}

View File

@@ -29,6 +29,11 @@ export class AuthMethod {
this.authMethodType = AuthMethodType.Password;
break;
}
case 'oidc': {
this.authMethodType = AuthMethodType.Oidc;
this.location = location;
break;
}
default: {
break;

View File

@@ -0,0 +1,45 @@
/**
* Model class for an Item Access Condition
*/
export class AccessesConditionOption {
/**
* The name for this Access Condition
*/
name: string;
/**
* The groupName for this Access Condition
*/
groupName: string;
/**
* A boolean representing if this Access Condition has a start date
*/
hasStartDate: boolean;
/**
* A boolean representing if this Access Condition has an end date
*/
hasEndDate: boolean;
/**
* Maximum value of the start date
*/
endDateLimit?: string;
/**
* Maximum value of the end date
*/
startDateLimit?: string;
/**
* Maximum value of the start date
*/
maxStartDate?: string;
/**
* Maximum value of the end date
*/
maxEndDate?: string;
}

View File

@@ -0,0 +1,42 @@
import { autoserialize, deserialize, inheritSerialization } from 'cerialize';
import { typedObject } from '../../cache/builders/build-decorators';
import { ConfigObject } from './config.model';
import { AccessesConditionOption } from './config-accesses-conditions-options.model';
import { SUBMISSION_ACCESSES_TYPE } from './config-type';
import { HALLink } from '../../shared/hal-link.model';
/**
* Class for the configuration describing the item accesses condition
*/
@typedObject
@inheritSerialization(ConfigObject)
export class SubmissionAccessModel extends ConfigObject {
static type = SUBMISSION_ACCESSES_TYPE;
/**
* A list of available item access conditions
*/
@autoserialize
accessConditionOptions: AccessesConditionOption[];
/**
* Boolean that indicates whether the current item must be findable via search or browse.
*/
@autoserialize
discoverable: boolean;
/**
* Boolean that indicates whether or not the user can change the discoverable flag.
*/
@autoserialize
canChangeDiscoverable: boolean;
/**
* The links to all related resources returned by the rest api.
*/
@deserialize
_links: {
self: HALLink
};
}

View File

@@ -0,0 +1,10 @@
import { inheritSerialization } from 'cerialize';
import { typedObject } from '../../cache/builders/build-decorators';
import { SUBMISSION_ACCESSES_TYPE } from './config-type';
import { SubmissionAccessModel } from './config-submission-access.model';
@typedObject
@inheritSerialization(SubmissionAccessModel)
export class SubmissionAccessesModel extends SubmissionAccessModel {
static type = SUBMISSION_ACCESSES_TYPE;
}

View File

@@ -15,3 +15,5 @@ export const SUBMISSION_SECTION_TYPE = new ResourceType('submissionsection');
export const SUBMISSION_UPLOADS_TYPE = new ResourceType('submissionuploads');
export const SUBMISSION_UPLOAD_TYPE = new ResourceType('submissionupload');
export const SUBMISSION_ACCESSES_TYPE = new ResourceType('submissionaccessoption');

View File

@@ -0,0 +1,42 @@
import { Injectable } from '@angular/core';
import { ConfigService } from './config.service';
import { RequestService } from '../data/request.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { ObjectCacheService } from '../cache/object-cache.service';
import { dataService } from '../cache/builders/build-decorators';
import { SUBMISSION_ACCESSES_TYPE } from './models/config-type';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { Store } from '@ngrx/store';
import { CoreState } from '../core.reducers';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { HttpClient } from '@angular/common/http';
import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service';
import { ConfigObject } from './models/config.model';
import { SubmissionAccessesModel } from './models/config-submission-accesses.model';
import { RemoteData } from '../data/remote-data';
import { Observable } from 'rxjs';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
/**
* Provides methods to retrieve, from REST server, bitstream access conditions configurations applicable during the submission process.
*/
@Injectable()
@dataService(SUBMISSION_ACCESSES_TYPE)
export class SubmissionAccessesConfigService extends ConfigService {
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
protected http: HttpClient,
protected comparator: DefaultChangeAnalyzer<SubmissionAccessesModel>
) {
super(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator, 'submissionaccessoptions');
}
findByHref(href: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow): Observable<RemoteData<SubmissionAccessesModel>> {
return super.findByHref(href, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow as FollowLinkConfig<ConfigObject>[]) as Observable<RemoteData<SubmissionAccessesModel>>;
}
}

View File

@@ -2,11 +2,7 @@ import { CommonModule } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core';
import {
DynamicFormLayoutService,
DynamicFormService,
DynamicFormValidationService
} from '@ng-dynamic-forms/core';
import { DynamicFormLayoutService, DynamicFormService, DynamicFormValidationService } from '@ng-dynamic-forms/core';
import { EffectsModule } from '@ngrx/effects';
import { Action, StoreConfig, StoreModule } from '@ngrx/store';
@@ -77,6 +73,7 @@ import { MetadataSchema } from './metadata/metadata-schema.model';
import { MetadataService } from './metadata/metadata.service';
import { RegistryService } from './registry/registry.service';
import { RoleService } from './roles/role.service';
import { FeedbackDataService } from './feedback/feedback-data.service';
import { ApiService } from './services/api.service';
import { ServerResponseService } from './services/server-response.service';
@@ -164,6 +161,7 @@ import { Root } from './data/root.model';
import { SearchConfig } from './shared/search/search-filters/search-config.model';
import { SequenceService } from './shared/sequence.service';
import { GroupDataService } from './eperson/group-data.service';
import { SubmissionAccessesModel } from './config/models/config-submission-accesses.model';
/**
* When not in production, endpoint responses can be mocked for testing purposes
@@ -286,7 +284,8 @@ const PROVIDERS = [
VocabularyService,
VocabularyTreeviewService,
SequenceService,
GroupDataService
GroupDataService,
FeedbackDataService,
];
/**
@@ -345,7 +344,8 @@ export const models =
Registration,
UsageReport,
Root,
SearchConfig
SearchConfig,
SubmissionAccessesModel
];
@NgModule({

View File

@@ -27,4 +27,5 @@ export enum FeatureID {
CanDeleteVersion = 'canDeleteVersion',
CanCreateVersion = 'canCreateVersion',
CanViewUsageStatistics = 'canViewUsageStatistics',
CanSendFeedback = 'canSendFeedback',
}

View File

@@ -0,0 +1,88 @@
import { FeedbackDataService } from './feedback-data.service';
import { HALLink } from '../shared/hal-link.model';
import { Item } from '../shared/item.model';
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { getMockRequestService } from '../../shared/mocks/request.service.mock';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { HttpClient } from '@angular/common/http';
import { Store } from '@ngrx/store';
import { CoreState } from '../core.reducers';
import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service';
import { Feedback } from './models/feedback.model';
describe('FeedbackDataService', () => {
let service: FeedbackDataService;
let requestService;
let halService;
let rdbService;
let notificationsService;
let http;
let comparator;
let objectCache;
let store;
let item;
let bundleLink;
let bundleHALLink;
const feedbackPayload = Object.assign(new Feedback(), {
email: 'test@email.com',
message: 'message',
page: '/home'
});
function initTestService(): FeedbackDataService {
bundleLink = '/items/0fdc0cd7-ff8c-433d-b33c-9b56108abc07/bundles';
bundleHALLink = new HALLink();
bundleHALLink.href = bundleLink;
item = new Item();
item._links = {
bundles: bundleHALLink
};
requestService = getMockRequestService();
halService = new HALEndpointServiceStub('url') as any;
rdbService = {} as RemoteDataBuildService;
notificationsService = {} as NotificationsService;
http = {} as HttpClient;
comparator = new DSOChangeAnalyzer() as any;
objectCache = {
addPatch: () => {
/* empty */
},
getObjectBySelfLink: () => {
/* empty */
}
} as any;
store = {} as Store<CoreState>;
return new FeedbackDataService(
requestService,
rdbService,
store,
objectCache,
halService,
notificationsService,
http,
comparator,
);
}
beforeEach(() => {
service = initTestService();
});
describe('getFeedback', () => {
beforeEach(() => {
spyOn(service, 'getFeedback');
service.getFeedback('3');
});
it('should call getFeedback with the feedback link', () => {
expect(service.getFeedback).toHaveBeenCalledWith('3');
});
});
});

View File

@@ -0,0 +1,49 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { DataService } from '../data/data.service';
import { Feedback } from './models/feedback.model';
import { FEEDBACK } from './models/feedback.resource-type';
import { dataService } from '../cache/builders/build-decorators';
import { RequestService } from '../data/request.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { Store } from '@ngrx/store';
import { ObjectCacheService } from '../cache/object-cache.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { HttpClient } from '@angular/common/http';
import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service';
import { getFirstSucceededRemoteData, getRemoteDataPayload } from '../shared/operators';
/**
* Service for checking and managing the feedback
*/
@Injectable()
@dataService(FEEDBACK)
export class FeedbackDataService extends DataService<Feedback> {
protected linkPath = 'feedbacks';
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected store: Store<any>,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
protected http: HttpClient,
protected comparator: DSOChangeAnalyzer<Feedback>,
) {
super();
}
/**
* Get feedback from its id
* @param uuid string the id of the feedback
*/
getFeedback(uuid: string): Observable<Feedback> {
return this.findById(uuid).pipe(
getFirstSucceededRemoteData(),
getRemoteDataPayload(),
);
}
}

View File

@@ -0,0 +1,20 @@
import { ActivatedRouteSnapshot, CanActivate, RouterStateSnapshot, UrlTree } from '@angular/router';
import { Observable } from 'rxjs';
import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service';
import { FeatureID } from '../data/feature-authorization/feature-id';
import { Injectable } from '@angular/core';
/**
* An guard for redirecting users to the feedback page if user is authorized
*/
@Injectable()
export class FeedbackGuard implements CanActivate {
constructor(private authorizationService: AuthorizationDataService) {
}
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> {
return this.authorizationService.isAuthorized(FeatureID.CanSendFeedback);
}
}

View File

@@ -0,0 +1,34 @@
import { autoserialize, inheritSerialization } from 'cerialize';
import { typedObject } from '../../cache/builders/build-decorators';
import { DSpaceObject } from '../../shared/dspace-object.model';
import { HALLink } from '../../shared/hal-link.model';
import { FEEDBACK } from './feedback.resource-type';
@typedObject
@inheritSerialization(DSpaceObject)
export class Feedback extends DSpaceObject {
static type = FEEDBACK;
/**
* The email address
*/
@autoserialize
public email: string;
/**
* A string representing message the user inserted
*/
@autoserialize
public message: string;
/**
* A string representing the page from which the user came from
*/
@autoserialize
public page: string;
_links: {
self: HALLink;
};
}

View File

@@ -0,0 +1,9 @@
import { ResourceType } from '../../shared/resource-type';
/**
* The resource type for Feedback
*
* Needs to be in a separate file to prevent circular
* dependencies in webpack.
*/
export const FEEDBACK = new ResourceType('feedback');

View File

@@ -0,0 +1,25 @@
/**
* An interface to represent an access condition.
*/
export class AccessConditionObject {
/**
* The access condition id
*/
id: string;
/**
* The access condition name
*/
name: string;
/**
* Possible start date of the access condition
*/
startDate: string;
/**
* Possible end date of the access condition
*/
endDate: string;
}

View File

@@ -0,0 +1,9 @@
import { ResourceType } from '../../shared/resource-type';
/**
* The resource type for Accesses section
*
* Needs to be in a separate file to prevent circular
* dependencies in webpack.
*/
export const SUBMISSION_ACCESSES = new ResourceType('submissionaccesses');

View File

@@ -0,0 +1,8 @@
import { AccessConditionObject } from './access-condition.model';
/**
* An interface to represent item's access condition.
*/
export class SubmissionItemAccessConditionObject extends AccessConditionObject {
}

View File

@@ -1,25 +1,8 @@
import { AccessConditionObject } from './access-condition.model';
/**
* An interface to represent bitstream's access condition.
*/
export class SubmissionUploadFileAccessConditionObject {
export class SubmissionUploadFileAccessConditionObject extends AccessConditionObject {
/**
* The access condition id
*/
id: string;
/**
* The access condition name
*/
name: string;
/**
* Possible start date of the access condition
*/
startDate: string;
/**
* Possible end date of the access condition
*/
endDate: string;
}

View File

@@ -0,0 +1,21 @@
import { SubmissionItemAccessConditionObject } from './submission-item-access-condition.model';
/**
* An interface to represent the submission's item accesses condition.
*/
export interface WorkspaceitemSectionAccessesObject {
/**
* The access condition id
*/
id: string;
/**
* Boolean that indicates whether the current item must be findable via search or browse.
*/
discoverable: boolean;
/**
* A list of available item access conditions
*/
accessConditions: SubmissionItemAccessConditionObject[];
}

View File

@@ -1,3 +1,4 @@
import { WorkspaceitemSectionAccessesObject } from './workspaceitem-section-accesses.model';
import { WorkspaceitemSectionFormObject } from './workspaceitem-section-form.model';
import { WorkspaceitemSectionLicenseObject } from './workspaceitem-section-license.model';
import { WorkspaceitemSectionUploadObject } from './workspaceitem-section-upload.model';
@@ -19,4 +20,5 @@ export type WorkspaceitemSectionDataType
| WorkspaceitemSectionFormObject
| WorkspaceitemSectionLicenseObject
| WorkspaceitemSectionCcLicenseObject
| WorkspaceitemSectionAccessesObject
| string;

View File

@@ -75,6 +75,10 @@
<a class="text-white"
routerLink="info/end-user-agreement">{{ 'footer.link.end-user-agreement' | translate}}</a>
</li>
<li>
<a class="text-white"
routerLink="info/feedback">{{ 'footer.link.feedback' | translate}}</a>
</li>
</ul>
</div>
</div>

View File

@@ -0,0 +1,45 @@
<div class="row row-offcanvas row-offcanvas-right">
<div class="col-xs-12 col-sm-12 col-md-9 main-content">
<form class="primary" [formGroup]="feedbackForm" (ngSubmit)="createFeedback()">
<h2>{{ 'info.feedback.head' | translate }}</h2>
<p>{{ 'info.feedback.info' | translate }}</p>
<fieldset class="col p-0">
<div class="row">
<div class="control-group col-sm-12">
<label class="control-label" for="email">{{ 'info.feedback.email-label' | translate }}&nbsp;</label>
<input id="email" class="form-control" name="email" type="text" value="" formControlName="email" autofocus="autofocus" title="{{ 'info.feedback.email_help' | translate }}">
<small class="text-muted">{{ 'info.feedback.email_help' | translate }}</small>
</div>
</div>
<ng-container *ngIf="feedbackForm.controls.email.invalid && (feedbackForm.controls.email.dirty || feedbackForm.controls.email.touched)"
class="alert">
<ds-error *ngIf="feedbackForm.controls.email.errors?.required" message="{{'info.feedback.error.email.required' | translate}}"></ds-error>
<ds-error *ngIf="feedbackForm.controls.email.errors?.pattern" message="{{'info.feedback.error.email.required' | translate}}"></ds-error>
</ng-container>
<div class="row">
<div class="control-group col-sm-12">
<label class="control-label" for="comments">{{ 'info.feedback.comments' | translate }}:&nbsp;</label>
<textarea id="comments" formControlName="message" class="form-control" name="message" cols="20" rows="5"> </textarea>
</div>
</div>
<ng-container *ngIf="feedbackForm.controls.message.invalid && (feedbackForm.controls.message.dirty || feedbackForm.controls.message.touched)"
class="alert">
<ds-error *ngIf="feedbackForm.controls.message.errors?.required" message="{{'info.feedback.error.message.required' | translate}}"></ds-error>
</ng-container>
<div class="row">
<div class="control-group col-sm-12">
<label class="control-label" for="page">{{ 'info.feedback.page-label' | translate }}&nbsp;</label>
<input id="page" readonly class="form-control" name="page" type="text" value="" formControlName="page" autofocus="autofocus" title="{{ 'info.feedback.page_help' | translate }}">
<small class="text-muted">{{ 'info.feedback.page_help' | translate }}</small>
</div>
</div>
<div class="row py-2">
<div class="control-group col-sm-12 text-right">
<button [disabled]="!feedbackForm.valid" class="btn btn-primary" name="submit" type="submit">{{ 'info.feedback.send' | translate }}</button>
</div>
</div>
</fieldset>
</form>
</div>
</div>

View File

@@ -0,0 +1,3 @@
ds-error{
color:red;
}

View File

@@ -0,0 +1,97 @@
import { EPersonMock } from '../../../shared/testing/eperson.mock';
import { FeedbackDataService } from '../../../core/feedback/feedback-data.service';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { FeedbackFormComponent } from './feedback-form.component';
import { TranslateModule } from '@ngx-translate/core';
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { By } from '@angular/platform-browser';
import { RouteService } from '../../../core/services/route.service';
import { routeServiceStub } from '../../../shared/testing/route-service.stub';
import { FormBuilder } from '@angular/forms';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub';
import { AuthService } from '../../../core/auth/auth.service';
import { AuthServiceStub } from '../../../shared/testing/auth-service.stub';
import { of } from 'rxjs';
import { Feedback } from '../../../core/feedback/models/feedback.model';
import { Router } from '@angular/router';
import { RouterMock } from '../../../shared/mocks/router.mock';
import { NativeWindowService } from '../../../core/services/window.service';
import { NativeWindowMockFactory } from '../../../shared/mocks/mock-native-window-ref';
describe('FeedbackFormComponent', () => {
let component: FeedbackFormComponent;
let fixture: ComponentFixture<FeedbackFormComponent>;
let de: DebugElement;
const notificationService = new NotificationsServiceStub();
const feedbackDataServiceStub = jasmine.createSpyObj('feedbackDataService', {
create: of(new Feedback())
});
const authService: AuthServiceStub = Object.assign(new AuthServiceStub(), {
getAuthenticatedUserFromStore: () => {
return of(EPersonMock);
}
});
const routerStub = new RouterMock();
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [FeedbackFormComponent],
providers: [
{ provide: RouteService, useValue: routeServiceStub },
{ provide: FormBuilder, useValue: new FormBuilder() },
{ provide: NotificationsService, useValue: notificationService },
{ provide: FeedbackDataService, useValue: feedbackDataServiceStub },
{ provide: AuthService, useValue: authService },
{ provide: NativeWindowService, useFactory: NativeWindowMockFactory },
{ provide: Router, useValue: routerStub },
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(FeedbackFormComponent);
component = fixture.componentInstance;
de = fixture.debugElement;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should have page value', () => {
expect(component.feedbackForm.controls.page.value).toEqual('http://localhost/home');
});
it('should have email if ePerson', () => {
expect(component.feedbackForm.controls.email.value).toEqual('test@test.com');
});
it('should have disabled button', () => {
expect(de.query(By.css('button')).nativeElement.disabled).toBeTrue();
});
describe('when message is inserted', () => {
beforeEach(() => {
component.feedbackForm.patchValue({ message: 'new feedback' });
fixture.detectChanges();
});
it('should not have disabled button', () => {
expect(de.query(By.css('button')).nativeElement.disabled).toBeFalse();
});
it('on submit should call createFeedback of feedbackDataServiceStub service', () => {
component.createFeedback();
fixture.detectChanges();
expect(feedbackDataServiceStub.create).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,83 @@
import { RemoteData } from '../../../core/data/remote-data';
import { NoContent } from '../../../core/shared/NoContent.model';
import { FeedbackDataService } from '../../../core/feedback/feedback-data.service';
import { Component, Inject, OnInit } from '@angular/core';
import { RouteService } from '../../../core/services/route.service';
import { FormBuilder, Validators } from '@angular/forms';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core';
import { AuthService } from '../../../core/auth/auth.service';
import { EPerson } from '../../../core/eperson/models/eperson.model';
import { getFirstCompletedRemoteData } from '../../../core/shared/operators';
import { Router } from '@angular/router';
import { getHomePageRoute } from '../../../app-routing-paths';
import { take } from 'rxjs/operators';
import { NativeWindowRef, NativeWindowService } from '../../../core/services/window.service';
import { URLCombiner } from '../../../core/url-combiner/url-combiner';
@Component({
selector: 'ds-feedback-form',
templateUrl: './feedback-form.component.html',
styleUrls: ['./feedback-form.component.scss']
})
/**
* Component displaying the contents of the Feedback Statement
*/
export class FeedbackFormComponent implements OnInit {
/**
* Form builder created used from the feedback from
*/
feedbackForm = this.fb.group({
email: ['', [Validators.required, Validators.pattern('^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,4}$')]],
message: ['', Validators.required],
page: [''],
});
constructor(
@Inject(NativeWindowService) protected _window: NativeWindowRef,
public routeService: RouteService,
private fb: FormBuilder,
protected notificationsService: NotificationsService,
protected translate: TranslateService,
private feedbackDataService: FeedbackDataService,
private authService: AuthService,
private router: Router) {
}
/**
* On init check if user is logged in and use its email if so
*/
ngOnInit() {
this.authService.getAuthenticatedUserFromStore().pipe(take(1)).subscribe((user: EPerson) => {
if (!!user) {
this.feedbackForm.patchValue({ email: user.email });
}
});
this.routeService.getPreviousUrl().pipe(take(1)).subscribe((url: string) => {
if (!url) {
url = getHomePageRoute();
}
const relatedUrl = new URLCombiner(this._window.nativeWindow.origin, url).toString();
this.feedbackForm.patchValue({ page: relatedUrl });
});
}
/**
* Function to create the feedback from form values
*/
createFeedback(): void {
const url = this.feedbackForm.value.page.replace(this._window.nativeWindow.origin, '');
this.feedbackDataService.create(this.feedbackForm.value).pipe(getFirstCompletedRemoteData()).subscribe((response: RemoteData<NoContent>) => {
if (response.isSuccess) {
this.notificationsService.success(this.translate.instant('info.feedback.create.success'));
this.feedbackForm.reset();
this.router.navigateByUrl(url);
}
});
}
}

View File

@@ -0,0 +1,3 @@
<div class="container">
<ds-feedback-form></ds-feedback-form>
</div>

View File

@@ -0,0 +1,27 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { FeedbackComponent } from './feedback.component';
import { TranslateModule } from '@ngx-translate/core';
import { NO_ERRORS_SCHEMA } from '@angular/core';
describe('FeedbackComponent', () => {
let component: FeedbackComponent;
let fixture: ComponentFixture<FeedbackComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [FeedbackComponent],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(FeedbackComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,12 @@
import { Component } from '@angular/core';
@Component({
selector: 'ds-feedback',
templateUrl: './feedback.component.html',
styleUrls: ['./feedback.component.scss']
})
/**
* Component displaying the Feedback Statement
*/
export class FeedbackComponent {
}

View File

@@ -0,0 +1,26 @@
import { Component } from '@angular/core';
import { ThemedComponent } from '../../shared/theme-support/themed.component';
import { FeedbackComponent } from './feedback.component';
/**
* Themed wrapper for FeedbackComponent
*/
@Component({
selector: 'ds-themed-feedback',
styleUrls: [],
templateUrl: '../../shared/theme-support/themed.component.html',
})
export class ThemedFeedbackComponent extends ThemedComponent<FeedbackComponent> {
protected getComponentName(): string {
return 'FeedbackComponent';
}
protected importThemedComponent(themeName: string): Promise<any> {
return import(`../../../themes/${themeName}/app/info/feedback/feedback.component`);
}
protected importUnthemedComponent(): Promise<any> {
return import(`./feedback.component`);
}
}

View File

@@ -2,6 +2,7 @@ import { getInfoModulePath } from '../app-routing-paths';
export const END_USER_AGREEMENT_PATH = 'end-user-agreement';
export const PRIVACY_PATH = 'privacy';
export const FEEDBACK_PATH = 'feedback';
export function getEndUserAgreementPath() {
return getSubPath(END_USER_AGREEMENT_PATH);
@@ -11,6 +12,10 @@ export function getPrivacyPath() {
return getSubPath(PRIVACY_PATH);
}
export function getFeedbackPath() {
return getSubPath(FEEDBACK_PATH);
}
function getSubPath(path: string) {
return `${getInfoModulePath()}/${path}`;
}

View File

@@ -1,9 +1,12 @@
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver';
import { PRIVACY_PATH, END_USER_AGREEMENT_PATH } from './info-routing-paths';
import { PRIVACY_PATH, END_USER_AGREEMENT_PATH, FEEDBACK_PATH } from './info-routing-paths';
import { ThemedEndUserAgreementComponent } from './end-user-agreement/themed-end-user-agreement.component';
import { ThemedPrivacyComponent } from './privacy/themed-privacy.component';
import { ThemedFeedbackComponent } from './feedback/themed-feedback.component';
import { FeedbackGuard } from '../core/feedback/feedback.guard';
@NgModule({
imports: [
@@ -22,6 +25,15 @@ import { ThemedPrivacyComponent } from './privacy/themed-privacy.component';
resolve: { breadcrumb: I18nBreadcrumbResolver },
data: { title: 'info.privacy.title', breadcrumbKey: 'info.privacy' }
}
]),
RouterModule.forChild([
{
path: FEEDBACK_PATH,
component: ThemedFeedbackComponent,
resolve: { breadcrumb: I18nBreadcrumbResolver },
data: { title: 'info.feedback.title', breadcrumbKey: 'info.feedback' },
canActivate: [FeedbackGuard]
}
])
]
})

View File

@@ -8,6 +8,11 @@ import { PrivacyComponent } from './privacy/privacy.component';
import { PrivacyContentComponent } from './privacy/privacy-content/privacy-content.component';
import { ThemedEndUserAgreementComponent } from './end-user-agreement/themed-end-user-agreement.component';
import { ThemedPrivacyComponent } from './privacy/themed-privacy.component';
import { FeedbackComponent } from './feedback/feedback.component';
import { FeedbackFormComponent } from './feedback/feedback-form/feedback-form.component';
import { ThemedFeedbackComponent } from './feedback/themed-feedback.component';
import { FeedbackGuard } from '../core/feedback/feedback.guard';
const DECLARATIONS = [
EndUserAgreementComponent,
@@ -15,21 +20,25 @@ const DECLARATIONS = [
EndUserAgreementContentComponent,
PrivacyComponent,
PrivacyContentComponent,
ThemedPrivacyComponent
ThemedPrivacyComponent,
FeedbackComponent,
FeedbackFormComponent,
ThemedFeedbackComponent
];
@NgModule({
imports: [
CommonModule,
SharedModule,
InfoRoutingModule
InfoRoutingModule,
],
declarations: [
...DECLARATIONS
],
exports: [
...DECLARATIONS
]
],
providers: [FeedbackGuard]
})
export class InfoModule {
}

View File

@@ -3,7 +3,7 @@ import { ItemType } from '../../../core/shared/item-relationships/item-type.mode
import { Relationship } from '../../../core/shared/item-relationships/relationship.model';
import { Item } from '../../../core/shared/item.model';
import { RouterStub } from '../../../shared/testing/router.stub';
import { of as observableOf } from 'rxjs';
import { of as observableOf, EMPTY } from 'rxjs';
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
@@ -24,6 +24,8 @@ import { RelationshipType } from '../../../core/shared/item-relationships/relati
import { EntityTypeService } from '../../../core/data/entity-type.service';
import { getItemEditRoute } from '../../item-page-routing-paths';
import { createPaginatedList } from '../../../shared/testing/utils.test';
import { RelationshipTypeService } from '../../../core/data/relationship-type.service';
import { LinkService } from '../../../core/cache/builders/link.service';
let comp: ItemDeleteComponent;
let fixture: ComponentFixture<ItemDeleteComponent>;
@@ -40,6 +42,7 @@ let mockItemDataService: ItemDataService;
let routeStub;
let objectUpdatesServiceStub;
let relationshipService;
let linkService;
let entityTypeService;
let notificationsServiceStub;
let typesSelection;
@@ -52,7 +55,12 @@ describe('ItemDeleteComponent', () => {
uuid: 'fake-uuid',
handle: 'fake/handle',
lastModified: '2018',
isWithdrawn: true
isWithdrawn: true,
metadata: {
'dspace.entity.type': [
{ value: 'Person' }
]
}
});
itemType = Object.assign(new ItemType(), {
@@ -129,6 +137,12 @@ describe('ItemDeleteComponent', () => {
}
);
linkService = jasmine.createSpyObj('linkService',
{
resolveLinks: relationships[0],
}
);
notificationsServiceStub = new NotificationsServiceStub();
TestBed.configureTestingModule({
@@ -142,6 +156,8 @@ describe('ItemDeleteComponent', () => {
{ provide: ObjectUpdatesService, useValue: objectUpdatesServiceStub },
{ provide: RelationshipService, useValue: relationshipService },
{ provide: EntityTypeService, useValue: entityTypeService },
{ provide: RelationshipTypeService, useValue: {} },
{ provide: LinkService, useValue: linkService },
], schemas: [
CUSTOM_ELEMENTS_SCHEMA
]
@@ -166,25 +182,45 @@ describe('ItemDeleteComponent', () => {
});
describe('performAction', () => {
it('should call delete function from the ItemDataService', () => {
spyOn(comp, 'notify');
comp.performAction();
expect(mockItemDataService.delete)
.toHaveBeenCalledWith(mockItem.id, types.filter((type) => typesSelection[type]).map((type) => type.id));
expect(comp.notify).toHaveBeenCalled();
describe(`when there are entitytypes`, () => {
it('should call delete function from the ItemDataService', () => {
spyOn(comp, 'notify');
comp.performAction();
expect(mockItemDataService.delete)
.toHaveBeenCalledWith(mockItem.id, types.filter((type) => typesSelection[type]).map((type) => type.id));
expect(comp.notify).toHaveBeenCalled();
});
it('should call delete function from the ItemDataService with empty types', () => {
spyOn(comp, 'notify');
jasmine.getEnv().allowRespy(true);
spyOn(entityTypeService, 'getEntityTypeRelationships').and.returnValue([]);
comp.ngOnInit();
comp.performAction();
expect(mockItemDataService.delete).toHaveBeenCalledWith(mockItem.id, []);
expect(comp.notify).toHaveBeenCalled();
});
});
it('should call delete function from the ItemDataService with empty types', () => {
describe(`when there are no entity types`, () => {
beforeEach(() => {
(comp as any).entityTypeService = jasmine.createSpyObj('entityTypeService',
{
getEntityTypeByLabel: EMPTY,
}
);
});
spyOn(comp, 'notify');
jasmine.getEnv().allowRespy(true);
spyOn(entityTypeService, 'getEntityTypeRelationships').and.returnValue([]);
comp.ngOnInit();
comp.performAction();
expect(mockItemDataService.delete).toHaveBeenCalledWith(mockItem.id, []);
expect(comp.notify).toHaveBeenCalled();
it('should call delete function from the ItemDataService', () => {
spyOn(comp, 'notify');
comp.performAction();
expect(mockItemDataService.delete)
.toHaveBeenCalledWith(mockItem.id, types.filter((type) => typesSelection[type]).map((type) => type.id));
expect(comp.notify).toHaveBeenCalled();
});
});
});
describe('notify', () => {

View File

@@ -1,12 +1,14 @@
import { Component, Input, OnInit } from '@angular/core';
import {defaultIfEmpty, filter, map, switchMap, take} from 'rxjs/operators';
import { AbstractSimpleItemActionComponent } from '../simple-item-action/abstract-simple-item-action.component';
import { Component, Input, OnInit, OnDestroy } from '@angular/core';
import { defaultIfEmpty, filter, map, switchMap, take } from 'rxjs/operators';
import {
AbstractSimpleItemActionComponent
} from '../simple-item-action/abstract-simple-item-action.component';
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
import {
combineLatest as observableCombineLatest,
combineLatest,
Observable,
of as observableOf
of as observableOf, Subscription
} from 'rxjs';
import { RelationshipType } from '../../../core/shared/item-relationships/relationship-type.model';
import { VirtualMetadata } from '../virtual-metadata/virtual-metadata.component';
@@ -32,6 +34,7 @@ import { followLink } from '../../../shared/utils/follow-link-config.model';
import { getItemEditRoute } from '../../item-page-routing-paths';
import { RemoteData } from '../../../core/data/remote-data';
import { NoContent } from '../../../core/shared/NoContent.model';
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
@Component({
selector: 'ds-item-delete',
@@ -42,7 +45,7 @@ import { NoContent } from '../../../core/shared/NoContent.model';
*/
export class ItemDeleteComponent
extends AbstractSimpleItemActionComponent
implements OnInit {
implements OnInit, OnDestroy {
/**
* The current url of this page
@@ -60,7 +63,7 @@ export class ItemDeleteComponent
* A list of the relationship types for which this item has relations as an observable.
* The list doesn't contain duplicates.
*/
types$: Observable<RelationshipType[]>;
types$: BehaviorSubject<RelationshipType[]> = new BehaviorSubject([]);
/**
* A map which stores the relationships of this item for each type as observable lists
@@ -84,6 +87,11 @@ export class ItemDeleteComponent
*/
public modalRef: NgbModalRef;
/**
* Array to track all subscriptions and unsubscribe them onDestroy
*/
private subs: Subscription[] = [];
constructor(protected route: ActivatedRoute,
protected router: Router,
protected notificationsService: NotificationsService,
@@ -113,8 +121,8 @@ export class ItemDeleteComponent
this.url = this.router.url;
const label = this.item.firstMetadataValue('dspace.entity.type');
if (label !== undefined) {
this.types$ = this.entityTypeService.getEntityTypeByLabel(label).pipe(
if (isNotEmpty(label)) {
this.subs.push(this.entityTypeService.getEntityTypeByLabel(label).pipe(
getFirstSucceededRemoteData(),
getRemoteDataPayload(),
switchMap((entityType) => this.entityTypeService.getEntityTypeRelationships(entityType.id)),
@@ -138,16 +146,14 @@ export class ItemDeleteComponent
),
);
})
);
} else {
this.types$ = observableOf([]);
).subscribe((types: RelationshipType[]) => this.types$.next(types)));
}
this.types$.pipe(
this.subs.push(this.types$.pipe(
take(1),
).subscribe((types) =>
this.objectUpdatesService.initialize(this.url, types, this.item.lastModified)
);
));
}
/**
@@ -327,7 +333,7 @@ export class ItemDeleteComponent
*/
performAction() {
this.types$.pipe(
this.subs.push(this.types$.pipe(
switchMap((types) =>
combineLatest(
types.map((type) => this.isSelected(type))
@@ -339,13 +345,14 @@ export class ItemDeleteComponent
map((selectedTypes) => selectedTypes.map((type) => type.id)),
)
),
).subscribe((types) => {
this.itemDataService.delete(this.item.id, types).pipe(getFirstCompletedRemoteData()).subscribe(
(rd: RemoteData<NoContent>) => {
this.notify(rd.hasSucceeded);
}
);
});
switchMap((types) =>
this.itemDataService.delete(this.item.id, types).pipe(getFirstCompletedRemoteData())
)
).subscribe(
(rd: RemoteData<NoContent>) => {
this.notify(rd.hasSucceeded);
}
));
}
/**
@@ -361,4 +368,14 @@ export class ItemDeleteComponent
this.router.navigate([getItemEditRoute(this.item)]);
}
}
/**
* Unsubscribe from all subscriptions
*/
ngOnDestroy(): void {
this.subs
.filter((sub) => hasValue(sub))
.forEach((sub) => sub.unsubscribe());
}
}

View File

@@ -14,7 +14,8 @@
<div>
<ng-container #componentViewContainer></ng-container>
</div>
<small *ngIf="hasHint && ((model.repeatable === false && (isRelationship === false || value?.value === null)) || (model.repeatable === true && context?.index === context?.context?.groups?.length - 1)) && (!showErrorMessages || errorMessages.length === 0)"
<small *ngIf="hasHint && ((!model.repeatable && (isRelationship === false || value?.value === null)) || (model.repeatable === true && context?.index === context?.context?.groups?.length - 1)) && (!showErrorMessages || errorMessages.length === 0)"
class="text-muted ds-hint" [innerHTML]="model.hint | translate" [ngClass]="getClass('element', 'hint')"></small>
<!-- In case of repeatable fields show empty space for all elements except the first -->
<div *ngIf="context?.index !== null

View File

@@ -15,8 +15,8 @@
[cdkDragDisabled]="dragDisabled"
[cdkDragPreviewClass]="'ds-submission-reorder-dragging'">
<!-- Item content -->
<div class="drag-handle" [class.invisible]="dragDisabled" tabindex="0">
<i class="drag-icon fas fa-grip-vertical fa-fw" ></i>
<div class="drag-handle" [class.drag-disable]="dragDisabled" tabindex="0">
<i class="drag-icon fas fa-grip-vertical fa-fw" [class.drag-disable]="dragDisabled" ></i>
</div>
<ng-container *ngTemplateOutlet="startTemplate?.templateRef; context: groupModel"></ng-container>
<ds-dynamic-form-control-container *ngFor="let _model of groupModel.group"

View File

@@ -3,7 +3,15 @@
:host {
display: block;
}
.drag-disable {
visibility: hidden !important;
&:hover, &:focus {
cursor: default;
.drag-icon {
visibility: hidden !important;
}
}
}
.cdk-drag {
margin-left: calc(-2.3 * var(--bs-spacer));
margin-right: calc(-0.5 * var(--bs-spacer));

View File

@@ -0,0 +1,3 @@
<button class="btn btn-lg btn-primary btn-block mt-2 text-white" (click)="redirectToOidc()">
<i class="fas fa-sign-in-alt"></i> {{"login.form.oidc" | translate}}
</button>

View File

@@ -0,0 +1,155 @@
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { provideMockStore } from '@ngrx/store/testing';
import { Store, StoreModule } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core';
import { EPerson } from '../../../../core/eperson/models/eperson.model';
import { EPersonMock } from '../../../testing/eperson.mock';
import { authReducer } from '../../../../core/auth/auth.reducer';
import { AuthService } from '../../../../core/auth/auth.service';
import { AuthServiceStub } from '../../../testing/auth-service.stub';
import { storeModuleConfig } from '../../../../app.reducer';
import { AuthMethod } from '../../../../core/auth/models/auth.method';
import { AuthMethodType } from '../../../../core/auth/models/auth.method-type';
import { LogInOidcComponent } from './log-in-oidc.component';
import { NativeWindowService } from '../../../../core/services/window.service';
import { RouterStub } from '../../../testing/router.stub';
import { ActivatedRouteStub } from '../../../testing/active-router.stub';
import { NativeWindowMockFactory } from '../../../mocks/mock-native-window-ref';
import { HardRedirectService } from '../../../../core/services/hard-redirect.service';
describe('LogInOidcComponent', () => {
let component: LogInOidcComponent;
let fixture: ComponentFixture<LogInOidcComponent>;
let page: Page;
let user: EPerson;
let componentAsAny: any;
let setHrefSpy;
let oidcBaseUrl;
let location;
let initialState: any;
let hardRedirectService: HardRedirectService;
beforeEach(() => {
user = EPersonMock;
oidcBaseUrl = 'dspace-rest.test/oidc?redirectUrl=';
location = oidcBaseUrl + 'http://dspace-angular.test/home';
hardRedirectService = jasmine.createSpyObj('hardRedirectService', {
getCurrentRoute: {},
redirect: {}
});
initialState = {
core: {
auth: {
authenticated: false,
loaded: false,
blocking: false,
loading: false,
authMethods: []
}
}
};
});
beforeEach(waitForAsync(() => {
// refine the test module by declaring the test component
TestBed.configureTestingModule({
imports: [
StoreModule.forRoot({ auth: authReducer }, storeModuleConfig),
TranslateModule.forRoot()
],
declarations: [
LogInOidcComponent
],
providers: [
{ provide: AuthService, useClass: AuthServiceStub },
{ provide: 'authMethodProvider', useValue: new AuthMethod(AuthMethodType.Oidc, location) },
{ provide: 'isStandalonePage', useValue: true },
{ provide: NativeWindowService, useFactory: NativeWindowMockFactory },
{ provide: Router, useValue: new RouterStub() },
{ provide: ActivatedRoute, useValue: new ActivatedRouteStub() },
{ provide: HardRedirectService, useValue: hardRedirectService },
provideMockStore({ initialState }),
],
schemas: [
CUSTOM_ELEMENTS_SCHEMA
]
})
.compileComponents();
}));
beforeEach(() => {
// create component and test fixture
fixture = TestBed.createComponent(LogInOidcComponent);
// get test component from the fixture
component = fixture.componentInstance;
componentAsAny = component;
// create page
page = new Page(component, fixture);
setHrefSpy = spyOnProperty(componentAsAny._window.nativeWindow.location, 'href', 'set').and.callThrough();
});
it('should set the properly a new redirectUrl', () => {
const currentUrl = 'http://dspace-angular.test/collections/12345';
componentAsAny._window.nativeWindow.location.href = currentUrl;
fixture.detectChanges();
expect(componentAsAny.injectedAuthMethodModel.location).toBe(location);
expect(componentAsAny._window.nativeWindow.location.href).toBe(currentUrl);
component.redirectToOidc();
expect(setHrefSpy).toHaveBeenCalledWith(currentUrl);
});
it('should not set a new redirectUrl', () => {
const currentUrl = 'http://dspace-angular.test/home';
componentAsAny._window.nativeWindow.location.href = currentUrl;
fixture.detectChanges();
expect(componentAsAny.injectedAuthMethodModel.location).toBe(location);
expect(componentAsAny._window.nativeWindow.location.href).toBe(currentUrl);
component.redirectToOidc();
expect(setHrefSpy).toHaveBeenCalledWith(currentUrl);
});
});
/**
* I represent the DOM elements and attach spies.
*
* @class Page
*/
class Page {
public emailInput: HTMLInputElement;
public navigateSpy: jasmine.Spy;
public passwordInput: HTMLInputElement;
constructor(private component: LogInOidcComponent, private fixture: ComponentFixture<LogInOidcComponent>) {
// use injector to get services
const injector = fixture.debugElement.injector;
const store = injector.get(Store);
// add spies
this.navigateSpy = spyOn(store, 'dispatch');
}
}

View File

@@ -0,0 +1,110 @@
import { Component, Inject, OnInit, } from '@angular/core';
import { Observable } from 'rxjs';
import { select, Store } from '@ngrx/store';
import { renderAuthMethodFor } from '../log-in.methods-decorator';
import { AuthMethodType } from '../../../../core/auth/models/auth.method-type';
import { AuthMethod } from '../../../../core/auth/models/auth.method';
import { CoreState } from '../../../../core/core.reducers';
import { isAuthenticated, isAuthenticationLoading } from '../../../../core/auth/selectors';
import { NativeWindowRef, NativeWindowService } from '../../../../core/services/window.service';
import { isNotNull, isEmpty } from '../../../empty.util';
import { AuthService } from '../../../../core/auth/auth.service';
import { HardRedirectService } from '../../../../core/services/hard-redirect.service';
import { take } from 'rxjs/operators';
import { URLCombiner } from '../../../../core/url-combiner/url-combiner';
@Component({
selector: 'ds-log-in-oidc',
templateUrl: './log-in-oidc.component.html',
})
@renderAuthMethodFor(AuthMethodType.Oidc)
export class LogInOidcComponent implements OnInit {
/**
* The authentication method data.
* @type {AuthMethod}
*/
public authMethod: AuthMethod;
/**
* True if the authentication is loading.
* @type {boolean}
*/
public loading: Observable<boolean>;
/**
* The oidc authentication location url.
* @type {string}
*/
public location: string;
/**
* Whether user is authenticated.
* @type {Observable<string>}
*/
public isAuthenticated: Observable<boolean>;
/**
* @constructor
* @param {AuthMethod} injectedAuthMethodModel
* @param {boolean} isStandalonePage
* @param {NativeWindowRef} _window
* @param {AuthService} authService
* @param {HardRedirectService} hardRedirectService
* @param {Store<State>} store
*/
constructor(
@Inject('authMethodProvider') public injectedAuthMethodModel: AuthMethod,
@Inject('isStandalonePage') public isStandalonePage: boolean,
@Inject(NativeWindowService) protected _window: NativeWindowRef,
private authService: AuthService,
private hardRedirectService: HardRedirectService,
private store: Store<CoreState>
) {
this.authMethod = injectedAuthMethodModel;
}
ngOnInit(): void {
// set isAuthenticated
this.isAuthenticated = this.store.pipe(select(isAuthenticated));
// set loading
this.loading = this.store.pipe(select(isAuthenticationLoading));
// set location
this.location = decodeURIComponent(this.injectedAuthMethodModel.location);
}
redirectToOidc() {
this.authService.getRedirectUrl().pipe(take(1)).subscribe((redirectRoute) => {
if (!this.isStandalonePage) {
redirectRoute = this.hardRedirectService.getCurrentRoute();
} else if (isEmpty(redirectRoute)) {
redirectRoute = '/';
}
const correctRedirectUrl = new URLCombiner(this._window.nativeWindow.origin, redirectRoute).toString();
let oidcServerUrl = this.location;
const myRegexp = /\?redirectUrl=(.*)/g;
const match = myRegexp.exec(this.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}`;
oidcServerUrl = this.location.replace(/\?redirectUrl=(.*)/g, newRedirectUrl);
}
// redirect to oidc authentication url
this.hardRedirectService.redirect(oidcServerUrl);
});
}
}

View File

@@ -7,7 +7,8 @@ export const MockWindow = {
get href() {
return this._href;
}
}
},
origin: 'http://localhost'
};
export class NativeWindowRefMock {

View File

@@ -0,0 +1,87 @@
import { SubmissionFormsConfigService } from '../../core/config/submission-forms-config.service';
import { SubmissionFormsModel } from '../../core/config/models/config-submission-forms.model';
import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils';
const configRes = Object.assign(new SubmissionFormsModel(), {
'id': 'AccessConditionDefaultConfiguration',
'canChangeDiscoverable': true,
'accessConditionOptions': [
{
'name': 'openaccess',
'hasStartDate': false,
'hasEndDate': false
},
{
'name': 'lease',
'hasStartDate': false,
'hasEndDate': true,
'maxEndDate': '2022-06-20T12:17:44.420+00:00'
},
{
'name': 'embargo',
'hasStartDate': true,
'hasEndDate': false,
'maxStartDate': '2024-12-20T12:17:44.420+00:00'
},
{
'name': 'administrator',
'hasStartDate': false,
'hasEndDate': false
}
],
'type': 'submissionaccessoption',
'_links': {
'self': {
'href': 'http://localhost:8080/server/api/config/submissionaccessoptions/AccessConditionDefaultConfiguration'
}
}
});
const configResNotChangeDiscoverable = Object.assign(new SubmissionFormsModel(), {
'id': 'AccessConditionDefaultConfiguration',
'canChangeDiscoverable': false,
'accessConditionOptions': [
{
'name': 'openaccess',
'hasStartDate': false,
'hasEndDate': false
},
{
'name': 'lease',
'hasStartDate': false,
'hasEndDate': true,
'maxEndDate': '2022-06-20T12:17:44.420+00:00'
},
{
'name': 'embargo',
'hasStartDate': true,
'hasEndDate': false,
'maxStartDate': '2024-12-20T12:17:44.420+00:00'
},
{
'name': 'administrator',
'hasStartDate': false,
'hasEndDate': false
}
],
'type': 'submissionaccessoption',
'_links': {
'self': {
'href': 'http://localhost:8080/server/api/config/submissionaccessoptions/AccessConditionDefaultConfiguration'
}
}
});
export function getSubmissionAccessesConfigService(): SubmissionFormsConfigService {
return jasmine.createSpyObj('SubmissionAccessesConfigService', {
findByHref: createSuccessfulRemoteDataObject$(configRes),
});
}
export function getSubmissionAccessesConfigNotChangeDiscoverableService(): SubmissionFormsConfigService {
return jasmine.createSpyObj('SubmissionAccessesConfigService', {
findByHref: createSuccessfulRemoteDataObject$(configResNotChangeDiscoverable),
});
}

View File

@@ -0,0 +1,13 @@
import { SubmissionFormsModel } from '../../core/config/models/config-submission-forms.model';
import { of as observableOf } from 'rxjs';
const dataRes = Object.assign(new SubmissionFormsModel(), {
'id': 'AccessConditionDefaultConfiguration',
'accessConditions': [],
});
export function getSectionAccessesService() {
return jasmine.createSpyObj('SectionAccessesService', {
getAccessesData: observableOf(dataRes),
});
}

View File

@@ -1654,7 +1654,7 @@ export const mockFileFormData = {
},
},
{
accessConditionGroup:{
accessConditionGroup: {
name: [
{
value: 'lease',
@@ -1723,3 +1723,94 @@ export const mockFileFormData = {
}
]
};
export const mockAccessesFormData = {
discoverable: true,
accessCondition: [
{
accessConditionGroup: {
name: [
{
value: 'openaccess',
language: null,
authority: null,
display: 'openaccess',
confidence: -1,
place: 0,
otherInformation: null
}
],
},
},
{
accessConditionGroup: {
name: [
{
value: 'lease',
language: null,
authority: null,
display: 'lease',
confidence: -1,
place: 0,
otherInformation: null
}
],
endDate: [
{
value: {
year: 2019,
month: 1,
day: 16
},
language: null,
authority: null,
display: {
year: 2019,
month: 1,
day: 16
},
confidence: -1,
place: 0,
otherInformation: null
}
],
}
},
{
accessConditionGroup: {
name: [
{
value: 'embargo',
language: null,
authority: null,
display: 'lease',
confidence: -1,
place: 0,
otherInformation: null
}
],
startDate: [
{
value: {
year: 2019,
month: 1,
day: 16
},
language: null,
authority: null,
display: {
year: 2019,
month: 1,
day: 16
},
confidence: -1,
place: 0,
otherInformation: null
}
],
}
}
]
};

View File

@@ -176,6 +176,7 @@ import { ItemVersionsDeleteModalComponent } from './item/item-versions/item-vers
import { ScopeSelectorModalComponent } from './search-form/scope-selector-modal/scope-selector-modal.component';
import { BitstreamRequestACopyPageComponent } from './bitstream-request-a-copy-page/bitstream-request-a-copy-page.component';
import { DsSelectComponent } from './ds-select/ds-select.component';
import { LogInOidcComponent } from './log-in/methods/oidc/log-in-oidc.component';
const MODULES = [
// Do NOT include UniversalModule, HttpModule, or JsonpModule here
@@ -307,6 +308,7 @@ const COMPONENTS = [
ImportableListItemControlComponent,
LogInShibbolethComponent,
LogInOidcComponent,
LogInPasswordComponent,
LogInContainerComponent,
ItemVersionsComponent,
@@ -378,6 +380,7 @@ const ENTRY_COMPONENTS = [
ItemMetadataRepresentationListElementComponent,
LogInPasswordComponent,
LogInShibbolethComponent,
LogInOidcComponent,
BundleListElementComponent,
ClaimedTaskActionsApproveComponent,
ClaimedTaskActionsRejectComponent,

View File

@@ -0,0 +1,100 @@
import { FormControl, FormGroup } from '@angular/forms';
import { DynamicCheckboxModel, DynamicSelectModel } from '@ng-dynamic-forms/core';
export const accessConditionChangeEvent = {
$event: {
bubbles: true,
cancelBubble: false,
cancelable: false,
composed: false,
currentTarget: null,
defaultPrevented: false,
eventPhase: 0,
isTrusted: true,
returnValue: true,
timeStamp: 143042.8999999999,
type: 'change',
},
context: null,
control: new FormControl({
errors: null,
pristine: false,
status: 'VALID',
statusChanges: { _isScalar: false, observers: [], closed: false, isStopped: false, hasError: false },
touched: true,
value: { year: 2021, month: 12, day: 30 },
valueChanges: { _isScalar: false, observers: [], closed: false, isStopped: false, hasError: false },
_updateOn: 'change',
}),
group: new FormGroup({}),
model: new DynamicSelectModel({
additional: null,
asyncValidators: null,
controlTooltip: null,
errorMessages: { required: 'submission.sections.upload.form.date-required-until' },
hidden: false,
hint: null,
id: 'endDate',
label: 'submission.sections.upload.form.until-label',
labelTooltip: null,
name: 'endDate',
placeholder: 'Until',
prefix: null,
relations: [],
required: true,
suffix: null,
tabIndex: null,
updateOn: null,
validators: { required: null },
}),
type: 'change'
};
export const checkboxChangeEvent = {
$event: {
bubbles: true,
cancelBubble: false,
cancelable: false,
composed: false,
currentTarget: null,
defaultPrevented: false,
eventPhase: 0,
isTrusted: true,
returnValue: true,
timeStamp: 143042.8999999999,
type: 'change',
},
context: null,
control: new FormControl({
errors: null,
pristine: false,
status: 'VALID',
statusChanges: { _isScalar: false, observers: [], closed: false, isStopped: false, hasError: false },
touched: true,
value: { year: 2021, month: 12, day: 30 },
valueChanges: { _isScalar: false, observers: [], closed: false, isStopped: false, hasError: false },
_updateOn: 'change',
}),
group: new FormGroup({}),
model: new DynamicCheckboxModel({
additional: null,
asyncValidators: null,
controlTooltip: null,
errorMessages: null,
hidden: false,
hint: null,
id: 'discoverable',
indeterminate: false,
label: 'Discoverable',
labelPosition: null,
labelTooltip: null,
name: 'discoverable',
relations: [],
required: false,
tabIndex: null,
updateOn: null,
validators: { required: null },
}),
type: 'change'
};

View File

@@ -29,7 +29,10 @@ export const routeServiceStub: any = {
return observableOf({});
},
getHistory: () => {
return observableOf(['/home','/collection/123','/home']);
return observableOf(['/home', '/collection/123', '/home']);
},
getPreviousUrl: () => {
return observableOf('/home');
}
/* tslint:enable:no-empty */
};

View File

@@ -155,7 +155,7 @@ export class SubmissionFormComponent implements OnChanges, OnDestroy {
// init submission state
this.subs.push(
this.halService.getEndpoint('workspaceitems').pipe(
this.halService.getEndpoint(this.submissionService.getSubmissionObjectLinkName()).pipe(
filter((href: string) => isNotEmpty(href)),
distinctUntilChanged())
.subscribe((endpointURL) => {

View File

@@ -205,7 +205,7 @@ export class SubmissionObjectEffects {
action.payload.submissionId,
'sections') as Observable<SubmissionObject[]>;
} else {
response$ = this.submissionObjectService.findById(action.payload.submissionId).pipe(
response$ = this.submissionObjectService.findById(action.payload.submissionId, false, true).pipe(
getFirstSucceededRemoteDataPayload(),
map((submissionObject: SubmissionObject) => [submissionObject])
);
@@ -356,6 +356,8 @@ export class SubmissionObjectEffects {
* The submission object retrieved from REST
* @param submissionId
* The submission id
* @param forms
* The forms state
* @param notify
* A boolean that indicate if show notification or not
* @return SubmissionObjectAction[]
@@ -365,7 +367,7 @@ export class SubmissionObjectEffects {
currentState: SubmissionObjectEntry,
response: SubmissionObject[],
submissionId: string,
forms,
forms: FormState,
notify: boolean = true): SubmissionObjectAction[] {
const mappedActions = [];

View File

@@ -0,0 +1,7 @@
<ds-form *ngIf="!!formModel" #formRef="formComponent"
[formId]="formId"
[formModel]="formModel"
[displaySubmit]="false"
[displayCancel]="false"
(dfChange)="onChange($event)"
(removeArrayItem)="onRemove($event)"></ds-form>

View File

@@ -0,0 +1,5 @@
::ng-deep .access-condition-group {
position: relative;
top: -2.3rem;
margin-bottom: -2.3rem;
}

View File

@@ -0,0 +1,204 @@
import { FormService } from '../../../shared/form/form.service';
import { ComponentFixture, inject, TestBed } from '@angular/core/testing';
import { SubmissionSectionAccessesComponent } from './section-accesses.component';
import { SectionsService } from '../sections.service';
import { SectionsServiceStub } from '../../../shared/testing/sections-service.stub';
import { FormBuilderService } from '../../../shared/form/builder/form-builder.service';
import { getMockFormBuilderService } from '../../../shared/mocks/form-builder-service.mock';
import { SubmissionAccessesConfigService } from '../../../core/config/submission-accesses-config.service';
import {
getSubmissionAccessesConfigNotChangeDiscoverableService,
getSubmissionAccessesConfigService
} from '../../../shared/mocks/section-accesses-config.service.mock';
import { SectionAccessesService } from './section-accesses.service';
import { SectionFormOperationsService } from '../form/section-form-operations.service';
import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { SubmissionJsonPatchOperationsService } from '../../../core/submission/submission-json-patch-operations.service';
import { getSectionAccessesService } from '../../../shared/mocks/section-accesses.service.mock';
import { getMockFormOperationsService } from '../../../shared/mocks/form-operations-service.mock';
import { getMockTranslateService } from '../../../shared/mocks/translate.service.mock';
import { SubmissionJsonPatchOperationsServiceStub } from '../../../shared/testing/submission-json-patch-operations-service.stub';
import { BrowserModule } from '@angular/platform-browser';
import { of as observableOf } from 'rxjs';
import { Store } from '@ngrx/store';
import { FormComponent } from '../../../shared/form/form.component';
import {
DynamicCheckboxModel,
DynamicDatePickerModel,
DynamicFormArrayModel,
DynamicSelectModel
} from '@ng-dynamic-forms/core';
import { AppState } from '../../../app.reducer';
import { getMockFormService } from '../../../shared/mocks/form-service.mock';
import { mockAccessesFormData } from '../../../shared/mocks/submission.mock';
import { accessConditionChangeEvent, checkboxChangeEvent } from '../../../shared/testing/form-event.stub';
describe('SubmissionSectionAccessesComponent', () => {
let component: SubmissionSectionAccessesComponent;
let fixture: ComponentFixture<SubmissionSectionAccessesComponent>;
const sectionsServiceStub = new SectionsServiceStub();
// const pathCombiner = new JsonPatchOperationPathCombiner('sections', sectionId, 'files', fileIndex);
const builderService: FormBuilderService = getMockFormBuilderService();
const submissionAccessesConfigService = getSubmissionAccessesConfigService();
const sectionAccessesService = getSectionAccessesService();
const sectionFormOperationsService = getMockFormOperationsService();
const operationsBuilder = jasmine.createSpyObj('operationsBuilder', {
add: undefined,
remove: undefined,
replace: undefined,
});
let formService: any;
const storeStub = jasmine.createSpyObj('store', ['dispatch']);
const sectionData = {
header: 'submit.progressbar.accessCondition',
config: 'http://localhost:8080/server/api/config/submissionaccessoptions/AccessConditionDefaultConfiguration',
mandatory: true,
sectionType: 'accessCondition',
collapsed: false,
enabled: true,
data: {
discoverable: true,
accessConditions: []
},
errorsToShow: [],
serverValidationErrors: [],
isLoading: false,
isValid: true
};
describe('First with canChangeDiscoverable true', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
BrowserModule,
TranslateModule.forRoot()
],
declarations: [SubmissionSectionAccessesComponent, FormComponent],
providers: [
{ provide: SectionsService, useValue: sectionsServiceStub },
{ provide: FormBuilderService, useValue: builderService },
{ provide: SubmissionAccessesConfigService, useValue: submissionAccessesConfigService },
{ provide: SectionAccessesService, useValue: sectionAccessesService },
{ provide: SectionFormOperationsService, useValue: sectionFormOperationsService },
{ provide: JsonPatchOperationsBuilder, useValue: operationsBuilder },
{ provide: TranslateService, useValue: getMockTranslateService() },
{ provide: FormService, useValue: getMockFormService() },
{ provide: Store, useValue: storeStub },
{ provide: SubmissionJsonPatchOperationsService, useValue: SubmissionJsonPatchOperationsServiceStub },
{ provide: 'sectionDataProvider', useValue: sectionData },
{ provide: 'submissionIdProvider', useValue: '1508' },
]
})
.compileComponents();
});
beforeEach(inject([Store], (store: Store<AppState>) => {
fixture = TestBed.createComponent(SubmissionSectionAccessesComponent);
component = fixture.componentInstance;
formService = TestBed.inject(FormService);
formService.validateAllFormFields.and.callFake(() => null);
formService.isValid.and.returnValue(observableOf(true));
formService.getFormData.and.returnValue(observableOf(mockAccessesFormData));
fixture.detectChanges();
}));
it('should create', () => {
expect(component).toBeTruthy();
});
it('should have created formModel', () => {
expect(component.formModel).toBeTruthy();
});
it('should have formModel length should be 2', () => {
expect(component.formModel.length).toEqual(2);
});
it('formModel should have 1 model type checkbox and 1 model type array', () => {
expect(component.formModel[0] instanceof DynamicCheckboxModel).toBeTrue();
expect(component.formModel[1] instanceof DynamicFormArrayModel).toBeTrue();
});
it('formModel type array should have formgroup with 1 input and 2 datepickers', () => {
const formModel: any = component.formModel[1];
const formGroup = formModel.groupFactory()[0].group;
expect(formGroup[0] instanceof DynamicSelectModel).toBeTrue();
expect(formGroup[1] instanceof DynamicDatePickerModel).toBeTrue();
expect(formGroup[2] instanceof DynamicDatePickerModel).toBeTrue();
});
it('when checkbox changed it should call operationsBuilder replace function', () => {
component.onChange(checkboxChangeEvent);
fixture.detectChanges();
expect(operationsBuilder.replace).toHaveBeenCalled();
});
it('when dropdown select changed it should call operationsBuilder add function', () => {
component.onChange(accessConditionChangeEvent);
fixture.detectChanges();
expect(operationsBuilder.add).toHaveBeenCalled();
});
});
describe('when canDescoverable is false', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
BrowserModule,
TranslateModule.forRoot()
],
declarations: [SubmissionSectionAccessesComponent, FormComponent],
providers: [
{ provide: SectionsService, useValue: sectionsServiceStub },
{ provide: FormBuilderService, useValue: builderService },
{ provide: SubmissionAccessesConfigService, useValue: getSubmissionAccessesConfigNotChangeDiscoverableService() },
{ provide: SectionAccessesService, useValue: sectionAccessesService },
{ provide: SectionFormOperationsService, useValue: sectionFormOperationsService },
{ provide: JsonPatchOperationsBuilder, useValue: operationsBuilder },
{ provide: TranslateService, useValue: getMockTranslateService() },
{ provide: FormService, useValue: getMockFormService() },
{ provide: Store, useValue: storeStub },
{ provide: SubmissionJsonPatchOperationsService, useValue: SubmissionJsonPatchOperationsServiceStub },
{ provide: 'sectionDataProvider', useValue: sectionData },
{ provide: 'submissionIdProvider', useValue: '1508' },
]
})
.compileComponents();
});
beforeEach(inject([Store], (store: Store<AppState>) => {
fixture = TestBed.createComponent(SubmissionSectionAccessesComponent);
component = fixture.componentInstance;
formService = TestBed.inject(FormService);
formService.validateAllFormFields.and.callFake(() => null);
formService.isValid.and.returnValue(observableOf(true));
formService.getFormData.and.returnValue(observableOf(mockAccessesFormData));
fixture.detectChanges();
}));
it('should have formModel length should be 1', () => {
expect(component.formModel.length).toEqual(1);
});
it('formModel should have only 1 model type array', () => {
expect(component.formModel[0] instanceof DynamicFormArrayModel).toBeTrue();
});
});
});

View File

@@ -0,0 +1,379 @@
import { SectionAccessesService } from './section-accesses.service';
import { Component, Inject, ViewChild } from '@angular/core';
import { FormControl } from '@angular/forms';
import { filter, map, mergeMap, take } from 'rxjs/operators';
import { combineLatest, Observable, of, Subscription } from 'rxjs';
import { TranslateService } from '@ngx-translate/core';
import { renderSectionFor } from '../sections-decorator';
import { SectionsType } from '../sections-type';
import { SectionDataObject } from '../models/section-data.model';
import { SectionsService } from '../sections.service';
import { SectionModelComponent } from '../models/section.model';
import {
DYNAMIC_FORM_CONTROL_TYPE_CHECKBOX,
DYNAMIC_FORM_CONTROL_TYPE_DATEPICKER,
DynamicCheckboxModel,
DynamicDatePickerModel,
DynamicFormArrayModel,
DynamicFormControlEvent,
DynamicFormControlModel,
DynamicFormGroupModel,
DynamicSelectModel,
MATCH_ENABLED,
OR_OPERATOR
} from '@ng-dynamic-forms/core';
import { FormBuilderService } from '../../../shared/form/builder/form-builder.service';
import {
ACCESS_CONDITION_GROUP_CONFIG,
ACCESS_CONDITION_GROUP_LAYOUT,
ACCESS_CONDITIONS_FORM_ARRAY_CONFIG,
ACCESS_CONDITIONS_FORM_ARRAY_LAYOUT,
ACCESS_FORM_CHECKBOX_CONFIG,
ACCESS_FORM_CHECKBOX_LAYOUT,
FORM_ACCESS_CONDITION_END_DATE_CONFIG,
FORM_ACCESS_CONDITION_END_DATE_LAYOUT,
FORM_ACCESS_CONDITION_START_DATE_CONFIG,
FORM_ACCESS_CONDITION_START_DATE_LAYOUT,
FORM_ACCESS_CONDITION_TYPE_CONFIG,
FORM_ACCESS_CONDITION_TYPE_LAYOUT
} from './section-accesses.model';
import { hasValue, isNotEmpty, isNotNull } from '../../../shared/empty.util';
import { WorkspaceitemSectionAccessesObject } from '../../../core/submission/models/workspaceitem-section-accesses.model';
import { SubmissionAccessesConfigService } from '../../../core/config/submission-accesses-config.service';
import { getFirstSucceededRemoteData } from '../../../core/shared/operators';
import { FormComponent } from '../../../shared/form/form.component';
import { FormService } from '../../../shared/form/form.service';
import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner';
import { SectionFormOperationsService } from '../form/section-form-operations.service';
import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder';
import { AccessesConditionOption } from '../../../core/config/models/config-accesses-conditions-options.model';
import { SubmissionJsonPatchOperationsService } from '../../../core/submission/submission-json-patch-operations.service';
import { dateToISOFormat } from '../../../shared/date.util';
/**
* This component represents a section for managing item's access conditions.
*/
@Component({
selector: 'ds-section-accesses',
templateUrl: './section-accesses.component.html',
styleUrls: ['./section-accesses.component.scss']
})
@renderSectionFor(SectionsType.AccessesCondition)
export class SubmissionSectionAccessesComponent extends SectionModelComponent {
/**
* The FormComponent reference
*/
@ViewChild('formRef') public formRef: FormComponent;
/**
* List of available access conditions that could be set to item
*/
public availableAccessConditionOptions: AccessesConditionOption[]; // List of accessConditions that an user can select
/**
* The form id
* @type {string}
*/
public formId: string;
/**
* The accesses section data
* @type {WorkspaceitemSectionAccessesObject}
*/
public accessesData: WorkspaceitemSectionAccessesObject;
/**
* The form model
* @type {DynamicFormControlModel[]}
*/
public formModel: DynamicFormControlModel[];
/**
* Array to track all subscriptions and unsubscribe them onDestroy
* @type {Array}
*/
protected subs: Subscription[] = [];
/**
* The [[JsonPatchOperationPathCombiner]] object
* @type {JsonPatchOperationPathCombiner}
*/
protected pathCombiner: JsonPatchOperationPathCombiner;
/**
* Defines if the access discoverable property can be managed
*/
public canChangeDiscoverable: boolean;
/**
* Initialize instance variables
*
* @param {SectionsService} sectionService
* @param {SectionDataObject} injectedSectionData
* @param {FormService} formService
* @param {JsonPatchOperationsBuilder} operationsBuilder
* @param {SectionFormOperationsService} formOperationsService
* @param {FormBuilderService} formBuilderService
* @param {TranslateService} translate
* @param {SubmissionAccessesConfigService} accessesConfigService
* @param {SectionAccessesService} accessesService
* @param {SubmissionJsonPatchOperationsService} operationsService
* @param {string} injectedSubmissionId
*/
constructor(
protected sectionService: SectionsService,
private formBuilderService: FormBuilderService,
private accessesConfigService: SubmissionAccessesConfigService,
private accessesService: SectionAccessesService,
protected formOperationsService: SectionFormOperationsService,
protected operationsBuilder: JsonPatchOperationsBuilder,
private formService: FormService,
private translate: TranslateService,
private operationsService: SubmissionJsonPatchOperationsService,
@Inject('sectionDataProvider') public injectedSectionData: SectionDataObject,
@Inject('submissionIdProvider') public injectedSubmissionId: string) {
super(undefined, injectedSectionData, injectedSubmissionId);
}
/**
* Initialize form model values
*
* @param formModel
* The form model
*/
public initModelData(formModel: DynamicFormControlModel[]) {
this.accessesData.accessConditions.forEach((accessCondition, index) => {
Array.of('name', 'startDate', 'endDate')
.filter((key) => accessCondition.hasOwnProperty(key) && isNotEmpty(accessCondition[key]))
.forEach((key) => {
const metadataModel: any = this.formBuilderService.findById(key, formModel, index);
if (metadataModel) {
if (metadataModel.type === DYNAMIC_FORM_CONTROL_TYPE_DATEPICKER) {
const date = new Date(accessCondition[key]);
metadataModel.value = {
year: date.getUTCFullYear(),
month: date.getUTCMonth() + 1,
day: date.getUTCDate()
};
} else {
metadataModel.value = accessCondition[key];
}
}
});
});
}
/**
* Method called when a form dfChange event is fired.
* Dispatch form operations based on changes.
*/
onChange(event: DynamicFormControlEvent) {
if (event.model.type === DYNAMIC_FORM_CONTROL_TYPE_CHECKBOX) {
const path = this.formOperationsService.getFieldPathSegmentedFromChangeEvent(event);
const value = this.formOperationsService.getFieldValueFromChangeEvent(event);
this.operationsBuilder.replace(this.pathCombiner.getPath(path), value.value, true);
} else {
if (event.model.id === FORM_ACCESS_CONDITION_TYPE_CONFIG.id) {
// Clear previous state when switching through different access conditions
const startDateControl: FormControl = event.control.parent.get('startDate') as FormControl;
const endDateControl: FormControl = event.control.parent.get('endDate') as FormControl;
startDateControl?.markAsUntouched();
endDateControl?.markAsUntouched();
startDateControl?.setValue(null);
endDateControl?.setValue(null);
event.control.parent.markAsDirty();
}
// validate form
this.formService.validateAllFormFields(this.formRef.formGroup);
this.formService.isValid(this.formId).pipe(
take(1),
filter((isValid) => isValid),
mergeMap(() => this.formService.getFormData(this.formId)),
take(1)
).subscribe((formData: any) => {
const accessConditionsToSave = [];
formData.accessCondition
.map((accessConditions) => accessConditions.accessConditionGroup)
.filter((accessCondition) => isNotEmpty(accessCondition))
.forEach((accessCondition) => {
let accessConditionOpt;
this.availableAccessConditionOptions
.filter((element) => isNotNull(accessCondition.name) && element.name === accessCondition.name[0].value)
.forEach((element) => accessConditionOpt = element);
if (accessConditionOpt) {
const currentAccessCondition = Object.assign({}, accessCondition);
currentAccessCondition.name = this.retrieveValueFromField(accessCondition.name);
/* When start and end date fields are deactivated, their values may be still present in formData,
therefore it is necessary to delete them if they're not allowed by the current access condition option. */
if (!accessConditionOpt.hasStartDate) {
delete currentAccessCondition.startDate;
} else if (accessCondition.startDate) {
const startDate = this.retrieveValueFromField(accessCondition.startDate);
currentAccessCondition.startDate = dateToISOFormat(startDate);
}
if (!accessConditionOpt.hasEndDate) {
delete currentAccessCondition.endDate;
} else if (accessCondition.endDate) {
const endDate = this.retrieveValueFromField(accessCondition.endDate);
currentAccessCondition.endDate = dateToISOFormat(endDate);
}
accessConditionsToSave.push(currentAccessCondition);
}
});
this.operationsBuilder.add(this.pathCombiner.getPath('accessConditions'), accessConditionsToSave, true);
});
}
}
/**
* Method called when a form removeArrayItem event is fired.
* Dispatch remove form operations based on changes.
*/
onRemove(event: DynamicFormControlEvent) {
const fieldIndex = this.formOperationsService.getArrayIndexFromEvent(event);
const fieldPath = 'accessConditions/' + fieldIndex;
this.operationsBuilder.remove(this.pathCombiner.getPath(fieldPath));
}
/**
* Unsubscribe from all subscriptions
*/
onSectionDestroy() {
this.subs
.filter((subscription) => hasValue(subscription))
.forEach((subscription) => subscription.unsubscribe());
}
/**
* Initialize all instance variables and retrieve collection default access conditions
*/
protected onSectionInit(): void {
this.pathCombiner = new JsonPatchOperationPathCombiner('sections', this.sectionData.id);
this.formId = this.formService.getUniqueId(this.sectionData.id);
const config$ = this.accessesConfigService.findByHref(this.sectionData.config, true, false).pipe(
getFirstSucceededRemoteData(),
map((config) => config.payload),
);
const accessData$ = this.accessesService.getAccessesData(this.submissionId, this.sectionData.id);
combineLatest([config$, accessData$]).subscribe(([config, accessData]) => {
this.availableAccessConditionOptions = isNotEmpty(config.accessConditionOptions) ? config.accessConditionOptions : [];
this.canChangeDiscoverable = !!config.canChangeDiscoverable;
this.accessesData = accessData;
this.formModel = this.buildFileEditForm();
});
}
/**
* Get section status
*
* @return Observable<boolean>
* the section status
*/
protected getSectionStatus(): Observable<boolean> {
return of(true);
}
/**
* Initialize form model
*/
protected buildFileEditForm() {
const formModel: DynamicFormControlModel[] = [];
if (this.canChangeDiscoverable) {
const discoverableCheckboxConfig = Object.assign({}, ACCESS_FORM_CHECKBOX_CONFIG, {
label: this.translate.instant('submission.sections.accesses.form.discoverable-label'),
hint: this.translate.instant('submission.sections.accesses.form.discoverable-description'),
value: this.accessesData.discoverable
});
formModel.push(
new DynamicCheckboxModel(discoverableCheckboxConfig, ACCESS_FORM_CHECKBOX_LAYOUT)
);
}
const accessConditionTypeModelConfig = Object.assign({}, FORM_ACCESS_CONDITION_TYPE_CONFIG);
const accessConditionsArrayConfig = Object.assign({}, ACCESS_CONDITIONS_FORM_ARRAY_CONFIG);
const accessConditionTypeOptions = [];
for (const accessCondition of this.availableAccessConditionOptions) {
accessConditionTypeOptions.push(
{
label: accessCondition.name,
value: accessCondition.name
}
);
}
accessConditionTypeModelConfig.options = accessConditionTypeOptions;
// Dynamically assign of relation in config. For startdate, endDate, groups.
const hasStart = [];
const hasEnd = [];
const hasGroups = [];
this.availableAccessConditionOptions.forEach((condition) => {
const showStart: boolean = condition.hasStartDate === true;
const showEnd: boolean = condition.hasEndDate === true;
const showGroups: boolean = showStart || showEnd;
if (showStart) {
hasStart.push({ id: 'name', value: condition.name });
}
if (showEnd) {
hasEnd.push({ id: 'name', value: condition.name });
}
if (showGroups) {
hasGroups.push({ id: 'name', value: condition.name });
}
});
const confStart = { relations: [{ match: MATCH_ENABLED, operator: OR_OPERATOR, when: hasStart }] };
const confEnd = { relations: [{ match: MATCH_ENABLED, operator: OR_OPERATOR, when: hasEnd }] };
accessConditionsArrayConfig.groupFactory = () => {
const type = new DynamicSelectModel(accessConditionTypeModelConfig, FORM_ACCESS_CONDITION_TYPE_LAYOUT);
const startDateConfig = Object.assign({}, FORM_ACCESS_CONDITION_START_DATE_CONFIG, confStart);
const endDateConfig = Object.assign({}, FORM_ACCESS_CONDITION_END_DATE_CONFIG, confEnd);
const startDate = new DynamicDatePickerModel(startDateConfig, FORM_ACCESS_CONDITION_START_DATE_LAYOUT);
const endDate = new DynamicDatePickerModel(endDateConfig, FORM_ACCESS_CONDITION_END_DATE_LAYOUT);
const accessConditionGroupConfig = Object.assign({}, ACCESS_CONDITION_GROUP_CONFIG);
accessConditionGroupConfig.group = [type];
if (hasStart.length > 0) {
accessConditionGroupConfig.group.push(startDate);
}
if (hasEnd.length > 0) {
accessConditionGroupConfig.group.push(endDate);
}
return [new DynamicFormGroupModel(accessConditionGroupConfig, ACCESS_CONDITION_GROUP_LAYOUT)];
};
// Number of access conditions blocks in form
accessConditionsArrayConfig.initialCount = isNotEmpty(this.accessesData.accessConditions) ? this.accessesData.accessConditions.length : 1;
formModel.push(
new DynamicFormArrayModel(accessConditionsArrayConfig, ACCESS_CONDITIONS_FORM_ARRAY_LAYOUT)
);
this.initModelData(formModel);
return formModel;
}
protected retrieveValueFromField(field: any) {
const temp = Array.isArray(field) ? field[0] : field;
return (temp) ? temp.value : undefined;
}
}

View File

@@ -0,0 +1,123 @@
import {
DynamicDatePickerModelConfig,
DynamicFormArrayModelConfig,
DynamicFormControlLayout,
DynamicFormGroupModelConfig,
DynamicSelectModelConfig,
MATCH_ENABLED,
OR_OPERATOR,
} from '@ng-dynamic-forms/core';
import { DynamicCheckboxModelConfig } from '@ng-dynamic-forms/core/lib/model/checkbox/dynamic-checkbox.model';
export const ACCESS_FORM_CHECKBOX_CONFIG: DynamicCheckboxModelConfig = {
id: 'discoverable',
name: 'discoverable'
};
export const ACCESS_FORM_CHECKBOX_LAYOUT = {
element: {
container: 'custom-control custom-checkbox pl-1',
control: 'custom-control-input',
label: 'custom-control-label pt-1'
}
};
export const ACCESS_CONDITION_GROUP_CONFIG: DynamicFormGroupModelConfig = {
id: 'accessConditionGroup',
group: []
};
export const ACCESS_CONDITION_GROUP_LAYOUT: DynamicFormControlLayout = {
element: {
host: 'form-group access-condition-group col',
container: 'pl-1 pr-1',
control: 'form-row '
}
};
export const ACCESS_CONDITIONS_FORM_ARRAY_CONFIG: DynamicFormArrayModelConfig = {
id: 'accessCondition',
groupFactory: null,
};
export const ACCESS_CONDITIONS_FORM_ARRAY_LAYOUT: DynamicFormControlLayout = {
grid: {
group: 'form-row pt-4',
}
};
export const FORM_ACCESS_CONDITION_TYPE_CONFIG: DynamicSelectModelConfig<any> = {
id: 'name',
label: 'submission.sections.accesses.form.access-condition-label',
hint: 'submission.sections.accesses.form.access-condition-hint',
options: []
};
export const FORM_ACCESS_CONDITION_TYPE_LAYOUT: DynamicFormControlLayout = {
element: {
host: 'col-12',
label: 'col-form-label name-label'
}
};
export const FORM_ACCESS_CONDITION_START_DATE_CONFIG: DynamicDatePickerModelConfig = {
id: 'startDate',
label: 'submission.sections.accesses.form.from-label',
hint: 'submission.sections.accesses.form.from-hint',
placeholder: 'submission.sections.accesses.form.from-placeholder',
inline: false,
toggleIcon: 'far fa-calendar-alt',
relations: [
{
match: MATCH_ENABLED,
operator: OR_OPERATOR,
when: []
}
],
required: true,
validators: {
required: null
},
errorMessages: {
required: 'submission.sections.accesses.form.date-required-from'
}
};
export const FORM_ACCESS_CONDITION_START_DATE_LAYOUT: DynamicFormControlLayout = {
element: {
label: 'col-form-label'
},
grid: {
host: 'col-6'
}
};
export const FORM_ACCESS_CONDITION_END_DATE_CONFIG: DynamicDatePickerModelConfig = {
id: 'endDate',
label: 'submission.sections.accesses.form.until-label',
hint: 'submission.sections.accesses.form.until-hint',
placeholder: 'submission.sections.accesses.form.until-placeholder',
inline: false,
toggleIcon: 'far fa-calendar-alt',
relations: [
{
match: MATCH_ENABLED,
operator: OR_OPERATOR,
when: []
}
],
required: true,
validators: {
required: null
},
errorMessages: {
required: 'submission.sections.accesses.form.date-required-until'
}
};
export const FORM_ACCESS_CONDITION_END_DATE_LAYOUT: DynamicFormControlLayout = {
element: {
label: 'col-form-label'
},
grid: {
host: 'col-6'
}
};

View File

@@ -0,0 +1,42 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { distinctUntilChanged, filter } from 'rxjs/operators';
import { Store } from '@ngrx/store';
import { SubmissionState } from '../../submission.reducers';
import { isNotUndefined } from '../../../shared/empty.util';
import { submissionSectionDataFromIdSelector } from '../../selectors';
import { WorkspaceitemSectionAccessesObject } from '../../../core/submission/models/workspaceitem-section-accesses.model';
/**
* A service that provides methods to handle submission item's accesses condition state.
*/
@Injectable()
export class SectionAccessesService {
/**
* Initialize service variables
*
* @param {Store<SubmissionState>} store
*/
constructor(private store: Store<SubmissionState>) { }
/**
* Return item's accesses condition state.
*
* @param submissionId
* The submission id
* @param sectionId
* The section id
* @returns {Observable}
* Emits bitstream's metadata
*/
public getAccessesData(submissionId: string, sectionId: string): Observable<WorkspaceitemSectionAccessesObject> {
return this.store.select(submissionSectionDataFromIdSelector(submissionId, sectionId)).pipe(
filter((state) => isNotUndefined(state)),
distinctUntilChanged());
}
}

View File

@@ -64,9 +64,9 @@ export class SubmissionSectionContainerComponent implements OnInit {
ngOnInit() {
this.objectInjector = Injector.create({
providers: [
{provide: 'collectionIdProvider', useFactory: () => (this.collectionId), deps: []},
{provide: 'sectionDataProvider', useFactory: () => (this.sectionData), deps: []},
{provide: 'submissionIdProvider', useFactory: () => (this.submissionId), deps: []},
{ provide: 'collectionIdProvider', useFactory: () => (this.collectionId), deps: [] },
{ provide: 'sectionDataProvider', useFactory: () => (this.sectionData), deps: [] },
{ provide: 'submissionIdProvider', useFactory: () => (this.submissionId), deps: [] },
],
parent: this.injector
});

View File

@@ -12,7 +12,7 @@ import { SubmissionServiceStub } from '../../../shared/testing/submission-servic
import { getMockTranslateService } from '../../../shared/mocks/translate.service.mock';
import { SectionsService } from '../sections.service';
import { SectionsServiceStub } from '../../../shared/testing/sections-service.stub';
import { SubmissionSectionformComponent } from './section-form.component';
import { SubmissionSectionFormComponent } from './section-form.component';
import { FormBuilderService } from '../../../shared/form/builder/form-builder.service';
import { getMockFormBuilderService } from '../../../shared/mocks/form-builder-service.mock';
import { getMockFormOperationsService } from '../../../shared/mocks/form-operations-service.mock';
@@ -137,11 +137,11 @@ const dynamicFormControlEvent: DynamicFormControlEvent = {
type: DynamicFormControlEventType.Change
};
describe('SubmissionSectionformComponent test suite', () => {
describe('SubmissionSectionFormComponent test suite', () => {
let comp: SubmissionSectionformComponent;
let comp: SubmissionSectionFormComponent;
let compAsAny: any;
let fixture: ComponentFixture<SubmissionSectionformComponent>;
let fixture: ComponentFixture<SubmissionSectionFormComponent>;
let submissionServiceStub: SubmissionServiceStub;
let notificationsServiceStub: NotificationsServiceStub;
let formService: any = getMockFormService();
@@ -167,7 +167,7 @@ describe('SubmissionSectionformComponent test suite', () => {
],
declarations: [
FormComponent,
SubmissionSectionformComponent,
SubmissionSectionFormComponent,
TestComponent
],
providers: [
@@ -186,7 +186,7 @@ describe('SubmissionSectionformComponent test suite', () => {
{ provide: 'submissionIdProvider', useValue: submissionId },
{ provide: SubmissionObjectDataService, useValue: { getHrefByID: () => observableOf('testUrl'), findById: () => createSuccessfulRemoteDataObject$(new WorkspaceItem()) } },
ChangeDetectorRef,
SubmissionSectionformComponent
SubmissionSectionFormComponent
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents().then();
@@ -215,7 +215,7 @@ describe('SubmissionSectionformComponent test suite', () => {
testFixture.destroy();
});
it('should create SubmissionSectionformComponent', inject([SubmissionSectionformComponent], (app: SubmissionSectionformComponent) => {
it('should create SubmissionSectionFormComponent', inject([SubmissionSectionFormComponent], (app: SubmissionSectionFormComponent) => {
expect(app).toBeDefined();
@@ -224,7 +224,7 @@ describe('SubmissionSectionformComponent test suite', () => {
describe('', () => {
beforeEach(() => {
fixture = TestBed.createComponent(SubmissionSectionformComponent);
fixture = TestBed.createComponent(SubmissionSectionFormComponent);
comp = fixture.componentInstance;
compAsAny = comp;
submissionServiceStub = TestBed.inject(SubmissionService as any);

View File

@@ -44,7 +44,7 @@ import { RemoteData } from '../../../core/data/remote-data';
templateUrl: './section-form.component.html',
})
@renderSectionFor(SectionsType.SubmissionForm)
export class SubmissionSectionformComponent extends SectionModelComponent {
export class SubmissionSectionFormComponent extends SectionModelComponent {
/**
* The form id

View File

@@ -59,8 +59,8 @@ export abstract class SectionModelComponent implements OnDestroy, OnInit, Sectio
* @param {string} injectedSubmissionId
*/
public constructor(@Inject('collectionIdProvider') public injectedCollectionId: string,
@Inject('sectionDataProvider') public injectedSectionData: SectionDataObject,
@Inject('submissionIdProvider') public injectedSubmissionId: string) {
@Inject('sectionDataProvider') public injectedSectionData: SectionDataObject,
@Inject('submissionIdProvider') public injectedSubmissionId: string) {
this.collectionId = injectedCollectionId;
this.sectionData = injectedSectionData;
this.submissionId = injectedSubmissionId;

View File

@@ -4,5 +4,6 @@ export enum SectionsType {
Upload = 'upload',
License = 'license',
CcLicense = 'cclicense',
collection = 'collection'
collection = 'collection',
AccessesCondition = 'accessCondition',
}

View File

@@ -28,7 +28,7 @@ export const BITSTREAM_ACCESS_CONDITION_GROUP_CONFIG: DynamicFormGroupModelConfi
export const BITSTREAM_ACCESS_CONDITION_GROUP_LAYOUT: DynamicFormControlLayout = {
element: {
host: 'form-group flex-fill access-condition-group',
host: 'form-group access-condition-group col',
container: 'pl-1 pr-1',
control: 'form-row '
}
@@ -47,6 +47,7 @@ export const BITSTREAM_ACCESS_CONDITIONS_FORM_ARRAY_LAYOUT: DynamicFormControlLa
export const BITSTREAM_FORM_ACCESS_CONDITION_TYPE_CONFIG: DynamicSelectModelConfig<any> = {
id: 'name',
label: 'submission.sections.upload.form.access-condition-label',
hint: 'submission.sections.upload.form.access-condition-hint',
options: []
};
export const BITSTREAM_FORM_ACCESS_CONDITION_TYPE_LAYOUT: DynamicFormControlLayout = {
@@ -59,6 +60,7 @@ export const BITSTREAM_FORM_ACCESS_CONDITION_TYPE_LAYOUT: DynamicFormControlLayo
export const BITSTREAM_FORM_ACCESS_CONDITION_START_DATE_CONFIG: DynamicDatePickerModelConfig = {
id: 'startDate',
label: 'submission.sections.upload.form.from-label',
hint: 'submission.sections.upload.form.from-hint',
placeholder: 'submission.sections.upload.form.from-placeholder',
inline: false,
toggleIcon: 'far fa-calendar-alt',
@@ -89,6 +91,7 @@ export const BITSTREAM_FORM_ACCESS_CONDITION_START_DATE_LAYOUT: DynamicFormContr
export const BITSTREAM_FORM_ACCESS_CONDITION_END_DATE_CONFIG: DynamicDatePickerModelConfig = {
id: 'endDate',
label: 'submission.sections.upload.form.until-label',
hint: 'submission.sections.upload.form.until-hint',
placeholder: 'submission.sections.upload.form.until-placeholder',
inline: false,
toggleIcon: 'far fa-calendar-alt',

View File

@@ -174,7 +174,7 @@ export class SubmissionSectionUploadFileComponent implements OnChanges, OnInit {
this.subscriptions.push(
this.uploadService
.getFileData(this.submissionId, this.sectionId, this.fileId).pipe(
filter((bitstream) => isNotUndefined(bitstream)))
filter((bitstream) => isNotUndefined(bitstream)))
.subscribe((bitstream) => {
this.fileData = bitstream;
}
@@ -251,12 +251,12 @@ export class SubmissionSectionUploadFileComponent implements OnChanges, OnInit {
protected loadFormMetadata() {
this.configMetadataForm.rows.forEach((row) => {
row.fields.forEach((field) => {
field.selectableMetadata.forEach((metadatum) => {
this.formMetadata.push(metadatum.metadata);
});
row.fields.forEach((field) => {
field.selectableMetadata.forEach((metadatum) => {
this.formMetadata.push(metadatum.metadata);
});
}
});
}
);
}

View File

@@ -45,27 +45,28 @@ export function submissionUploadedFilesFromIdSelector(submissionId: string, sect
}
export function submissionUploadedFileFromUuidSelector(submissionId: string, sectionId: string, uuid: string): MemoizedSelector<SubmissionState, any> {
const filesSelector = submissionSectionDataFromIdSelector(submissionId, sectionId);
const filesSelector = submissionSectionDataFromIdSelector(submissionId, sectionId);
return keySelector<SubmissionState, any>(filesSelector, 'files', uuid);
}
export function submissionSectionFromIdSelector(submissionId: string, sectionId: string): MemoizedSelector<SubmissionState, any> {
const submissionIdSelector = submissionObjectFromIdSelector(submissionId);
const submissionIdSelector = submissionObjectFromIdSelector(submissionId);
return keySelector<SubmissionState, SubmissionObjectEntry>(submissionIdSelector, 'sections', sectionId);
}
export function submissionSectionDataFromIdSelector(submissionId: string, sectionId: string): MemoizedSelector<SubmissionState, any> {
const submissionIdSelector = submissionSectionFromIdSelector(submissionId, sectionId);
const submissionIdSelector = submissionSectionFromIdSelector(submissionId, sectionId);
return subStateSelector<SubmissionState, SubmissionSectionObject>(submissionIdSelector, 'data');
}
export function submissionSectionErrorsFromIdSelector(submissionId: string, sectionId: string): MemoizedSelector<SubmissionState, any> {
const submissionIdSelector = submissionSectionFromIdSelector(submissionId, sectionId);
const submissionIdSelector = submissionSectionFromIdSelector(submissionId, sectionId);
return subStateSelector<SubmissionState, SubmissionSectionObject>(submissionIdSelector, 'errorsToShow');
}
export function submissionSectionServerErrorsFromIdSelector(submissionId: string, sectionId: string): MemoizedSelector<SubmissionState, any> {
const submissionIdSelector = submissionSectionFromIdSelector(submissionId, sectionId);
const submissionIdSelector = submissionSectionFromIdSelector(submissionId, sectionId);
return subStateSelector<SubmissionState, SubmissionSectionObject>(submissionIdSelector, 'serverValidationErrors');
}

View File

@@ -2,7 +2,7 @@ import { NgModule } from '@angular/core';
import { CoreModule } from '../core/core.module';
import { SharedModule } from '../shared/shared.module';
import { SubmissionSectionformComponent } from './sections/form/section-form.component';
import { SubmissionSectionFormComponent } from './sections/form/section-form.component';
import { SectionsDirective } from './sections/sections.directive';
import { SectionsService } from './sections/sections.service';
import { SubmissionFormCollectionComponent } from './form/collection/submission-form-collection.component';
@@ -38,14 +38,23 @@ import { ThemedSubmissionEditComponent } from './edit/themed-submission-edit.com
import { ThemedSubmissionSubmitComponent } from './submit/themed-submission-submit.component';
import { ThemedSubmissionImportExternalComponent } from './import-external/themed-submission-import-external.component';
import { FormModule } from '../shared/form/form.module';
import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap';
import { NgbAccordionModule, NgbModalModule } from '@ng-bootstrap/ng-bootstrap';
import { SubmissionSectionAccessesComponent } from './sections/accesses/section-accesses.component';
import { SubmissionAccessesConfigService } from '../core/config/submission-accesses-config.service';
import { SectionAccessesService } from './sections/accesses/section-accesses.service';
const DECLARATIONS = [
SubmissionSectionUploadAccessConditionsComponent,
const ENTRY_COMPONENTS = [
// put only entry components that use custom decorator
SubmissionSectionUploadComponent,
SubmissionSectionformComponent,
SubmissionSectionFormComponent,
SubmissionSectionLicenseComponent,
SubmissionSectionCcLicensesComponent,
SubmissionSectionAccessesComponent,
SubmissionSectionUploadFileEditComponent
];
const DECLARATIONS = [
...ENTRY_COMPONENTS,
SectionsDirective,
SubmissionEditComponent,
ThemedSubmissionEditComponent,
@@ -57,6 +66,7 @@ const DECLARATIONS = [
ThemedSubmissionSubmitComponent,
SubmissionUploadFilesComponent,
SubmissionSectionContainerComponent,
SubmissionSectionUploadAccessConditionsComponent,
SubmissionSectionUploadFileComponent,
SubmissionSectionUploadFileEditComponent,
SubmissionSectionUploadFileViewComponent,
@@ -64,34 +74,30 @@ const DECLARATIONS = [
ThemedSubmissionImportExternalComponent,
SubmissionImportExternalSearchbarComponent,
SubmissionImportExternalPreviewComponent,
SubmissionImportExternalCollectionComponent
];
const ENTRY_COMPONENTS = [
SubmissionSectionUploadComponent,
SubmissionSectionformComponent,
SubmissionSectionLicenseComponent,
SubmissionSectionCcLicensesComponent
SubmissionImportExternalCollectionComponent,
];
@NgModule({
imports: [
CommonModule,
CoreModule.forRoot(),
SharedModule,
StoreModule.forFeature('submission', submissionReducers, storeModuleConfig as StoreConfig<SubmissionState, Action>),
EffectsModule.forFeature(submissionEffects),
JournalEntitiesModule.withEntryComponents(),
ResearchEntitiesModule.withEntryComponents(),
FormModule,
NgbAccordionModule
],
imports: [
CommonModule,
CoreModule.forRoot(),
SharedModule,
StoreModule.forFeature('submission', submissionReducers, storeModuleConfig as StoreConfig<SubmissionState, Action>),
EffectsModule.forFeature(submissionEffects),
JournalEntitiesModule.withEntryComponents(),
ResearchEntitiesModule.withEntryComponents(),
FormModule,
NgbAccordionModule,
NgbModalModule
],
declarations: DECLARATIONS,
exports: DECLARATIONS,
providers: [
SectionUploadService,
SectionsService,
SubmissionUploadsConfigService
SubmissionUploadsConfigService,
SubmissionAccessesConfigService,
SectionAccessesService
]
})
@@ -106,7 +112,7 @@ export class SubmissionModule {
static withEntryComponents() {
return {
ngModule: SubmissionModule,
providers: ENTRY_COMPONENTS.map((component) => ({provide: component}))
providers: ENTRY_COMPONENTS.map((component) => ({ provide: component }))
};
}
}

View File

@@ -587,6 +587,23 @@
"bitstream.edit.notifications.error.format.title": "An error occurred saving the bitstream's format",
"bitstream.edit.form.iiifLabel.label": "IIIF Label",
"bitstream.edit.form.iiifLabel.hint": "Canvas label for this image. If not provided default label will be used.",
"bitstream.edit.form.iiifToc.label": "IIIF Table of Contents",
"bitstream.edit.form.iiifToc.hint": "Adding text here makes this the start of a new table of contents range.",
"bitstream.edit.form.iiifWidth.label": "IIIF Canvas Width",
"bitstream.edit.form.iiifWidth.hint": "The canvas width should usually match the image width.",
"bitstream.edit.form.iiifHeight.label": "IIIF Canvas Height",
"bitstream.edit.form.iiifHeight.hint": "The canvas height should usually match the image height.",
"bitstream.edit.notifications.saved.content": "Your changes to this bitstream were saved.",
"bitstream.edit.notifications.saved.title": "Bitstream saved",
@@ -1377,6 +1394,8 @@
"footer.link.end-user-agreement":"End User Agreement",
"footer.link.feedback":"Send Feedback",
"forgot-email.form.header": "Forgot Password",
@@ -1575,6 +1594,32 @@
"info.privacy.title": "Privacy Statement",
"info.feedback.breadcrumbs": "Feedback",
"info.feedback.head": "Feedback",
"info.feedback.title": "Feedback",
"info.feedback.info": "Thanks for sharing your feedback about the DSpace system. Your comments are appreciated!",
"info.feedback.email_help": "This address will be used to follow up on your feedback.",
"info.feedback.send": "Send Feedback",
"info.feedback.comments": "Comments",
"info.feedback.email-label": "Your Email",
"info.feedback.create.success" : "Feedback Sent Successfully!",
"info.feedback.error.email.required" : "A valid email address is required",
"info.feedback.error.message.required" : "A comment is required",
"info.feedback.page-label" : "Page",
"info.feedback.page_help" : "Tha page related to your feedback",
"item.alerts.private": "This item is private",
@@ -2341,6 +2386,8 @@
"login.form.or-divider": "or",
"login.form.oidc": "Log in with OIDC",
"login.form.password": "Password",
"login.form.shibboleth": "Log in with Shibboleth",
@@ -3806,6 +3853,8 @@
"submission.sections.submit.progressbar.accessCondition": "Item access conditions",
"submission.sections.submit.progressbar.CClicense": "Creative commons license",
"submission.sections.submit.progressbar.describe.recycle": "Recycle",
@@ -3862,6 +3911,8 @@
"submission.sections.upload.form.access-condition-label": "Access condition type",
"submission.sections.upload.form.access-condition-hint": "Select an access condition to apply on the bitstream once the item is deposited",
"submission.sections.upload.form.date-required": "Date is required.",
"submission.sections.upload.form.date-required-from": "Grant access from date is required.",
@@ -3870,6 +3921,8 @@
"submission.sections.upload.form.from-label": "Grant access from",
"submission.sections.upload.form.from-hint": "Select the date from which the related access condition is applied",
"submission.sections.upload.form.from-placeholder": "From",
"submission.sections.upload.form.group-label": "Group",
@@ -3878,6 +3931,8 @@
"submission.sections.upload.form.until-label": "Grant access until",
"submission.sections.upload.form.until-hint": "Select the date until which the related access condition is applied",
"submission.sections.upload.form.until-placeholder": "Until",
"submission.sections.upload.header.policy.default.nolist": "Uploaded files in the {{collectionName}} collection will be accessible according to the following group(s):",
@@ -3898,6 +3953,35 @@
"submission.sections.upload.upload-successful": "Upload successful",
"submission.sections.accesses.form.discoverable-description": "When checked, this item will be discoverable in search/browse. When unchecked, the item will only be available via a direct link and will never appear in search/browse.",
"submission.sections.accesses.form.discoverable-label": "Discoverable",
"submission.sections.accesses.form.access-condition-label": "Access condition type",
"submission.sections.accesses.form.access-condition-hint": "Select an access condition to apply on the item once it is deposited",
"submission.sections.accesses.form.date-required": "Date is required.",
"submission.sections.accesses.form.date-required-from": "Grant access from date is required.",
"submission.sections.accesses.form.date-required-until": "Grant access until date is required.",
"submission.sections.accesses.form.from-label": "Grant access from",
"submission.sections.accesses.form.from-hint": "Select the date from which the related access condition is applied",
"submission.sections.accesses.form.from-placeholder": "From",
"submission.sections.accesses.form.group-label": "Group",
"submission.sections.accesses.form.group-required": "Group is required.",
"submission.sections.accesses.form.until-label": "Grant access until",
"submission.sections.accesses.form.until-hint": "Select the date until which the related access condition is applied",
"submission.sections.accesses.form.until-placeholder": "Until",
"submission.submit.breadcrumbs": "New submission",

6076
src/assets/i18n/gd.json5 Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -184,6 +184,7 @@ export class DefaultAppConfig implements AppConfig {
{ code: 'de', label: 'Deutsch', active: true },
{ code: 'es', label: 'Español', active: true },
{ code: 'fr', label: 'Français', active: true },
{ code: 'gd', label: 'Gàidhlig', active: true },
{ code: 'lv', label: 'Latviešu', active: true },
{ code: 'hu', label: 'Magyar', active: true },
{ code: 'nl', label: 'Nederlands', active: true },

View File

@@ -0,0 +1,15 @@
import { Component } from '@angular/core';
import { FeedbackComponent as BaseComponent } from '../../../../../app/info/feedback/feedback.component';
@Component({
selector: 'ds-feedback',
// styleUrls: ['./feedback.component.scss'],
styleUrls: ['../../../../../app/info/feedback/feedback.component.scss'],
// templateUrl: './feedback.component.html'
templateUrl: '../../../../../app/info/feedback/feedback.component.html'
})
/**
* Component displaying the feedback Statement
*/
export class FeedbackComponent extends BaseComponent { }

View File

@@ -83,6 +83,7 @@ import { FileSectionComponent } from './app/item-page/simple/field-components/fi
import { SearchModule } from '../../app/shared/search/search.module';
import { ResourcePoliciesModule } from '../../app/shared/resource-policies/resource-policies.module';
import { ComcolModule } from '../../app/shared/comcol/comcol.module';
import { FeedbackComponent } from './app/info/feedback/feedback.component';
const DECLARATIONS = [
FileSectionComponent,
@@ -124,7 +125,8 @@ const DECLARATIONS = [
HeaderComponent,
NavbarComponent,
HeaderNavbarWrapperComponent,
BreadcrumbsComponent
BreadcrumbsComponent,
FeedbackComponent
];
@NgModule({