From d3466c3e8215dc0012b1e3f063aafde9ecf67de9 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Wed, 12 May 2021 17:59:11 +0200 Subject: [PATCH 01/97] Fix issue where uploaded files disappear --- src/app/submission/form/submission-form.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/submission/form/submission-form.component.ts b/src/app/submission/form/submission-form.component.ts index 8df0ab1658..6d4ddb4ca0 100644 --- a/src/app/submission/form/submission-form.component.ts +++ b/src/app/submission/form/submission-form.component.ts @@ -122,7 +122,7 @@ export class SubmissionFormComponent implements OnChanges, OnDestroy { * Initialize all instance variables and retrieve form configuration */ ngOnChanges(changes: SimpleChanges) { - if (this.collectionId && this.submissionId) { + if ((changes.collectionId && this.collectionId) && (changes.submissionId && this.submissionId)) { this.isActive = true; // retrieve submission's section list From d06b76af3f850ec81b599798b2076b7b37989d14 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Wed, 19 May 2021 15:04:12 +0200 Subject: [PATCH 02/97] Fix issue with patching value with a date --- .../json-patch/builder/json-patch-operations-builder.ts | 4 +++- src/app/shared/date.util.ts | 9 +++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/app/core/json-patch/builder/json-patch-operations-builder.ts b/src/app/core/json-patch/builder/json-patch-operations-builder.ts index ced3750834..d3896c4a6c 100644 --- a/src/app/core/json-patch/builder/json-patch-operations-builder.ts +++ b/src/app/core/json-patch/builder/json-patch-operations-builder.ts @@ -9,7 +9,7 @@ import { import { JsonPatchOperationPathObject } from './json-patch-operation-path-combiner'; import { Injectable } from '@angular/core'; import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util'; -import { dateToISOFormat } from '../../../shared/date.util'; +import { dateToISOFormat, dateToString, isNgbDateStruct } from '../../../shared/date.util'; import { VocabularyEntry } from '../../submission/vocabularies/models/vocabulary-entry.model'; import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model'; import { FormFieldLanguageValueObject } from '../../../shared/form/builder/models/form-field-language-value.model'; @@ -136,6 +136,8 @@ export class JsonPatchOperationsBuilder { operationValue = new FormFieldMetadataValueObject(value.value, value.language); } else if (value.hasOwnProperty('authority')) { operationValue = new FormFieldMetadataValueObject(value.value, value.language, value.authority); + } else if (isNgbDateStruct(value)) { + operationValue = new FormFieldMetadataValueObject(dateToString(value)); } else if (value.hasOwnProperty('value')) { operationValue = new FormFieldMetadataValueObject(value.value); } else { diff --git a/src/app/shared/date.util.ts b/src/app/shared/date.util.ts index 063820784c..44afdd10a4 100644 --- a/src/app/shared/date.util.ts +++ b/src/app/shared/date.util.ts @@ -3,7 +3,7 @@ import { NgbDateStruct } from '@ng-bootstrap/ng-bootstrap'; import { isObject } from 'lodash'; import * as moment from 'moment'; -import { isNull } from './empty.util'; +import { isNull, isUndefined } from './empty.util'; /** * Returns true if the passed value is a NgbDateStruct. @@ -27,8 +27,9 @@ export function isNgbDateStruct(value: object): boolean { * @return string * the formatted date */ -export function dateToISOFormat(date: Date | NgbDateStruct): string { - const dateObj: Date = (date instanceof Date) ? date : ngbDateStructToDate(date); +export function dateToISOFormat(date: Date | NgbDateStruct | string): string { + const dateObj: Date = (date instanceof Date) ? date : + ((typeof date === 'string') ? ngbDateStructToDate(stringToNgbDateStruct(date)) : ngbDateStructToDate(date)); let year = dateObj.getFullYear().toString(); let month = (dateObj.getMonth() + 1).toString(); @@ -80,7 +81,7 @@ export function stringToNgbDateStruct(date: string): NgbDateStruct { * the NgbDateStruct object */ export function dateToNgbDateStruct(date?: Date): NgbDateStruct { - if (isNull(date)) { + if (isNull(date) || isUndefined(date)) { date = new Date(); } From e0edcd64d2dcedd695ae2634f845a58b1f54d7f7 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Wed, 19 May 2021 15:35:37 +0200 Subject: [PATCH 03/97] Fix wrong visualization of bitstream access condition form within submission form --- src/app/shared/mocks/submission.mock.ts | 154 +++++++++--------- .../section-upload-file-edit.component.scss | 6 + .../section-upload-file-edit.component.ts | 8 +- .../edit/section-upload-file-edit.model.ts | 29 ++-- .../file/section-upload-file.component.ts | 1 + 5 files changed, 110 insertions(+), 88 deletions(-) create mode 100644 src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.scss diff --git a/src/app/shared/mocks/submission.mock.ts b/src/app/shared/mocks/submission.mock.ts index 1ee097af71..eaebb38df8 100644 --- a/src/app/shared/mocks/submission.mock.ts +++ b/src/app/shared/mocks/submission.mock.ts @@ -1519,83 +1519,87 @@ export const mockFileFormData = { }, accessConditions: [ { - name: [ - { - value: 'openaccess', - language: null, - authority: null, - display: 'openaccess', - confidence: -1, - place: 0, - otherInformation: null - } - ], - } - , + accessConditionGroup: { + name: [ + { + value: 'openaccess', + language: null, + authority: null, + display: 'openaccess', + confidence: -1, + place: 0, + otherInformation: null + } + ], + }, + }, { - 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: '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 + } + ], + } + }, { - 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 - } - ], + 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 + } + ], + } } ] }; diff --git a/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.scss b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.scss new file mode 100644 index 0000000000..b443db711b --- /dev/null +++ b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.scss @@ -0,0 +1,6 @@ + +::ng-deep .access-condition-group { + position: relative; + top: -2.3rem; + margin-bottom: -2.3rem; +} diff --git a/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.ts b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.ts index 512453d84e..cfece7a5fe 100644 --- a/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.ts +++ b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.ts @@ -18,6 +18,8 @@ import { import { WorkspaceitemSectionUploadFileObject } from '../../../../../core/submission/models/workspaceitem-section-upload-file.model'; import { FormBuilderService } from '../../../../../shared/form/builder/form-builder.service'; import { + BITSTREAM_ACCESS_CONDITION_GROUP_CONFIG, + BITSTREAM_ACCESS_CONDITION_GROUP_LAYOUT, BITSTREAM_ACCESS_CONDITIONS_FORM_ARRAY_CONFIG, BITSTREAM_ACCESS_CONDITIONS_FORM_ARRAY_LAYOUT, BITSTREAM_FORM_ACCESS_CONDITION_END_DATE_CONFIG, @@ -43,6 +45,7 @@ import { FormComponent } from '../../../../../shared/form/form.component'; */ @Component({ selector: 'ds-submission-section-upload-file-edit', + styleUrls: ['./section-upload-file-edit.component.scss'], templateUrl: './section-upload-file-edit.component.html', }) export class SubmissionSectionUploadFileEditComponent implements OnChanges { @@ -209,8 +212,9 @@ export class SubmissionSectionUploadFileEditComponent implements OnChanges { const startDate = new DynamicDatePickerModel(startDateConfig, BITSTREAM_FORM_ACCESS_CONDITION_START_DATE_LAYOUT); const endDate = new DynamicDatePickerModel(endDateConfig, BITSTREAM_FORM_ACCESS_CONDITION_END_DATE_LAYOUT); - - return [type, startDate, endDate]; + const accessConditionGroupConfig = Object.assign({}, BITSTREAM_ACCESS_CONDITION_GROUP_CONFIG); + accessConditionGroupConfig.group = [type, startDate, endDate]; + return [new DynamicFormGroupModel(accessConditionGroupConfig, BITSTREAM_ACCESS_CONDITION_GROUP_LAYOUT)]; }; // Number of access conditions blocks in form diff --git a/src/app/submission/sections/upload/file/edit/section-upload-file-edit.model.ts b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.model.ts index 096954659e..300a4b461f 100644 --- a/src/app/submission/sections/upload/file/edit/section-upload-file-edit.model.ts +++ b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.model.ts @@ -15,12 +15,24 @@ export const BITSTREAM_METADATA_FORM_GROUP_CONFIG: DynamicFormGroupModelConfig = export const BITSTREAM_METADATA_FORM_GROUP_LAYOUT: DynamicFormControlLayout = { element: { container: 'form-group', - label: 'col-form-label' + label: 'col-form-label' }, grid: { label: 'col-sm-3' } }; +export const BITSTREAM_ACCESS_CONDITION_GROUP_CONFIG: DynamicFormGroupModelConfig = { + id: 'accessConditionGroup', + group: [] +}; + +export const BITSTREAM_ACCESS_CONDITION_GROUP_LAYOUT: DynamicFormControlLayout = { + element: { + host: 'form-group flex-fill access-condition-group', + container: 'pl-1 pr-1', + control: 'form-row ' + } +}; export const BITSTREAM_ACCESS_CONDITIONS_FORM_ARRAY_CONFIG: DynamicFormArrayModelConfig = { id: 'accessConditions', @@ -28,7 +40,7 @@ export const BITSTREAM_ACCESS_CONDITIONS_FORM_ARRAY_CONFIG: DynamicFormArrayMode }; export const BITSTREAM_ACCESS_CONDITIONS_FORM_ARRAY_LAYOUT: DynamicFormControlLayout = { grid: { - group: 'form-row' + group: 'form-row pt-4', } }; @@ -39,11 +51,8 @@ export const BITSTREAM_FORM_ACCESS_CONDITION_TYPE_CONFIG: DynamicSelectModelConf }; export const BITSTREAM_FORM_ACCESS_CONDITION_TYPE_LAYOUT: DynamicFormControlLayout = { element: { - container: 'p-0', - label: 'col-form-label' - }, - grid: { - host: 'col-md-10' + host: 'col-12', + label: 'col-form-label name-label' } }; @@ -70,11 +79,10 @@ export const BITSTREAM_FORM_ACCESS_CONDITION_START_DATE_CONFIG: DynamicDatePicke }; export const BITSTREAM_FORM_ACCESS_CONDITION_START_DATE_LAYOUT: DynamicFormControlLayout = { element: { - container: 'p-0', label: 'col-form-label' }, grid: { - host: 'col-md-4' + host: 'col-6' } }; @@ -101,10 +109,9 @@ export const BITSTREAM_FORM_ACCESS_CONDITION_END_DATE_CONFIG: DynamicDatePickerM }; export const BITSTREAM_FORM_ACCESS_CONDITION_END_DATE_LAYOUT: DynamicFormControlLayout = { element: { - container: 'p-0', label: 'col-form-label' }, grid: { - host: 'col-md-4' + host: 'col-6' } }; diff --git a/src/app/submission/sections/upload/file/section-upload-file.component.ts b/src/app/submission/sections/upload/file/section-upload-file.component.ts index 5a97140a70..d4c901b290 100644 --- a/src/app/submission/sections/upload/file/section-upload-file.component.ts +++ b/src/app/submission/sections/upload/file/section-upload-file.component.ts @@ -255,6 +255,7 @@ export class SubmissionSectionUploadFileComponent implements OnChanges, OnInit { }); const accessConditionsToSave = []; formData.accessConditions + .map((accessConditions) => accessConditions.accessConditionGroup) .filter((accessCondition) => isNotEmpty(accessCondition)) .forEach((accessCondition) => { let accessConditionOpt; From e18c66d6888e4c5eeb481e64879902d1dc843b58 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Fri, 14 May 2021 19:14:56 +0200 Subject: [PATCH 04/97] Fix issue with patch operations related to repeatable fields --- .../form/section-form-operations.service.ts | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/src/app/submission/sections/form/section-form-operations.service.ts b/src/app/submission/sections/form/section-form-operations.service.ts index a1bb99e3cd..8aef798cbc 100644 --- a/src/app/submission/sections/form/section-form-operations.service.ts +++ b/src/app/submission/sections/form/section-form-operations.service.ts @@ -6,7 +6,8 @@ import { DYNAMIC_FORM_CONTROL_TYPE_GROUP, DynamicFormArrayGroupModel, DynamicFormControlEvent, - DynamicFormControlModel, isDynamicFormControlEvent + DynamicFormControlModel, + isDynamicFormControlEvent } from '@ng-dynamic-forms/core'; import { hasValue, isNotEmpty, isNotNull, isNotUndefined, isNull, isUndefined } from '../../../shared/empty.util'; @@ -299,7 +300,7 @@ export class SectionFormOperationsService { if (event.context && event.context instanceof DynamicFormArrayGroupModel) { // Model is a DynamicRowArrayModel - this.handleArrayGroupPatch(pathCombiner, event, (event as any).context.context); + this.handleArrayGroupPatch(pathCombiner, event, (event as any).context.context, previousValue); return; } @@ -368,7 +369,7 @@ export class SectionFormOperationsService { if (event.context && event.context instanceof DynamicFormArrayGroupModel) { // Model is a DynamicRowArrayModel - this.handleArrayGroupPatch(pathCombiner, event, (event as any).context.context); + this.handleArrayGroupPatch(pathCombiner, event, (event as any).context.context, previousValue); return; } @@ -498,23 +499,37 @@ export class SectionFormOperationsService { event: DynamicFormControlEvent, previousValue: FormFieldPreviousValueObject) { - return this.handleArrayGroupPatch(pathCombiner, event.$event, (event as any).$event.arrayModel); + return this.handleArrayGroupPatch(pathCombiner, event.$event, (event as any).$event.arrayModel, previousValue); } /** * Specific patch handler for a DynamicRowArrayModel. * Configure a Patch ADD with the current array value. * @param pathCombiner + * the [[JsonPatchOperationPathCombiner]] object for the specified operation * @param event + * the [[DynamicFormControlEvent]] for the specified operation * @param model + * the [[DynamicRowArrayModel]] model + * @param previousValue + * the [[FormFieldPreviousValueObject]] for the specified operation */ private handleArrayGroupPatch(pathCombiner: JsonPatchOperationPathCombiner, event, - model: DynamicRowArrayModel) { + model: DynamicRowArrayModel, + previousValue: FormFieldPreviousValueObject) { + const arrayValue = this.formBuilder.getValueFromModel([model]); - const segmentedPath2 = this.getFieldPathSegmentedFromChangeEvent(event); - this.operationsBuilder.add( - pathCombiner.getPath(segmentedPath2), - arrayValue[segmentedPath2], false); + const segmentedPath = this.getFieldPathSegmentedFromChangeEvent(event); + if (isNotEmpty(arrayValue)) { + this.operationsBuilder.add( + pathCombiner.getPath(segmentedPath), + arrayValue[segmentedPath], + false + ); + } else if (previousValue.isPathEqual(this.formBuilder.getPath(event.model))) { + this.operationsBuilder.remove(pathCombiner.getPath(segmentedPath)); + } + } } From d6dbbd1f1fa853e06d387668491462f939b7fad8 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Tue, 18 May 2021 10:41:15 +0200 Subject: [PATCH 05/97] Add tests for handleArrayGroupPatch method --- .../section-form-operations.service.spec.ts | 92 ++++++++++++++++++- 1 file changed, 91 insertions(+), 1 deletion(-) diff --git a/src/app/submission/sections/form/section-form-operations.service.spec.ts b/src/app/submission/sections/form/section-form-operations.service.spec.ts index c76a15abcb..9c9b6d971b 100644 --- a/src/app/submission/sections/form/section-form-operations.service.spec.ts +++ b/src/app/submission/sections/form/section-form-operations.service.spec.ts @@ -4,7 +4,8 @@ import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { DYNAMIC_FORM_CONTROL_TYPE_ARRAY, DYNAMIC_FORM_CONTROL_TYPE_GROUP, - DynamicFormControlEvent + DynamicFormControlEvent, + DynamicInputModel } from '@ng-dynamic-forms/core'; import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; @@ -28,6 +29,7 @@ import { } from '../../../shared/mocks/form-models.mock'; import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model'; import { VocabularyEntry } from '../../../core/submission/vocabularies/models/vocabulary-entry.model'; +import { DynamicRowArrayModel } from '../../../shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-array-model'; describe('SectionFormOperationsService test suite', () => { let formBuilderService: any; @@ -83,6 +85,11 @@ describe('SectionFormOperationsService test suite', () => { formBuilderService = TestBed.inject(FormBuilderService); }); + afterEach(() => { + jsonPatchOpBuilder.add.calls.reset(); + jsonPatchOpBuilder.remove.calls.reset(); + }); + describe('dispatchOperationsFromEvent', () => { it('should call dispatchOperationsFromRemoveEvent on remove event', () => { const previousValue = new FormFieldPreviousValueObject(['path', 'test'], 'value'); @@ -760,4 +767,87 @@ describe('SectionFormOperationsService test suite', () => { }); }); + describe('handleArrayGroupPatch', () => { + let arrayModel; + let previousValue; + beforeEach(() => { + arrayModel = new DynamicRowArrayModel( + { + id: 'testFormRowArray', + initialCount: 5, + notRepeatable: false, + relationshipConfig: undefined, + submissionId: '1234', + isDraggable: true, + showButtons: false, + groupFactory: () => { + return [ + new DynamicInputModel({ id: 'testFormRowArrayGroupInput' }) + ]; + }, + required: false, + metadataKey: 'dc.contributor.author', + metadataFields: ['dc.contributor.author'], + hasSelectableMetadata: true + } + ); + spyOn(serviceAsAny, 'getFieldPathSegmentedFromChangeEvent').and.returnValue('path'); + previousValue = new FormFieldPreviousValueObject(['path'], null); + }); + + it('should not dispatch a json-path operation when a array value is empty', () => { + formBuilderService.getValueFromModel.and.returnValue({}); + spyOn(previousValue, 'isPathEqual').and.returnValue(false); + + serviceAsAny.handleArrayGroupPatch( + pathCombiner, + dynamicFormControlChangeEvent, + arrayModel, + previousValue + ); + + expect(jsonPatchOpBuilder.add).not.toHaveBeenCalled(); + expect(jsonPatchOpBuilder.remove).not.toHaveBeenCalled(); + }); + + it('should dispatch a json-path add operation when a array value is not empty', () => { + const pathValue = [ + new FormFieldMetadataValueObject('test'), + new FormFieldMetadataValueObject('test two') + ]; + formBuilderService.getValueFromModel.and.returnValue({ + path:pathValue + }); + spyOn(previousValue, 'isPathEqual').and.returnValue(false); + + serviceAsAny.handleArrayGroupPatch( + pathCombiner, + dynamicFormControlChangeEvent, + arrayModel, + previousValue + ); + + expect(jsonPatchOpBuilder.add).toHaveBeenCalledWith( + pathCombiner.getPath('path'), + pathValue, + false + ); + expect(jsonPatchOpBuilder.remove).not.toHaveBeenCalled(); + }); + + it('should dispatch a json-path remove operation when a array value is empty and has previous value', () => { + formBuilderService.getValueFromModel.and.returnValue({}); + spyOn(previousValue, 'isPathEqual').and.returnValue(true); + + serviceAsAny.handleArrayGroupPatch( + pathCombiner, + dynamicFormControlChangeEvent, + arrayModel, + previousValue + ); + + expect(jsonPatchOpBuilder.add).not.toHaveBeenCalled(); + expect(jsonPatchOpBuilder.remove).toHaveBeenCalledWith(pathCombiner.getPath('path')); + }); + }); }); From 98dde58f9d0f330945f6faf4665c703709475ad9 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Tue, 18 May 2021 11:52:03 +0200 Subject: [PATCH 06/97] [D4CRIS-1080] Fix issue where a replace patch operation was dispatched instead of an add one when field's previous value is empty --- .../section-form-operations.service.spec.ts | 29 ++++++++++++++++++- .../form/section-form-operations.service.ts | 4 +-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/app/submission/sections/form/section-form-operations.service.spec.ts b/src/app/submission/sections/form/section-form-operations.service.spec.ts index 9c9b6d971b..ec179b6151 100644 --- a/src/app/submission/sections/form/section-form-operations.service.spec.ts +++ b/src/app/submission/sections/form/section-form-operations.service.spec.ts @@ -574,7 +574,7 @@ describe('SectionFormOperationsService test suite', () => { }); it('should dispatch a json-path remove operation when has a stored value', () => { - const previousValue = new FormFieldPreviousValueObject(['path', 'test'], 'value'); + let previousValue = new FormFieldPreviousValueObject(['path', 'test'], 'value'); const event = Object.assign({}, dynamicFormControlChangeEvent, { model: { parent: mockRowGroupModel @@ -597,6 +597,7 @@ describe('SectionFormOperationsService test suite', () => { spyIndex.and.returnValue(1); spyPath.and.returnValue('path/1'); + previousValue = new FormFieldPreviousValueObject(['path', 'test'], 'value'); serviceAsAny.dispatchOperationsFromChangeEvent(pathCombiner, event, previousValue, true); expect(jsonPatchOpBuilder.remove).toHaveBeenCalledWith(pathCombiner.getPath('path/1')); @@ -627,6 +628,32 @@ describe('SectionFormOperationsService test suite', () => { new FormFieldMetadataValueObject('test')); }); + it('should dispatch a json-path add operation when has a stored value but previous value is empty', () => { + const previousValue = new FormFieldPreviousValueObject(['path', 'test'], null); + const event = Object.assign({}, dynamicFormControlChangeEvent, { + model: { + parent: mockRowGroupModel + } + }); + spyOn(service, 'getFieldPathFromEvent').and.returnValue('path/0'); + spyOn(service, 'getFieldPathSegmentedFromChangeEvent').and.returnValue('path'); + spyOn(service, 'getFieldValueFromChangeEvent').and.returnValue(new FormFieldMetadataValueObject('test')); + spyOn(service, 'getArrayIndexFromEvent').and.returnValue(0); + spyOn(serviceAsAny, 'getValueMap'); + spyOn(serviceAsAny, 'dispatchOperationsFromMap'); + formBuilderService.isQualdropGroup.and.returnValue(false); + formBuilderService.isRelationGroup.and.returnValue(false); + formBuilderService.hasArrayGroupValue.and.returnValue(false); + spyOn(previousValue, 'isPathEqual').and.returnValue(false); + + serviceAsAny.dispatchOperationsFromChangeEvent(pathCombiner, event, previousValue, true); + + expect(jsonPatchOpBuilder.add).toHaveBeenCalledWith( + pathCombiner.getPath('path'), + new FormFieldMetadataValueObject('test'), + true); + }); + it('should dispatch a json-path add operation when has a value and field index is zero or undefined', () => { const previousValue = new FormFieldPreviousValueObject(['path', 'test'], 'value'); const event = Object.assign({}, dynamicFormControlChangeEvent, { diff --git a/src/app/submission/sections/form/section-form-operations.service.ts b/src/app/submission/sections/form/section-form-operations.service.ts index 8aef798cbc..7174d5da67 100644 --- a/src/app/submission/sections/form/section-form-operations.service.ts +++ b/src/app/submission/sections/form/section-form-operations.service.ts @@ -389,7 +389,7 @@ export class SectionFormOperationsService { this.operationsBuilder.add( pathCombiner.getPath(segmentedPath), value, true); - } else if (previousValue.isPathEqual(this.formBuilder.getPath(event.model)) || hasStoredValue) { + } else if (previousValue.isPathEqual(this.formBuilder.getPath(event.model)) || (hasStoredValue && isNotEmpty(previousValue.value)) ) { // Here model has a previous value changed or stored in the server if (hasValue(event.$event) && hasValue(event.$event.previousIndex)) { if (event.$event.previousIndex < 0) { @@ -422,7 +422,7 @@ export class SectionFormOperationsService { previousValue.delete(); } else if (value.hasValue()) { // Here model has no previous value but a new one - if (isUndefined(this.getArrayIndexFromEvent(event)) || this.getArrayIndexFromEvent(event) === 0) { + if (isUndefined(this.getArrayIndexFromEvent(event)) || this.getArrayIndexFromEvent(event) === 0) { // Model is single field or is part of an array model but is the first item, // so dispatch an add operation that initialize the values of a specific metadata this.operationsBuilder.add( From 3c0cb33bc707a641131c57deb8a2b4f17f32d777 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Wed, 19 May 2021 18:16:31 +0200 Subject: [PATCH 07/97] fix failed build --- .../sections/form/section-form-operations.service.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/submission/sections/form/section-form-operations.service.spec.ts b/src/app/submission/sections/form/section-form-operations.service.spec.ts index ec179b6151..d5798b82c8 100644 --- a/src/app/submission/sections/form/section-form-operations.service.spec.ts +++ b/src/app/submission/sections/form/section-form-operations.service.spec.ts @@ -806,7 +806,6 @@ describe('SectionFormOperationsService test suite', () => { relationshipConfig: undefined, submissionId: '1234', isDraggable: true, - showButtons: false, groupFactory: () => { return [ new DynamicInputModel({ id: 'testFormRowArrayGroupInput' }) From 2dfed863edcb84d7b58066a97daf48e0457d4252 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Fri, 28 May 2021 18:49:47 +0200 Subject: [PATCH 08/97] [DSC-75] Fix issue while deleting multiple qualdrop value --- src/app/shared/form/form.component.ts | 9 ++++++++- .../sections/form/section-form-operations.service.ts | 9 +++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/app/shared/form/form.component.ts b/src/app/shared/form/form.component.ts index 42469ddba2..8d75d7f13a 100644 --- a/src/app/shared/form/form.component.ts +++ b/src/app/shared/form/form.component.ts @@ -309,9 +309,16 @@ export class FormComponent implements OnDestroy, OnInit { removeItem($event, arrayContext: DynamicFormArrayModel, index: number): void { const formArrayControl = this.formGroup.get(this.formBuilderService.getPath(arrayContext)) as FormArray; const event = this.getEvent($event, arrayContext, index, 'remove'); + if (this.formBuilderService.isQualdropGroup(event.model as DynamicFormControlModel)) { + // In case of qualdrop value remove event must be dispatched before removing the control from array + this.removeArrayItem.emit(event); + } this.formBuilderService.removeFormArrayGroup(index, formArrayControl, arrayContext); this.formService.changeForm(this.formId, this.formModel); - this.removeArrayItem.emit(event); + if (!this.formBuilderService.isQualdropGroup(event.model as DynamicFormControlModel)) { + // dispatch remove event for any field type except for qualdrop value + this.removeArrayItem.emit(event); + } } insertItem($event, arrayContext: DynamicFormArrayModel, index: number): void { diff --git a/src/app/submission/sections/form/section-form-operations.service.ts b/src/app/submission/sections/form/section-form-operations.service.ts index 7174d5da67..adba46bf3a 100644 --- a/src/app/submission/sections/form/section-form-operations.service.ts +++ b/src/app/submission/sections/form/section-form-operations.service.ts @@ -298,17 +298,14 @@ export class SectionFormOperationsService { event: DynamicFormControlEvent, previousValue: FormFieldPreviousValueObject): void { - if (event.context && event.context instanceof DynamicFormArrayGroupModel) { - // Model is a DynamicRowArrayModel - this.handleArrayGroupPatch(pathCombiner, event, (event as any).context.context, previousValue); - return; - } - const path = this.getFieldPathFromEvent(event); const value = this.getFieldValueFromChangeEvent(event); console.log(value); if (this.formBuilder.isQualdropGroup(event.model as DynamicFormControlModel)) { this.dispatchOperationsFromMap(this.getQualdropValueMap(event), pathCombiner, event, previousValue); + } else if (event.context && event.context instanceof DynamicFormArrayGroupModel) { + // Model is a DynamicRowArrayModel + this.handleArrayGroupPatch(pathCombiner, event, (event as any).context.context, previousValue); } else if ((isNotEmpty(value) && typeof value === 'string') || (isNotEmpty(value) && value instanceof FormFieldMetadataValueObject && value.hasValue())) { this.operationsBuilder.remove(pathCombiner.getPath(path)); } From 3e53b7c7b17f83547f727833f4408a479330f6e0 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Tue, 1 Jun 2021 14:09:17 +0200 Subject: [PATCH 09/97] [CST-4248] Add possibility to add additional content to form.component --- src/app/shared/form/form.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/shared/form/form.component.html b/src/app/shared/form/form.component.html index 39ccda360f..de24880b3b 100644 --- a/src/app/shared/form/form.component.html +++ b/src/app/shared/form/form.component.html @@ -48,7 +48,7 @@ - +
From c150fb881eae270591e3b4329b595d4aa6a7d2d2 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Tue, 1 Jun 2021 14:12:12 +0200 Subject: [PATCH 10/97] [CST-4248] bitstream authorizations page --- .../bitstream-authorizations.component.html | 10 +++ ...bitstream-authorizations.component.spec.ts | 84 +++++++++++++++++++ .../bitstream-authorizations.component.ts | 40 +++++++++ .../bitstream-page-routing.module.ts | 36 ++++++++ .../+bitstream-page/bitstream-page.module.ts | 2 + src/assets/i18n/en.json5 | 8 +- 6 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 src/app/+bitstream-page/bitstream-authorizations/bitstream-authorizations.component.html create mode 100644 src/app/+bitstream-page/bitstream-authorizations/bitstream-authorizations.component.spec.ts create mode 100644 src/app/+bitstream-page/bitstream-authorizations/bitstream-authorizations.component.ts diff --git a/src/app/+bitstream-page/bitstream-authorizations/bitstream-authorizations.component.html b/src/app/+bitstream-page/bitstream-authorizations/bitstream-authorizations.component.html new file mode 100644 index 0000000000..804bb4f891 --- /dev/null +++ b/src/app/+bitstream-page/bitstream-authorizations/bitstream-authorizations.component.html @@ -0,0 +1,10 @@ + diff --git a/src/app/+bitstream-page/bitstream-authorizations/bitstream-authorizations.component.spec.ts b/src/app/+bitstream-page/bitstream-authorizations/bitstream-authorizations.component.spec.ts new file mode 100644 index 0000000000..c41351f380 --- /dev/null +++ b/src/app/+bitstream-page/bitstream-authorizations/bitstream-authorizations.component.spec.ts @@ -0,0 +1,84 @@ +import { CommonModule } from '@angular/common'; +import { ChangeDetectorRef, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; + +import { cold } from 'jasmine-marbles'; +import { of as observableOf } from 'rxjs'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; + +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { BitstreamAuthorizationsComponent } from './bitstream-authorizations.component'; +import { Bitstream } from '../../core/shared/bitstream.model'; +import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; +import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; + +describe('BitstreamAuthorizationsComponent', () => { + let comp: BitstreamAuthorizationsComponent; + let fixture: ComponentFixture>; + + const bitstream = Object.assign(new Bitstream(), { + sizeBytes: 10000, + metadata: { + 'dc.title': [ + { + value: 'file name', + language: null + } + ] + }, + _links: { + content: { href: 'file-selflink' } + } + }); + + const bitstreamRD = createSuccessfulRemoteDataObject(bitstream); + + const routeStub = { + data: observableOf({ + bitstream: bitstreamRD + }) + }; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + CommonModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }) + ], + declarations: [BitstreamAuthorizationsComponent], + providers: [ + { provide: ActivatedRoute, useValue: routeStub }, + ChangeDetectorRef, + BitstreamAuthorizationsComponent, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(BitstreamAuthorizationsComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + comp = null; + fixture.destroy(); + }); + + it('should create', () => { + expect(comp).toBeTruthy(); + }); + + it('should init dso remote data properly', (done) => { + const expected = cold('(a|)', { a: bitstreamRD }); + expect(comp.dsoRD$).toBeObservable(expected); + done(); + }); +}); diff --git a/src/app/+bitstream-page/bitstream-authorizations/bitstream-authorizations.component.ts b/src/app/+bitstream-page/bitstream-authorizations/bitstream-authorizations.component.ts new file mode 100644 index 0000000000..adc0638780 --- /dev/null +++ b/src/app/+bitstream-page/bitstream-authorizations/bitstream-authorizations.component.ts @@ -0,0 +1,40 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; + +import { Observable } from 'rxjs'; +import { first, map } from 'rxjs/operators'; + +import { RemoteData } from '../../core/data/remote-data'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; + +@Component({ + selector: 'ds-collection-authorizations', + templateUrl: './bitstream-authorizations.component.html', +}) +/** + * Component that handles the Collection Authorizations + */ +export class BitstreamAuthorizationsComponent implements OnInit { + + /** + * The initial DSO object + */ + public dsoRD$: Observable>; + + /** + * Initialize instance variables + * + * @param {ActivatedRoute} route + */ + constructor( + private route: ActivatedRoute + ) { + } + + /** + * Initialize the component, setting up the collection + */ + ngOnInit(): void { + this.dsoRD$ = this.route.data.pipe(first(), map((data) => data.bitstream)); + } +} diff --git a/src/app/+bitstream-page/bitstream-page-routing.module.ts b/src/app/+bitstream-page/bitstream-page-routing.module.ts index bbbd65f279..284f29f7b4 100644 --- a/src/app/+bitstream-page/bitstream-page-routing.module.ts +++ b/src/app/+bitstream-page/bitstream-page-routing.module.ts @@ -4,8 +4,14 @@ import { EditBitstreamPageComponent } from './edit-bitstream-page/edit-bitstream import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; import { BitstreamPageResolver } from './bitstream-page.resolver'; import { BitstreamDownloadPageComponent } from '../shared/bitstream-download-page/bitstream-download-page.component'; +import { ResourcePolicyTargetResolver } from '../shared/resource-policies/resolvers/resource-policy-target.resolver'; +import { ResourcePolicyCreateComponent } from '../shared/resource-policies/create/resource-policy-create.component'; +import { ResourcePolicyResolver } from '../shared/resource-policies/resolvers/resource-policy.resolver'; +import { ResourcePolicyEditComponent } from '../shared/resource-policies/edit/resource-policy-edit.component'; +import { BitstreamAuthorizationsComponent } from './bitstream-authorizations/bitstream-authorizations.component'; const EDIT_BITSTREAM_PATH = ':id/edit'; +const EDIT_BITSTREAM_AUTHORIZATIONS_PATH = ':id/authorizations'; /** * Routing module to help navigate Bitstream pages @@ -27,6 +33,36 @@ const EDIT_BITSTREAM_PATH = ':id/edit'; bitstream: BitstreamPageResolver }, canActivate: [AuthenticatedGuard] + }, + { + path: EDIT_BITSTREAM_AUTHORIZATIONS_PATH, + + children: [ + { + path: 'create', + resolve: { + resourcePolicyTarget: ResourcePolicyTargetResolver + }, + component: ResourcePolicyCreateComponent, + data: { title: 'resource-policies.create.page.title', showBreadcrumbs: true } + }, + { + path: 'edit', + resolve: { + resourcePolicy: ResourcePolicyResolver + }, + component: ResourcePolicyEditComponent, + data: { title: 'resource-policies.edit.page.title', showBreadcrumbs: true } + }, + { + path: '', + resolve: { + bitstream: BitstreamPageResolver + }, + component: BitstreamAuthorizationsComponent, + data: { title: 'bitstream.edit.authorizations.title', showBreadcrumbs: true } + } + ] } ]) ], diff --git a/src/app/+bitstream-page/bitstream-page.module.ts b/src/app/+bitstream-page/bitstream-page.module.ts index 24b4cd512f..80e5ad14e3 100644 --- a/src/app/+bitstream-page/bitstream-page.module.ts +++ b/src/app/+bitstream-page/bitstream-page.module.ts @@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common'; import { SharedModule } from '../shared/shared.module'; import { EditBitstreamPageComponent } from './edit-bitstream-page/edit-bitstream-page.component'; import { BitstreamPageRoutingModule } from './bitstream-page-routing.module'; +import { BitstreamAuthorizationsComponent } from './bitstream-authorizations/bitstream-authorizations.component'; /** * This module handles all components that are necessary for Bitstream related pages @@ -14,6 +15,7 @@ import { BitstreamPageRoutingModule } from './bitstream-page-routing.module'; BitstreamPageRoutingModule ], declarations: [ + BitstreamAuthorizationsComponent, EditBitstreamPageComponent ] }) diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 4c3317a0c0..44725337be 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -530,6 +530,12 @@ + "bitstream.edit.authorizations.link": "Edit bitstream's Policies", + + "bitstream.edit.authorizations.title": "Edit bitstream's Policies", + + "bitstream.edit.return": "Back", + "bitstream.edit.bitstream": "Bitstream: ", "bitstream.edit.form.description.hint": "Optionally, provide a brief description of the file, for example \"Main article\" or \"Experiment data readings\".", @@ -1817,8 +1823,6 @@ "item.page.description": "Description", - "item.page.edit": "Edit this item", - "item.page.journal-issn": "Journal ISSN", "item.page.journal-title": "Journal Title", From eaaad88443694d30d4ff4eed5cbba929c1c389e8 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Tue, 1 Jun 2021 14:13:19 +0200 Subject: [PATCH 11/97] [CST-4248] Remove embargo form field and add link to bitstream authorization page --- .../edit-bitstream-page.component.html | 6 +++- .../edit-bitstream-page.component.spec.ts | 21 ++++---------- .../edit-bitstream-page.component.ts | 28 +++---------------- 3 files changed, 15 insertions(+), 40 deletions(-) diff --git a/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.html b/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.html index fd13e249a0..cbb587cca4 100644 --- a/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.html +++ b/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.html @@ -19,7 +19,11 @@ [submitLabel]="'form.save'" (submitForm)="onSubmit()" (cancel)="onCancel()" - (dfChange)="onChange($event)"> + (dfChange)="onChange($event)"> + +
diff --git a/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.spec.ts b/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.spec.ts index 2e7eb4e1d1..9c2cb3a093 100644 --- a/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.spec.ts +++ b/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.spec.ts @@ -18,12 +18,8 @@ import { hasValue } from '../../shared/empty.util'; import { FormControl, FormGroup } from '@angular/forms'; import { FileSizePipe } from '../../shared/utils/file-size-pipe'; import { VarDirective } from '../../shared/utils/var.directive'; -import { - createSuccessfulRemoteDataObject, - createSuccessfulRemoteDataObject$ -} from '../../shared/remote-data.utils'; -import { RouterStub } from '../../shared/testing/router.stub'; -import { getEntityEditRoute, getItemEditRoute } from '../../+item-page/item-page-routing-paths'; +import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { getEntityEditRoute } from '../../+item-page/item-page-routing-paths'; import { createPaginatedList } from '../../shared/testing/utils.test'; import { Item } from '../../core/shared/item.model'; @@ -39,7 +35,6 @@ let bitstream: Bitstream; let selectedFormat: BitstreamFormat; let allFormats: BitstreamFormat[]; let router: Router; -let routerStub; describe('EditBitstreamPageComponent', () => { let comp: EditBitstreamPageComponent; @@ -129,10 +124,6 @@ describe('EditBitstreamPageComponent', () => { findAll: createSuccessfulRemoteDataObject$(createPaginatedList(allFormats)) }); - const itemPageUrl = `fake-url/some-uuid`; - routerStub = Object.assign(new RouterStub(), { - url: `${itemPageUrl}` - }); TestBed.configureTestingModule({ imports: [TranslateModule.forRoot(), RouterTestingModule], declarations: [EditBitstreamPageComponent, FileSizePipe, VarDirective], @@ -142,7 +133,6 @@ describe('EditBitstreamPageComponent', () => { { provide: ActivatedRoute, useValue: { data: observableOf({ bitstream: createSuccessfulRemoteDataObject(bitstream) }), snapshot: { queryParams: {} } } }, { provide: BitstreamDataService, useValue: bitstreamService }, { provide: BitstreamFormatDataService, useValue: bitstreamFormatService }, - { provide: Router, useValue: routerStub }, ChangeDetectorRef ], schemas: [NO_ERRORS_SCHEMA] @@ -154,7 +144,8 @@ describe('EditBitstreamPageComponent', () => { fixture = TestBed.createComponent(EditBitstreamPageComponent); comp = fixture.componentInstance; fixture.detectChanges(); - router = (comp as any).router; + router = TestBed.inject(Router); + spyOn(router, 'navigate'); }); describe('on startup', () => { @@ -241,14 +232,14 @@ describe('EditBitstreamPageComponent', () => { 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(routerStub.navigate).toHaveBeenCalledWith([getEntityEditRoute(null, 'some-uuid1'), 'bitstreams']); + 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(routerStub.navigate).toHaveBeenCalledWith([getEntityEditRoute(null, 'some-uuid'), 'bitstreams']); + expect(router.navigate).toHaveBeenCalledWith([getEntityEditRoute(null, 'some-uuid'), 'bitstreams']); }); }); }); diff --git a/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts b/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts index 8a4d584647..4ad0aac7ef 100644 --- a/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts +++ b/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts @@ -19,10 +19,10 @@ import { cloneDeep } from 'lodash'; import { BitstreamDataService } from '../../core/data/bitstream-data.service'; import { getAllSucceededRemoteDataPayload, - getFirstSucceededRemoteDataPayload, - getRemoteDataPayload, + getFirstCompletedRemoteData, getFirstSucceededRemoteData, - getFirstCompletedRemoteData + getFirstSucceededRemoteDataPayload, + getRemoteDataPayload } from '../../core/shared/operators'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { BitstreamFormatDataService } from '../../core/data/bitstream-format-data.service'; @@ -131,15 +131,6 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { rows: 10 }); - /** - * The Dynamic Input Model for the file's embargo (disabled on this page) - */ - embargoModel = new DynamicInputModel({ - id: 'embargo', - name: 'embargo', - disabled: true - }); - /** * The Dynamic Input Model for the selected format */ @@ -159,7 +150,7 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { /** * All input models in a simple array for easier iterations */ - inputModels = [this.fileNameModel, this.primaryBitstreamModel, this.descriptionModel, this.embargoModel, 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 @@ -179,12 +170,6 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { this.descriptionModel ] }), - new DynamicFormGroupModel({ - id: 'embargoContainer', - group: [ - this.embargoModel - ] - }), new DynamicFormGroupModel({ id: 'formatContainer', group: [ @@ -243,11 +228,6 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { host: 'row' } }, - embargoContainer: { - grid: { - host: 'row' - } - }, formatContainer: { grid: { host: 'row' From 4683df431cd88494b423a5c4a58397f01eeb6827 Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Mon, 31 May 2021 14:37:29 +0200 Subject: [PATCH 12/97] 79730: Exclude search facet link from tablist --- .../search-facet-option/search-facet-option.component.html | 1 + .../search-facet-selected-option.component.html | 1 + 2 files changed, 2 insertions(+) diff --git a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html index cf4876e34f..055a2d2500 100644 --- a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html +++ b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html @@ -1,4 +1,5 @@ diff --git a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.html b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.html index 4bcfc02966..411dc766d0 100644 --- a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.html +++ b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.html @@ -1,4 +1,5 @@ From ffb320373debd251ef146e2450a9fa7a2cd3f9d8 Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Mon, 31 May 2021 14:41:36 +0200 Subject: [PATCH 13/97] 79730: Add labels around facet checkbox inputs --- .../search-facet-option.component.html | 8 +++++--- .../search-facet-selected-option.component.html | 2 ++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html index 055a2d2500..e2e57e7370 100644 --- a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html +++ b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html @@ -2,10 +2,12 @@ [tabIndex]="-1" [routerLink]="[searchLink]" [queryParams]="addQueryParams" queryParamsHandling="merge"> - - + + {{filterValue.count}} diff --git a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.html b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.html index 411dc766d0..d6cb7a3d79 100644 --- a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.html +++ b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.html @@ -2,8 +2,10 @@ [tabIndex]="-1" [routerLink]="[searchLink]" [queryParams]="removeQueryParams" queryParamsHandling="merge"> + From 5b490203b2453477a52d90132a71ea61576dd7ac Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Tue, 1 Jun 2021 09:29:01 +0200 Subject: [PATCH 14/97] 79730: Add labels around FilterInputSuggestionsComponent inputs --- .../org-unit-input-suggestions.component.html | 2 +- .../filter-input-suggestions.component.html | 25 ++++++++---- .../input-suggestions.component.ts | 9 ++++- .../search-authority-filter.component.html | 3 +- .../search-hierarchy-filter.component.html | 3 +- .../search-text-filter.component.html | 3 +- src/assets/i18n/en.json5 | 40 +++++++++++++++++++ 7 files changed, 71 insertions(+), 14 deletions(-) diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.html b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.html index e177b2b561..87a422e7db 100644 --- a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.html +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.html @@ -21,4 +21,4 @@ - \ No newline at end of file + diff --git a/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.html b/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.html index 7a9481f2f1..f1b0ba9023 100644 --- a/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.html +++ b/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.html @@ -3,13 +3,22 @@ (keydown.arrowdown)="shiftFocusDown($event)" (keydown.arrowup)="shiftFocusUp($event)" (keydown.esc)="close()" (dsClickOutside)="close();"> - - + + + + + - \ No newline at end of file + diff --git a/src/app/shared/input-suggestions/input-suggestions.component.ts b/src/app/shared/input-suggestions/input-suggestions.component.ts index c48dcfb831..7e05dbcc8c 100644 --- a/src/app/shared/input-suggestions/input-suggestions.component.ts +++ b/src/app/shared/input-suggestions/input-suggestions.component.ts @@ -53,6 +53,11 @@ export class InputSuggestionsComponent implements ControlValueAccessor, OnChange */ @Input() valid = true; + /** + * Label for the input field. Used for screen readers. + */ + @Input() label? = ''; + /** * Output for when the form is submitted */ @@ -106,10 +111,10 @@ export class InputSuggestionsComponent implements ControlValueAccessor, OnChange @Input() disabled = false; propagateChange = (_: any) => { /* Empty implementation */ - } + }; propagateTouch = (_: any) => { /* Empty implementation */ - } + }; /** * When any of the inputs change, check if we should still show the suggestions diff --git a/src/app/shared/search/search-filters/search-filter/search-authority-filter/search-authority-filter.component.html b/src/app/shared/search/search-filters/search-filter/search-authority-filter/search-authority-filter.component.html index 5e6bcfaf8b..4a7f769f21 100644 --- a/src/app/shared/search/search-filters/search-filter/search-authority-filter/search-authority-filter.component.html +++ b/src/app/shared/search/search-filters/search-filter/search-authority-filter/search-authority-filter.component.html @@ -16,7 +16,8 @@ Date: Tue, 1 Jun 2021 09:45:17 +0200 Subject: [PATCH 15/97] 79730: Add labels around date range inputs --- .../search-range-filter.component.html | 31 +++++++++++++------ src/assets/i18n/en.json5 | 8 +++-- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.html b/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.html index 8c4fe2b174..e4e8152e97 100644 --- a/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.html +++ b/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.html @@ -3,18 +3,31 @@
- +
- +
- +
diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index e9f22fd52c..90aac07a54 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -2914,9 +2914,13 @@ "search.filters.filter.dateIssued.head": "Date", - "search.filters.filter.dateIssued.max.placeholder": "Minimum Date", + "search.filters.filter.dateIssued.max.placeholder": "Maximum Date", - "search.filters.filter.dateIssued.min.placeholder": "Maximum Date", + "search.filters.filter.dateIssued.max.label": "End", + + "search.filters.filter.dateIssued.min.placeholder": "Minimum Date", + + "search.filters.filter.dateIssued.min.label": "Start", "search.filters.filter.dateSubmitted.head": "Date submitted", From abe26ce9f828daf5a63e18cb79b2270dfe430cbe Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Tue, 1 Jun 2021 12:42:40 +0200 Subject: [PATCH 16/97] 79730: Keyboard navigation for expandable filter facets --- .../search-filter.component.html | 12 +++--- .../search-filter.component.scss | 38 ++++++++++++++++--- .../search-filter/search-filter.component.ts | 24 ++++++++++++ 3 files changed, 63 insertions(+), 11 deletions(-) diff --git a/src/app/shared/search/search-filters/search-filter/search-filter.component.html b/src/app/shared/search/search-filters/search-filter/search-filter.component.html index eb2105f4e7..b71111de6a 100644 --- a/src/app/shared/search/search-filters/search-filter/search-filter.component.html +++ b/src/app/shared/search/search-filters/search-filter/search-filter.component.html @@ -1,17 +1,19 @@ -
-
+
+
+
+ class="search-filter-wrapper" [ngClass]="{ 'closed' : closed, 'notab': notab }"> diff --git a/src/app/shared/search/search-filters/search-filter/search-filter.component.scss b/src/app/shared/search/search-filters/search-filter/search-filter.component.scss index 518e7c9d5f..7e2631b55f 100644 --- a/src/app/shared/search/search-filters/search-filter/search-filter.component.scss +++ b/src/app/shared/search/search-filters/search-filter/search-filter.component.scss @@ -1,10 +1,36 @@ :host .facet-filter { - border: 1px solid var(--bs-light); - cursor: pointer; - .search-filter-wrapper.closed { - overflow: hidden; + border: 1px solid var(--bs-light); + cursor: pointer; + line-height: 0; + + .search-filter-wrapper { + line-height: var(--bs-line-height-base); + &.closed { + overflow: hidden; } - .filter-toggle { - line-height: var(--bs-line-height-base); + &.notab { + visibility: hidden; } + } + + .filter-toggle { + line-height: var(--bs-line-height-base); + text-align: right; + position: relative; + top: -0.125rem; // Fix weird outline shape in Chrome + } + + > button { + appearance: none; + border: 0; + padding: 0; + background: transparent; + width: 100%; + outline: none !important; + } + + &.focus { + outline: none; + box-shadow: var(--bs-input-btn-focus-box-shadow); + } } diff --git a/src/app/shared/search/search-filters/search-filter/search-filter.component.ts b/src/app/shared/search/search-filters/search-filter/search-filter.component.ts index 31ace10a7d..23cd92a601 100644 --- a/src/app/shared/search/search-filters/search-filter/search-filter.component.ts +++ b/src/app/shared/search/search-filters/search-filter/search-filter.component.ts @@ -37,6 +37,16 @@ export class SearchFilterComponent implements OnInit { */ closed: boolean; + /** + * True when the filter controls should be hidden & removed from the tablist + */ + notab: boolean; + + /** + * True when the filter toggle button is focused + */ + focusBox: boolean = false; + /** * Emits true when the filter is currently collapsed in the store */ @@ -112,6 +122,9 @@ export class SearchFilterComponent implements OnInit { if (event.fromState === 'collapsed') { this.closed = false; } + if (event.toState === 'collapsed') { + this.notab = true; + } } /** @@ -122,6 +135,17 @@ export class SearchFilterComponent implements OnInit { if (event.toState === 'collapsed') { this.closed = true; } + if (event.fromState === 'collapsed') { + this.notab = false; + } + } + + get regionId(): string { + return `search-filter-region-${this.constructor['ɵcmp'].id}`; + } + + get toggleId(): string { + return `search-filter-toggle-${this.constructor['ɵcmp'].id}`; } /** From cb3f5ad259a5088b2bf70659a00559d67ec8ff58 Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Tue, 1 Jun 2021 13:04:49 +0200 Subject: [PATCH 17/97] 79730: Add null href to more/collapse toggle links --- .../search-authority-filter.component.html | 10 ++++++---- .../search-boolean-filter.component.html | 10 ++++++---- .../search-hierarchy-filter.component.html | 10 ++++++---- .../search-text-filter.component.html | 10 ++++++---- 4 files changed, 24 insertions(+), 16 deletions(-) diff --git a/src/app/shared/search/search-filters/search-filter/search-authority-filter/search-authority-filter.component.html b/src/app/shared/search/search-filters/search-filter/search-authority-filter/search-authority-filter.component.html index 4a7f769f21..44aed494e3 100644 --- a/src/app/shared/search/search-filters/search-filter/search-authority-filter/search-authority-filter.component.html +++ b/src/app/shared/search/search-filters/search-filter/search-authority-filter/search-authority-filter.component.html @@ -8,11 +8,13 @@
{{"search.filters.filter.show-more" - | translate}} + (click)="showMore()" href="javascript:void(0);"> + {{"search.filters.filter.show-more" | translate}} + {{"search.filters.filter.show-less" - | translate}} + (click)="showFirstPageOnly()" href="javascript:void(0);"> + {{"search.filters.filter.show-less" | translate}} +
{{"search.filters.filter.show-more" - | translate}} + (click)="showMore()" href="javascript:void(0);"> + {{"search.filters.filter.show-more" | translate}} + {{"search.filters.filter.show-less" - | translate}} + (click)="showFirstPageOnly()" href="javascript:void(0);"> + {{"search.filters.filter.show-less" | translate}} +
diff --git a/src/app/shared/search/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html b/src/app/shared/search/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html index 2154ae2e24..49ca6fe3fd 100644 --- a/src/app/shared/search/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html +++ b/src/app/shared/search/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html @@ -8,11 +8,13 @@
{{"search.filters.filter.show-more" - | translate}} + (click)="showMore()" href="javascript:void(0);"> + {{"search.filters.filter.show-more" | translate}} + {{"search.filters.filter.show-less" - | translate}} + (click)="showFirstPageOnly()" href="javascript:void(0);"> + {{"search.filters.filter.show-less" | translate}} +
{{"search.filters.filter.show-more" - | translate}} + (click)="showMore()" href="javascript:void(0);"> + {{"search.filters.filter.show-more" | translate}} + {{"search.filters.filter.show-less" - | translate}} + (click)="showFirstPageOnly()" href="javascript:void(0);"> + {{"search.filters.filter.show-less" | translate}} +
Date: Tue, 1 Jun 2021 14:01:48 +0200 Subject: [PATCH 18/97] 79730: Improve slider handles keyboard control --- .../search-range-filter/search-range-filter.component.html | 5 +++-- .../search-range-filter/search-range-filter.component.scss | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.html b/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.html index e4e8152e97..3a6a6565c0 100644 --- a/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.html +++ b/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.html @@ -32,8 +32,9 @@ - + [dsDebounce]="500" (onDebounce)="onSubmit()" + [(ngModel)]="range" ngDefaultControl> +
diff --git a/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.scss b/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.scss index 2c98280e7f..f26806abfb 100644 --- a/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.scss +++ b/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.scss @@ -21,6 +21,7 @@ } &:focus { outline: none; + box-shadow: var(--bs-input-btn-focus-box-shadow); } } From c60fa2c441257ebe46f3d3ab5a85334b3b33dc9e Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Tue, 1 Jun 2021 15:20:33 +0200 Subject: [PATCH 19/97] 79730: Don't submit date slider changes until keyup --- .../search-range-filter.component.html | 3 ++- .../search-range-filter.component.ts | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.html b/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.html index 3a6a6565c0..0ebd5f74a2 100644 --- a/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.html +++ b/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.html @@ -32,7 +32,8 @@ diff --git a/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.ts b/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.ts index 62b1cb98a6..b23a2d8224 100644 --- a/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.ts +++ b/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.ts @@ -68,6 +68,12 @@ export class SearchRangeFilterComponent extends SearchFacetFilterComponent imple */ sub: Subscription; + /** + * Whether the sider is being controlled by the keyboard. + * Supresses any changes until the key is released. + */ + keyboardControl: boolean; + constructor(protected searchService: SearchService, protected filterService: SearchFilterService, protected router: Router, @@ -104,6 +110,10 @@ export class SearchRangeFilterComponent extends SearchFacetFilterComponent imple * Submits new custom range values to the range filter from the widget */ onSubmit() { + if (this.keyboardControl) { + return; // don't submit if a key is being held down + } + const newMin = this.range[0] !== this.min ? [this.range[0]] : null; const newMax = this.range[1] !== this.max ? [this.range[1]] : null; this.router.navigate(this.getSearchLinkParts(), { @@ -117,6 +127,14 @@ export class SearchRangeFilterComponent extends SearchFacetFilterComponent imple this.filter = ''; } + startKeyboardControl(): void { + this.keyboardControl = true; + } + + stopKeyboardControl(): void { + this.keyboardControl = false; + } + /** * TODO when upgrading nouislider, verify that this check is still needed. * Prevents AoT bug From 08878941aba73fd5e849b0e4f7c6dede293d7701 Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Tue, 1 Jun 2021 15:31:30 +0200 Subject: [PATCH 20/97] 79730: Fix tslint issues --- .../shared/input-suggestions/input-suggestions.component.ts | 4 ++-- .../search-filters/search-filter/search-filter.component.ts | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/app/shared/input-suggestions/input-suggestions.component.ts b/src/app/shared/input-suggestions/input-suggestions.component.ts index 7e05dbcc8c..7b5c9f34f2 100644 --- a/src/app/shared/input-suggestions/input-suggestions.component.ts +++ b/src/app/shared/input-suggestions/input-suggestions.component.ts @@ -111,10 +111,10 @@ export class InputSuggestionsComponent implements ControlValueAccessor, OnChange @Input() disabled = false; propagateChange = (_: any) => { /* Empty implementation */ - }; + } propagateTouch = (_: any) => { /* Empty implementation */ - }; + } /** * When any of the inputs change, check if we should still show the suggestions diff --git a/src/app/shared/search/search-filters/search-filter/search-filter.component.ts b/src/app/shared/search/search-filters/search-filter/search-filter.component.ts index 23cd92a601..57c4f991db 100644 --- a/src/app/shared/search/search-filters/search-filter/search-filter.component.ts +++ b/src/app/shared/search/search-filters/search-filter/search-filter.component.ts @@ -45,7 +45,7 @@ export class SearchFilterComponent implements OnInit { /** * True when the filter toggle button is focused */ - focusBox: boolean = false; + focusBox = false; /** * Emits true when the filter is currently collapsed in the store @@ -141,10 +141,12 @@ export class SearchFilterComponent implements OnInit { } get regionId(): string { + // tslint:disable-next-line:no-string-literal return `search-filter-region-${this.constructor['ɵcmp'].id}`; } get toggleId(): string { + // tslint:disable-next-line:no-string-literal return `search-filter-toggle-${this.constructor['ɵcmp'].id}`; } From d37d043531ab41ab4ed3b99835472788ed12a636 Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Thu, 3 Jun 2021 15:10:20 +0200 Subject: [PATCH 21/97] 79730: Show input labels when available --- .../filter-input-suggestions.component.html | 17 ++++++++----- .../search-range-filter.component.html | 24 ++++++++++--------- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.html b/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.html index f1b0ba9023..d239c8db8d 100644 --- a/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.html +++ b/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.html @@ -3,20 +3,25 @@ (keydown.arrowdown)="shiftFocusDown($event)" (keydown.arrowup)="shiftFocusUp($event)" (keydown.esc)="close()" (dsClickOutside)="close();"> - +
+ +
+ [ngModelOptions]="{standalone: true}" autocomplete="off" + /> diff --git a/src/app/shared/idle-modal/idle-modal.component.ts b/src/app/shared/idle-modal/idle-modal.component.ts new file mode 100644 index 0000000000..750657c2e4 --- /dev/null +++ b/src/app/shared/idle-modal/idle-modal.component.ts @@ -0,0 +1,85 @@ +import { Component, OnInit, Output } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { environment } from '../../../environments/environment'; +import { AuthService } from '../../core/auth/auth.service'; +import { Subject } from 'rxjs'; +import { hasValue } from '../empty.util'; + +@Component({ + selector: 'ds-idle-modal', + templateUrl: 'idle-modal.component.html', +}) +export class IdleModalComponent implements OnInit { + + /** + * Total time of idleness before session expires (in minutes) + * (environment.auth.ui.timeUntilIdle + environment.auth.ui.idleGracePeriod / 1000 / 60) + */ + timeToExpire: number; + + /** + * Timer to track time grace period + */ + private graceTimer; + + /** + * An event fired when the modal is closed + */ + @Output() + response: Subject = new Subject(); + + constructor(private activeModal: NgbActiveModal, + private authService: AuthService) { + this.timeToExpire = (environment.auth.ui.timeUntilIdle + environment.auth.ui.idleGracePeriod) / 1000 / 60; // ms => min + } + + ngOnInit() { + if (hasValue(this.graceTimer)) { + clearTimeout(this.graceTimer); + } + this.graceTimer = setTimeout(() => { + this.logOutPressed(); + }, environment.auth.ui.idleGracePeriod); + } + + /** + * When extend session is pressed + */ + extendSessionPressed() { + this.extendSessionAndCloseModal(); + } + + /** + * Close modal and logout + */ + logOutPressed() { + this.authService.logout(); + this.closeModal(); + } + + /** + * When close is pressed + */ + closePressed() { + this.extendSessionAndCloseModal(); + } + + /** + * Close the modal and extend session + */ + extendSessionAndCloseModal() { + if (hasValue(this.graceTimer)) { + clearTimeout(this.graceTimer); + } + this.authService.setIdle(false); + this.closeModal(); + } + + /** + * Close the modal and set the response to true so RootComponent knows the modal was closed + */ + closeModal() { + this.activeModal.close(); + this.response.next(true); + } +} diff --git a/src/app/shared/mocks/auth.service.mock.ts b/src/app/shared/mocks/auth.service.mock.ts index 98878bd6c1..bb39d08284 100644 --- a/src/app/shared/mocks/auth.service.mock.ts +++ b/src/app/shared/mocks/auth.service.mock.ts @@ -19,4 +19,11 @@ export class AuthServiceMock { public setRedirectUrl(url: string) { } + + public trackTokenExpiration(): void { + } + + public isUserIdle(): Observable { + return observableOf(false); + } } diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index cf19be3730..6105c79cd9 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -3691,5 +3691,15 @@ "workflow-item.send-back.button.cancel": "Cancel", - "workflow-item.send-back.button.confirm": "Send back" + "workflow-item.send-back.button.confirm": "Send back", + + + + "idle-modal.header": "Session will expire soon", + + "idle-modal.info": "For security reasons, user sessions expire after {{ timeToExpire }} minutes of inactivity. Your session will expire soon. Would you like to extend it or log out?”", + + "idle-modal.log-out": "Log out", + + "idle-modal.extend-session": "Extend session" } diff --git a/src/environments/mock-environment.ts b/src/environments/mock-environment.ts index 8de5b187ad..050f99ea29 100644 --- a/src/environments/mock-environment.ts +++ b/src/environments/mock-environment.ts @@ -35,6 +35,22 @@ export const environment: Partial = { timePerMethod: {[RestRequestMethod.PATCH]: 3} as any // time in seconds } }, + // Authority settings + auth: { + // Authority UI settings + ui: { + // the amount of time before the idle warning is shown + timeUntilIdle: 20000, // 20 sec + // the amount of time the user has to react after the idle warning is shown before they are logged out. + idleGracePeriod: 20000, // 20 sec + }, + // Authority REST settings + rest: { + // If the rest token expires in less than this amount of time, it will be refreshed automatically. + // This is independent from the idle warning. + timeLeftBeforeTokenRefresh: 20000, // 20 sec + }, + }, // Form settings form: { // NOTE: Map server-side validators to comparative Angular form validators From e88baa1995d86f34d70b0fde13e078ce3c67ea37 Mon Sep 17 00:00:00 2001 From: Marie Verdonck Date: Thu, 3 Jun 2021 14:29:50 +0200 Subject: [PATCH 77/97] 79700: specs for modal, auth check for idleness tracking & stop blocking at token success --- src/app/core/auth/auth.effects.ts | 1 - src/app/core/auth/auth.reducer.ts | 1 + src/app/root/root.component.ts | 30 ++-- .../idle-modal/idle-modal.component.spec.ts | 128 ++++++++++++++++++ src/assets/i18n/en.json5 | 2 +- src/environments/environment.common.ts | 7 +- 6 files changed, 151 insertions(+), 18 deletions(-) create mode 100644 src/app/shared/idle-modal/idle-modal.component.spec.ts diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts index c133310471..f7b81dc4ef 100644 --- a/src/app/core/auth/auth.effects.ts +++ b/src/app/core/auth/auth.effects.ts @@ -263,7 +263,6 @@ export class AuthEffects { filter((action: Action) => !IDLE_TIMER_IGNORE_TYPES.includes(action.type)), // Using switchMap the timer will be interrupted and restarted if a new action comes in, so idleness timer restarts switchMap(() => { - this.authService.isAuthenticated(); return timer(environment.auth.ui.timeUntilIdle); }), map(() => { diff --git a/src/app/core/auth/auth.reducer.ts b/src/app/core/auth/auth.reducer.ts index 0424a58898..f26ddb0182 100644 --- a/src/app/core/auth/auth.reducer.ts +++ b/src/app/core/auth/auth.reducer.ts @@ -193,6 +193,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut return Object.assign({}, state, { authToken: (action as RefreshTokenSuccessAction).payload, refreshing: false, + blocking: false }); case AuthActionTypes.ADD_MESSAGE: diff --git a/src/app/root/root.component.ts b/src/app/root/root.component.ts index c2d3c96951..81ae1a745c 100644 --- a/src/app/root/root.component.ts +++ b/src/app/root/root.component.ts @@ -2,7 +2,12 @@ import { map, take } from 'rxjs/operators'; import { Component, Inject, OnInit, Optional, Input } from '@angular/core'; import { Router } from '@angular/router'; -import { combineLatest as combineLatestObservable, Observable, of } from 'rxjs'; +import { + combineLatest as observableCombineLatest, + combineLatest as combineLatestObservable, + Observable, + of +} from 'rxjs'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; @@ -84,18 +89,19 @@ export class RootComponent implements OnInit { map(([collapsed, mobile]) => collapsed || mobile) ); - this.authService.isUserIdle().subscribe((userIdle: boolean) => { - if (userIdle) { - if (!this.idleModalOpen) { - const modalRef = this.modalService.open(IdleModalComponent); - this.idleModalOpen = true; - modalRef.componentInstance.response.pipe(take(1)).subscribe((closed: boolean) => { - if (closed) { - this.idleModalOpen = false; - } - }); + observableCombineLatest([this.authService.isUserIdle(), this.authService.isAuthenticated()]) + .subscribe(([userIdle, authenticated]) => { + if (userIdle && authenticated) { + if (!this.idleModalOpen) { + const modalRef = this.modalService.open(IdleModalComponent); + this.idleModalOpen = true; + modalRef.componentInstance.response.pipe(take(1)).subscribe((closed: boolean) => { + if (closed) { + this.idleModalOpen = false; + } + }); + } } - } }); } } diff --git a/src/app/shared/idle-modal/idle-modal.component.spec.ts b/src/app/shared/idle-modal/idle-modal.component.spec.ts new file mode 100644 index 0000000000..639cbd6ad1 --- /dev/null +++ b/src/app/shared/idle-modal/idle-modal.component.spec.ts @@ -0,0 +1,128 @@ +import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { IdleModalComponent } from './idle-modal.component'; +import { AuthService } from '../../core/auth/auth.service'; +import { By } from '@angular/platform-browser'; + +describe('IdleModalComponent', () => { + let component: IdleModalComponent; + let fixture: ComponentFixture; + let debugElement: DebugElement; + + const modalStub = jasmine.createSpyObj('modalStub', ['close']); + const authServiceStub = jasmine.createSpyObj('authService', ['setIdle', 'logout']); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [IdleModalComponent], + providers: [ + { provide: NgbActiveModal, useValue: modalStub }, + { provide: AuthService, useValue: authServiceStub } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(IdleModalComponent); + component = fixture.componentInstance; + debugElement = fixture.debugElement; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('extendSessionPressed', () => { + beforeEach(fakeAsync(() => { + spyOn(component.response, 'next'); + component.extendSessionPressed(); + })); + it('should set idle to false', () => { + expect(authServiceStub.setIdle).toHaveBeenCalledWith(false); + }); + it('should close the modal', () => { + expect(modalStub.close).toHaveBeenCalled(); + }); + it('response \'closed\' should have true as next', () => { + expect(component.response.next).toHaveBeenCalledWith(true); + }); + }); + + describe('logOutPressed', () => { + beforeEach(() => { + component.logOutPressed(); + }); + it('should logout', () => { + expect(authServiceStub.logout).toHaveBeenCalled(); + }); + it('should close the modal', () => { + expect(modalStub.close).toHaveBeenCalled(); + }); + }); + + describe('closePressed', () => { + beforeEach(fakeAsync(() => { + spyOn(component.response, 'next'); + component.closePressed(); + })); + it('should set idle to false', () => { + expect(authServiceStub.setIdle).toHaveBeenCalledWith(false); + }); + it('should close the modal', () => { + expect(modalStub.close).toHaveBeenCalled(); + }); + it('response \'closed\' should have true as next', () => { + expect(component.response.next).toHaveBeenCalledWith(true); + }); + }); + + describe('when the click method emits on extend session button', () => { + beforeEach(fakeAsync(() => { + spyOn(component, 'extendSessionPressed'); + debugElement.query(By.css('button.confirm')).triggerEventHandler('click', { + preventDefault: () => {/**/ + } + }); + tick(); + fixture.detectChanges(); + })); + it('should call the extendSessionPressed method on the component', () => { + expect(component.extendSessionPressed).toHaveBeenCalled(); + }); + }); + + describe('when the click method emits on log out button', () => { + beforeEach(fakeAsync(() => { + spyOn(component, 'logOutPressed'); + debugElement.query(By.css('button.cancel')).triggerEventHandler('click', { + preventDefault: () => {/**/ + } + }); + tick(); + fixture.detectChanges(); + })); + it('should call the logOutPressed method on the component', () => { + expect(component.logOutPressed).toHaveBeenCalled(); + }); + }); + + describe('when the click method emits on close button', () => { + beforeEach(fakeAsync(() => { + spyOn(component, 'closePressed'); + debugElement.query(By.css('.close')).triggerEventHandler('click', { + preventDefault: () => {/**/ + } + }); + tick(); + fixture.detectChanges(); + })); + it('should call the closePressed method on the component', () => { + expect(component.closePressed).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 6105c79cd9..5501b92aa7 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -3697,7 +3697,7 @@ "idle-modal.header": "Session will expire soon", - "idle-modal.info": "For security reasons, user sessions expire after {{ timeToExpire }} minutes of inactivity. Your session will expire soon. Would you like to extend it or log out?”", + "idle-modal.info": "For security reasons, user sessions expire after {{ timeToExpire }} minutes of inactivity. Your session will expire soon. Would you like to extend it or log out?", "idle-modal.log-out": "Log out", diff --git a/src/environments/environment.common.ts b/src/environments/environment.common.ts index a7d4ec8a00..24496386e9 100644 --- a/src/environments/environment.common.ts +++ b/src/environments/environment.common.ts @@ -48,17 +48,16 @@ export const environment: GlobalConfig = { ui: { // the amount of time before the idle warning is shown // timeUntilIdle: 15 * 60 * 1000, // 15 minutes - timeUntilIdle: 1 * 60 * 1000, // 1 minutes + timeUntilIdle: 30 * 1000, // 30 seconds // the amount of time the user has to react after the idle warning is shown before they are logged out. // idleGracePeriod: 5 * 60 * 1000, // 5 minutes - idleGracePeriod: 1 * 60 * 1000, // 1 minutes + idleGracePeriod: 1 * 60 * 1000, // 1 minute }, // Authority REST settings rest: { // If the rest token expires in less than this amount of time, it will be refreshed automatically. // This is independent from the idle warning. - // timeLeftBeforeTokenRefresh: 2 * 60 * 1000, // 2 minutes - timeLeftBeforeTokenRefresh: 0.25 * 60 * 1000, // 25 seconds + timeLeftBeforeTokenRefresh: 2 * 60 * 1000, // 2 minutes }, }, // Form settings From 91b4c81986e6b5b00f8a12b6c6054822bd7b4f48 Mon Sep 17 00:00:00 2001 From: Art Lowel Date: Tue, 15 Jun 2021 15:48:10 +0200 Subject: [PATCH 78/97] run idle timer outside of angular zone --- src/app/core/auth/auth.effects.ts | 34 ++++++++++++------- .../core/utilities/enter-zone.scheduler.ts | 19 +++++++++++ .../core/utilities/leave-zone.scheduler.ts | 19 +++++++++++ 3 files changed, 60 insertions(+), 12 deletions(-) create mode 100644 src/app/core/utilities/enter-zone.scheduler.ts create mode 100644 src/app/core/utilities/leave-zone.scheduler.ts diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts index f7b81dc4ef..8ce10c0c6b 100644 --- a/src/app/core/auth/auth.effects.ts +++ b/src/app/core/auth/auth.effects.ts @@ -1,7 +1,13 @@ -import { Injectable } from '@angular/core'; +import { Injectable, NgZone } from '@angular/core'; -import { combineLatest as observableCombineLatest, Observable, of as observableOf, timer } from 'rxjs'; -import { catchError, filter, map, switchMap, take, tap } from 'rxjs/operators'; +import { + combineLatest as observableCombineLatest, + Observable, + of as observableOf, + timer, + asyncScheduler, queueScheduler +} from 'rxjs'; +import { catchError, filter, map, switchMap, take, tap, observeOn } from 'rxjs/operators'; // import @ngrx import { Actions, Effect, ofType } from '@ngrx/effects'; import { Action, select, Store } from '@ngrx/store'; @@ -43,8 +49,8 @@ import { hasValue } from '../../shared/empty.util'; import { environment } from '../../../environments/environment'; import { RequestActionTypes } from '../data/request.actions'; import { NotificationsActionTypes } from '../../shared/notifications/notifications.actions'; -import { ObjectCacheActionTypes } from '../cache/object-cache.actions'; -import { NO_OP_ACTION_TYPE } from '../../shared/ngrx/no-op.action'; +import { LeaveZoneScheduler } from '../utilities/leave-zone.scheduler'; +import { EnterZoneScheduler } from '../utilities/enter-zone.scheduler'; // Action Types that do not break/prevent the user from an idle state const IDLE_TIMER_IGNORE_TYPES: string[] @@ -261,22 +267,26 @@ export class AuthEffects { @Effect() public trackIdleness$: Observable = this.actions$.pipe( filter((action: Action) => !IDLE_TIMER_IGNORE_TYPES.includes(action.type)), - // Using switchMap the timer will be interrupted and restarted if a new action comes in, so idleness timer restarts - switchMap(() => { - return timer(environment.auth.ui.timeUntilIdle); - }), - map(() => { - return new SetUserAsIdleAction(); - }) + // Using switchMap the effect will stop subscribing to the previous timer if a new action comes + // in, and start a new timer + switchMap(() => + // Start a timer outside of Angular's zone + timer(environment.auth.ui.timeUntilIdle, new LeaveZoneScheduler(this.zone, asyncScheduler)) + ), + // Re-enter the zone to dispatch the action + observeOn(new EnterZoneScheduler(this.zone, queueScheduler)), + map(() => new SetUserAsIdleAction()), ); /** * @constructor * @param {Actions} actions$ + * @param {NgZone} zone * @param {AuthService} authService * @param {Store} store */ constructor(private actions$: Actions, + private zone: NgZone, private authService: AuthService, private store: Store) { } diff --git a/src/app/core/utilities/enter-zone.scheduler.ts b/src/app/core/utilities/enter-zone.scheduler.ts new file mode 100644 index 0000000000..96aee7d9a5 --- /dev/null +++ b/src/app/core/utilities/enter-zone.scheduler.ts @@ -0,0 +1,19 @@ +import { SchedulerLike, Subscription } from 'rxjs'; +import { NgZone } from '@angular/core'; + +/** + * An RXJS scheduler that will re-enter the Angular zone to run what's scheduled + */ +export class EnterZoneScheduler implements SchedulerLike { + constructor(private zone: NgZone, private scheduler: SchedulerLike) { } + + schedule(...args: any[]): Subscription { + return this.zone.run(() => + this.scheduler.schedule.apply(this.scheduler, args) + ); + } + + now (): number { + return this.scheduler.now(); + } +} diff --git a/src/app/core/utilities/leave-zone.scheduler.ts b/src/app/core/utilities/leave-zone.scheduler.ts new file mode 100644 index 0000000000..2587563661 --- /dev/null +++ b/src/app/core/utilities/leave-zone.scheduler.ts @@ -0,0 +1,19 @@ +import { SchedulerLike, Subscription } from 'rxjs'; +import { NgZone } from '@angular/core'; + +/** + * An RXJS scheduler that will run what's scheduled outside of the Angular zone + */ +export class LeaveZoneScheduler implements SchedulerLike { + constructor(private zone: NgZone, private scheduler: SchedulerLike) { } + + schedule(...args: any[]): Subscription { + return this.zone.runOutsideAngular(() => + this.scheduler.schedule.apply(this.scheduler, args) + ); + } + + now (): number { + return this.scheduler.now(); + } +} From 4b1f0864696fb06a8a10457184b90dbe0dc15d75 Mon Sep 17 00:00:00 2001 From: Marie Verdonck Date: Fri, 18 Jun 2021 13:18:17 +0200 Subject: [PATCH 79/97] 79700: Feedback 2021-06-15 applied --- src/app/app.component.ts | 37 +++++++++++++++-- src/app/core/auth/auth.reducer.ts | 12 ++---- src/app/core/auth/auth.service.ts | 13 +++--- src/app/root/root.component.ts | 40 ++----------------- .../idle-modal/idle-modal.component.spec.ts | 12 +++++- .../shared/idle-modal/idle-modal.component.ts | 7 +++- 6 files changed, 64 insertions(+), 57 deletions(-) diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 2c01bf637b..48e1e6f3d1 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,4 +1,4 @@ -import { delay, distinctUntilChanged, filter, take } from 'rxjs/operators'; +import { delay, distinctUntilChanged, filter, take, withLatestFrom } from 'rxjs/operators'; import { AfterViewInit, ChangeDetectionStrategy, @@ -11,7 +11,7 @@ import { } from '@angular/core'; import { NavigationCancel, NavigationEnd, NavigationStart, Router } from '@angular/router'; -import { BehaviorSubject, Observable, of } from 'rxjs'; +import { BehaviorSubject, combineLatest as observableCombineLatest, Observable, of } from 'rxjs'; import { select, Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; @@ -38,6 +38,8 @@ import { ThemeService } from './shared/theme-support/theme.service'; import { BASE_THEME_NAME } from './shared/theme-support/theme.constants'; import { DEFAULT_THEME_CONFIG } from './shared/theme-support/theme.effects'; import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service'; +import { IdleModalComponent } from './shared/idle-modal/idle-modal.component'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; @Component({ selector: 'ds-app', @@ -70,6 +72,11 @@ export class AppComponent implements OnInit, AfterViewInit { isThemeLoading$: BehaviorSubject = new BehaviorSubject(false); + /** + * Whether or not the idle modal is is currently open + */ + idleModalOpen: boolean; + constructor( @Inject(NativeWindowService) private _window: NativeWindowRef, @Inject(DOCUMENT) private document: any, @@ -87,6 +94,7 @@ export class AppComponent implements OnInit, AfterViewInit { private windowService: HostWindowService, private localeService: LocaleService, private breadcrumbsService: BreadcrumbsService, + private modalService: NgbModal, @Optional() private cookiesService: KlaroService, @Optional() private googleAnalyticsService: GoogleAnalyticsService, ) { @@ -108,6 +116,11 @@ export class AppComponent implements OnInit, AfterViewInit { } }); + if (isPlatformBrowser(this.platformId)) { + this.authService.trackTokenExpiration(); + this.trackIdleModal(); + } + // Load all the languages that are defined as active from the config file translate.addLangs(environment.languages.filter((LangConfig) => LangConfig.active === true).map((a) => a.code)); @@ -130,7 +143,6 @@ export class AppComponent implements OnInit, AfterViewInit { console.info(environment); } this.storeCSSVariables(); - this.authService.trackTokenExpiration(); } ngOnInit() { @@ -229,4 +241,23 @@ export class AppComponent implements OnInit, AfterViewInit { }; head.appendChild(link); } + + private trackIdleModal() { + const isIdle$ = this.authService.isUserIdle(); + const isAuthenticated$ = this.authService.isAuthenticated(); + isIdle$.pipe(withLatestFrom(isAuthenticated$)) + .subscribe(([userIdle, authenticated]) => { + if (userIdle && authenticated) { + if (!this.idleModalOpen) { + const modalRef = this.modalService.open(IdleModalComponent); + this.idleModalOpen = true; + modalRef.componentInstance.response.pipe(take(1)).subscribe((closed: boolean) => { + if (closed) { + this.idleModalOpen = false; + } + }); + } + } + }); + } } diff --git a/src/app/core/auth/auth.reducer.ts b/src/app/core/auth/auth.reducer.ts index f26ddb0182..ef803503c8 100644 --- a/src/app/core/auth/auth.reducer.ts +++ b/src/app/core/auth/auth.reducer.ts @@ -240,15 +240,9 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut }); case AuthActionTypes.SET_USER_AS_IDLE: - if (state.authenticated) { - return Object.assign({}, state, { - idle: true, - }); - } else { - return Object.assign({}, state, { - idle: false, - }); - } + return Object.assign({}, state, { + idle: true, + }); case AuthActionTypes.UNSET_USER_AS_IDLE: return Object.assign({}, state, { diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 7b7c61f741..a5b5d70704 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -282,7 +282,7 @@ export class AuthService { // Send a request that sign end the session let headers = new HttpHeaders(); headers = headers.append('Content-Type', 'application/x-www-form-urlencoded'); - const options: HttpOptions = Object.create({headers, responseType: 'text'}); + const options: HttpOptions = Object.create({ headers, responseType: 'text' }); return this.authRequestService.postToEndpoint('logout', options).pipe( map((rd: RemoteData) => { const status = rd.payload; @@ -447,11 +447,14 @@ export class AuthService { * @param redirectUrl */ public navigateToRedirectUrl(redirectUrl: string) { - let url = `/reload/${new Date().getTime()}`; - if (isNotEmpty(redirectUrl) && !redirectUrl.startsWith(LOGIN_ROUTE)) { - url += `?redirect=${encodeURIComponent(redirectUrl)}`; + // Don't do redirect if already on reload url + if (!hasValue(redirectUrl) || !redirectUrl.includes('/reload/')) { + let url = `/reload/${new Date().getTime()}`; + if (isNotEmpty(redirectUrl) && !redirectUrl.startsWith(LOGIN_ROUTE)) { + url += `?redirect=${encodeURIComponent(redirectUrl)}`; + } + this.hardRedirectService.redirect(url); } - this.hardRedirectService.redirect(url); } /** diff --git a/src/app/root/root.component.ts b/src/app/root/root.component.ts index 81ae1a745c..209f17b520 100644 --- a/src/app/root/root.component.ts +++ b/src/app/root/root.component.ts @@ -1,13 +1,8 @@ -import { map, take } from 'rxjs/operators'; -import { Component, Inject, OnInit, Optional, Input } from '@angular/core'; +import { map } from 'rxjs/operators'; +import { Component, Inject, OnInit, Input } from '@angular/core'; import { Router } from '@angular/router'; -import { - combineLatest as observableCombineLatest, - combineLatest as combineLatestObservable, - Observable, - of -} from 'rxjs'; +import { combineLatest as combineLatestObservable, Observable, of } from 'rxjs'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; @@ -23,11 +18,7 @@ import { HostWindowService } from '../shared/host-window.service'; import { ThemeConfig } from '../../config/theme.model'; import { Angulartics2DSpace } from '../statistics/angulartics/dspace-provider'; import { environment } from '../../environments/environment'; -import { LocaleService } from '../core/locale/locale.service'; -import { KlaroService } from '../shared/cookies/klaro.service'; import { slideSidebarPadding } from '../shared/animations/slide'; -import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { IdleModalComponent } from '../shared/idle-modal/idle-modal.component'; @Component({ selector: 'ds-root', @@ -54,11 +45,6 @@ export class RootComponent implements OnInit { */ @Input() shouldShowRouteLoader: boolean; - /** - * Whether or not the idle modal is is currently open - */ - idleModalOpen: boolean; - constructor( @Inject(NativeWindowService) private _window: NativeWindowRef, private translate: TranslateService, @@ -70,10 +56,7 @@ export class RootComponent implements OnInit { private router: Router, private cssService: CSSVariableService, private menuService: MenuService, - private windowService: HostWindowService, - private localeService: LocaleService, - @Optional() private cookiesService: KlaroService, - private modalService: NgbModal + private windowService: HostWindowService ) { } @@ -88,20 +71,5 @@ export class RootComponent implements OnInit { .pipe( map(([collapsed, mobile]) => collapsed || mobile) ); - - observableCombineLatest([this.authService.isUserIdle(), this.authService.isAuthenticated()]) - .subscribe(([userIdle, authenticated]) => { - if (userIdle && authenticated) { - if (!this.idleModalOpen) { - const modalRef = this.modalService.open(IdleModalComponent); - this.idleModalOpen = true; - modalRef.componentInstance.response.pipe(take(1)).subscribe((closed: boolean) => { - if (closed) { - this.idleModalOpen = false; - } - }); - } - } - }); } } diff --git a/src/app/shared/idle-modal/idle-modal.component.spec.ts b/src/app/shared/idle-modal/idle-modal.component.spec.ts index 639cbd6ad1..552315d1a4 100644 --- a/src/app/shared/idle-modal/idle-modal.component.spec.ts +++ b/src/app/shared/idle-modal/idle-modal.component.spec.ts @@ -5,6 +5,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { IdleModalComponent } from './idle-modal.component'; import { AuthService } from '../../core/auth/auth.service'; import { By } from '@angular/platform-browser'; +import { HardRedirectService } from '../../core/services/hard-redirect.service'; describe('IdleModalComponent', () => { let component: IdleModalComponent; @@ -12,15 +13,18 @@ describe('IdleModalComponent', () => { let debugElement: DebugElement; const modalStub = jasmine.createSpyObj('modalStub', ['close']); - const authServiceStub = jasmine.createSpyObj('authService', ['setIdle', 'logout']); + const authServiceStub = jasmine.createSpyObj('authService', ['setIdle', 'logout', 'navigateToRedirectUrl']); + let hardRedirectService; beforeEach(waitForAsync(() => { + hardRedirectService = jasmine.createSpyObj('hardRedirectService', ['getCurrentRoute']); TestBed.configureTestingModule({ imports: [TranslateModule.forRoot()], declarations: [IdleModalComponent], providers: [ { provide: NgbActiveModal, useValue: modalStub }, - { provide: AuthService, useValue: authServiceStub } + { provide: AuthService, useValue: authServiceStub }, + { provide: HardRedirectService, useValue: hardRedirectService } ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); @@ -63,6 +67,10 @@ describe('IdleModalComponent', () => { it('should close the modal', () => { expect(modalStub.close).toHaveBeenCalled(); }); + it('should reload', () => { + expect(hardRedirectService.getCurrentRoute).toHaveBeenCalled(); + expect(authServiceStub.navigateToRedirectUrl).toHaveBeenCalled(); + }); }); describe('closePressed', () => { diff --git a/src/app/shared/idle-modal/idle-modal.component.ts b/src/app/shared/idle-modal/idle-modal.component.ts index 750657c2e4..d812d3ffc1 100644 --- a/src/app/shared/idle-modal/idle-modal.component.ts +++ b/src/app/shared/idle-modal/idle-modal.component.ts @@ -4,6 +4,7 @@ import { environment } from '../../../environments/environment'; import { AuthService } from '../../core/auth/auth.service'; import { Subject } from 'rxjs'; import { hasValue } from '../empty.util'; +import { HardRedirectService } from '../../core/services/hard-redirect.service'; @Component({ selector: 'ds-idle-modal', @@ -29,7 +30,8 @@ export class IdleModalComponent implements OnInit { response: Subject = new Subject(); constructor(private activeModal: NgbActiveModal, - private authService: AuthService) { + private authService: AuthService, + protected hardRedirectService: HardRedirectService) { this.timeToExpire = (environment.auth.ui.timeUntilIdle + environment.auth.ui.idleGracePeriod) / 1000 / 60; // ms => min } @@ -53,8 +55,9 @@ export class IdleModalComponent implements OnInit { * Close modal and logout */ logOutPressed() { - this.authService.logout(); this.closeModal(); + this.authService.logout(); + this.authService.navigateToRedirectUrl(this.hardRedirectService.getCurrentRoute()); } /** From c696b783931ccd0cbf35fc019a2a33338c9ad6f7 Mon Sep 17 00:00:00 2001 From: Marie Verdonck Date: Fri, 18 Jun 2021 13:24:34 +0200 Subject: [PATCH 80/97] 79700: idle time and grace period testing times removed --- src/environments/environment.common.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/environments/environment.common.ts b/src/environments/environment.common.ts index 24496386e9..87be9b4260 100644 --- a/src/environments/environment.common.ts +++ b/src/environments/environment.common.ts @@ -47,11 +47,9 @@ export const environment: GlobalConfig = { // Authority UI settings ui: { // the amount of time before the idle warning is shown - // timeUntilIdle: 15 * 60 * 1000, // 15 minutes - timeUntilIdle: 30 * 1000, // 30 seconds + timeUntilIdle: 15 * 60 * 1000, // 15 minutes // the amount of time the user has to react after the idle warning is shown before they are logged out. - // idleGracePeriod: 5 * 60 * 1000, // 5 minutes - idleGracePeriod: 1 * 60 * 1000, // 1 minute + idleGracePeriod: 5 * 60 * 1000, // 5 minutes }, // Authority REST settings rest: { From ddcb27da3f9f7144b2f5e8af2d9480da455cdb15 Mon Sep 17 00:00:00 2001 From: Marie Verdonck Date: Fri, 18 Jun 2021 16:01:53 +0200 Subject: [PATCH 81/97] 79700: logout via store, automatic redirect --- .../idle-modal/idle-modal.component.spec.ts | 23 +++++++++---------- .../shared/idle-modal/idle-modal.component.ts | 9 ++++---- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/app/shared/idle-modal/idle-modal.component.spec.ts b/src/app/shared/idle-modal/idle-modal.component.spec.ts index 552315d1a4..847bf6ac4f 100644 --- a/src/app/shared/idle-modal/idle-modal.component.spec.ts +++ b/src/app/shared/idle-modal/idle-modal.component.spec.ts @@ -5,26 +5,29 @@ import { TranslateModule } from '@ngx-translate/core'; import { IdleModalComponent } from './idle-modal.component'; import { AuthService } from '../../core/auth/auth.service'; import { By } from '@angular/platform-browser'; -import { HardRedirectService } from '../../core/services/hard-redirect.service'; +import { Store } from '@ngrx/store'; +import { LogOutAction } from '../../core/auth/auth.actions'; describe('IdleModalComponent', () => { let component: IdleModalComponent; let fixture: ComponentFixture; let debugElement: DebugElement; - const modalStub = jasmine.createSpyObj('modalStub', ['close']); - const authServiceStub = jasmine.createSpyObj('authService', ['setIdle', 'logout', 'navigateToRedirectUrl']); - let hardRedirectService; + let modalStub; + let authServiceStub; + let storeStub; beforeEach(waitForAsync(() => { - hardRedirectService = jasmine.createSpyObj('hardRedirectService', ['getCurrentRoute']); + modalStub = jasmine.createSpyObj('modalStub', ['close']); + authServiceStub = jasmine.createSpyObj('authService', ['setIdle']); + storeStub = jasmine.createSpyObj('store', ['dispatch']); TestBed.configureTestingModule({ imports: [TranslateModule.forRoot()], declarations: [IdleModalComponent], providers: [ { provide: NgbActiveModal, useValue: modalStub }, { provide: AuthService, useValue: authServiceStub }, - { provide: HardRedirectService, useValue: hardRedirectService } + { provide: Store, useValue: storeStub } ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); @@ -61,15 +64,11 @@ describe('IdleModalComponent', () => { beforeEach(() => { component.logOutPressed(); }); - it('should logout', () => { - expect(authServiceStub.logout).toHaveBeenCalled(); - }); it('should close the modal', () => { expect(modalStub.close).toHaveBeenCalled(); }); - it('should reload', () => { - expect(hardRedirectService.getCurrentRoute).toHaveBeenCalled(); - expect(authServiceStub.navigateToRedirectUrl).toHaveBeenCalled(); + it('should send logout action', () => { + expect(storeStub.dispatch).toHaveBeenCalledWith(new LogOutAction()); }); }); diff --git a/src/app/shared/idle-modal/idle-modal.component.ts b/src/app/shared/idle-modal/idle-modal.component.ts index d812d3ffc1..35fafcf5cf 100644 --- a/src/app/shared/idle-modal/idle-modal.component.ts +++ b/src/app/shared/idle-modal/idle-modal.component.ts @@ -4,7 +4,9 @@ import { environment } from '../../../environments/environment'; import { AuthService } from '../../core/auth/auth.service'; import { Subject } from 'rxjs'; import { hasValue } from '../empty.util'; -import { HardRedirectService } from '../../core/services/hard-redirect.service'; +import { Store } from '@ngrx/store'; +import { AppState } from '../../app.reducer'; +import { LogOutAction } from '../../core/auth/auth.actions'; @Component({ selector: 'ds-idle-modal', @@ -31,7 +33,7 @@ export class IdleModalComponent implements OnInit { constructor(private activeModal: NgbActiveModal, private authService: AuthService, - protected hardRedirectService: HardRedirectService) { + private store: Store) { this.timeToExpire = (environment.auth.ui.timeUntilIdle + environment.auth.ui.idleGracePeriod) / 1000 / 60; // ms => min } @@ -56,8 +58,7 @@ export class IdleModalComponent implements OnInit { */ logOutPressed() { this.closeModal(); - this.authService.logout(); - this.authService.navigateToRedirectUrl(this.hardRedirectService.getCurrentRoute()); + this.store.dispatch(new LogOutAction()); } /** From 829ce12710d7d1c13f93f00e0895614fb4bdebb5 Mon Sep 17 00:00:00 2001 From: Marie Verdonck Date: Fri, 18 Jun 2021 17:53:34 +0200 Subject: [PATCH 82/97] lgtm alerts --- src/app/app.component.ts | 2 +- src/app/core/auth/auth.interceptor.ts | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 48e1e6f3d1..4feee0e585 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -11,7 +11,7 @@ import { } from '@angular/core'; import { NavigationCancel, NavigationEnd, NavigationStart, Router } from '@angular/router'; -import { BehaviorSubject, combineLatest as observableCombineLatest, Observable, of } from 'rxjs'; +import { BehaviorSubject, Observable, of } from 'rxjs'; import { select, Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; diff --git a/src/app/core/auth/auth.interceptor.ts b/src/app/core/auth/auth.interceptor.ts index d16f46a849..a49030110b 100644 --- a/src/app/core/auth/auth.interceptor.ts +++ b/src/app/core/auth/auth.interceptor.ts @@ -1,6 +1,6 @@ import { Observable, of as observableOf, throwError as observableThrowError } from 'rxjs'; -import { catchError, filter, map } from 'rxjs/operators'; +import { catchError, map } from 'rxjs/operators'; import { Injectable, Injector } from '@angular/core'; import { HttpErrorResponse, @@ -12,14 +12,13 @@ import { HttpResponse, HttpResponseBase } from '@angular/common/http'; -import { find } from 'lodash'; import { AppState } from '../../app.reducer'; import { AuthService } from './auth.service'; import { AuthStatus } from './models/auth-status.model'; import { AuthTokenInfo } from './models/auth-token-info.model'; -import { hasValue, isNotEmpty, isNotNull, isUndefined } from '../../shared/empty.util'; -import { RedirectWhenTokenExpiredAction, RefreshTokenAction } from './auth.actions'; +import { hasValue, isNotEmpty, isNotNull } from '../../shared/empty.util'; +import { RedirectWhenTokenExpiredAction } from './auth.actions'; import { Store } from '@ngrx/store'; import { Router } from '@angular/router'; import { AuthMethod } from './models/auth.method'; From 6c219e72d53ece28293c60c22dea5b7ec1012d99 Mon Sep 17 00:00:00 2001 From: Marie Verdonck Date: Thu, 1 Jul 2021 15:50:21 +0200 Subject: [PATCH 83/97] 79700: Doc fixes, Spec tests authService & ariaLabelledBy for idle-modal --- src/app/app.component.ts | 2 +- src/app/core/auth/auth.service.spec.ts | 82 ++++++++++++++++++- .../idle-modal/idle-modal.component.html | 2 +- src/environments/environment.common.ts | 6 +- src/environments/mock-environment.ts | 6 +- 5 files changed, 88 insertions(+), 10 deletions(-) diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 4feee0e585..356025da9e 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -249,7 +249,7 @@ export class AppComponent implements OnInit, AfterViewInit { .subscribe(([userIdle, authenticated]) => { if (userIdle && authenticated) { if (!this.idleModalOpen) { - const modalRef = this.modalService.open(IdleModalComponent); + const modalRef = this.modalService.open(IdleModalComponent, { ariaLabelledBy: 'idle-modal.header' }); this.idleModalOpen = true; modalRef.componentInstance.response.pipe(take(1)).subscribe((closed: boolean) => { if (closed) { diff --git a/src/app/core/auth/auth.service.spec.ts b/src/app/core/auth/auth.service.spec.ts index d54ffdae16..ced8bb94c8 100644 --- a/src/app/core/auth/auth.service.spec.ts +++ b/src/app/core/auth/auth.service.spec.ts @@ -31,6 +31,7 @@ import { NotificationsService } from '../../shared/notifications/notifications.s import { TranslateService } from '@ngx-translate/core'; import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; +import { SetUserAsIdleAction, UnsetUserAsIdleAction } from './auth.actions'; describe('AuthService test', () => { @@ -51,6 +52,7 @@ describe('AuthService test', () => { let token: AuthTokenInfo; let authenticatedState; let unAuthenticatedState; + let idleState; let linkService; let hardRedirectService; @@ -68,14 +70,24 @@ describe('AuthService test', () => { loaded: true, loading: false, authToken: token, - user: EPersonMock + user: EPersonMock, + idle: false }; unAuthenticatedState = { authenticated: false, loaded: true, loading: false, authToken: undefined, - user: undefined + user: undefined, + idle: false + }; + idleState = { + authenticated: true, + loaded: true, + loading: false, + authToken: token, + user: EPersonMock, + idle: true }; authRequest = new AuthRequestServiceStub(); routeStub = new ActivatedRouteStub(); @@ -186,6 +198,26 @@ describe('AuthService test', () => { expect(authMethods.length).toBe(2); }); }); + + describe('setIdle true', () => { + beforeEach(() => { + authService.setIdle(true); + }); + + it('store should dispatch SetUserAsIdleAction', () => { + expect(mockStore.dispatch).toHaveBeenCalledWith(new SetUserAsIdleAction()); + }); + }); + + describe('setIdle false', () => { + beforeEach(() => { + authService.setIdle(false); + }); + + it('store should dispatch UnsetUserAsIdleAction', () => { + expect(mockStore.dispatch).toHaveBeenCalledWith(new UnsetUserAsIdleAction()); + }); + }); }); describe('', () => { @@ -256,6 +288,12 @@ describe('AuthService test', () => { }); }); + it('isUserIdle should return false when user is not yet idle', () => { + authService.isUserIdle().subscribe((status: boolean) => { + expect(status).toBe(false); + }); + }); + }); describe('', () => { @@ -514,4 +552,44 @@ describe('AuthService test', () => { }); }); }); + + describe('when user is idle', () => { + beforeEach(waitForAsync(() => { + init(); + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({ authReducer }, { + runtimeChecks: { + strictStateImmutability: false, + strictActionImmutability: false + } + }) + ], + providers: [ + { provide: AuthRequestService, useValue: authRequest }, + { provide: REQUEST, useValue: {} }, + { provide: Router, useValue: routerStub }, + { provide: RouteService, useValue: routeServiceStub }, + { provide: RemoteDataBuildService, useValue: linkService }, + CookieService, + AuthService + ] + }).compileComponents(); + })); + + beforeEach(inject([CookieService, AuthRequestService, Store, Router, RouteService], (cookieService: CookieService, authReqService: AuthRequestService, store: Store, router: Router, routeService: RouteService, notificationsService: NotificationsService, translateService: TranslateService) => { + store + .subscribe((state) => { + (state as any).core = Object.create({}); + (state as any).core.auth = idleState; + }); + authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService); + })); + + it('isUserIdle should return true when user is not idle', () => { + authService.isUserIdle().subscribe((status: boolean) => { + expect(status).toBe(true); + }); + }); + }); }); diff --git a/src/app/shared/idle-modal/idle-modal.component.html b/src/app/shared/idle-modal/idle-modal.component.html index 665ebb9672..beea91fe7b 100644 --- a/src/app/shared/idle-modal/idle-modal.component.html +++ b/src/app/shared/idle-modal/idle-modal.component.html @@ -1,5 +1,5 @@
- From 50400895de6aca9ebe85068a12092d788e7d71ba Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Thu, 1 Jul 2021 22:36:02 +0200 Subject: [PATCH 85/97] 79768: Add unit tests for metaTagReducer --- src/app/core/metadata/meta-tag.actions.ts | 1 - .../core/metadata/meta-tag.reducer.spec.ts | 50 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 src/app/core/metadata/meta-tag.reducer.spec.ts diff --git a/src/app/core/metadata/meta-tag.actions.ts b/src/app/core/metadata/meta-tag.actions.ts index 6451e58da2..cd048d3be2 100644 --- a/src/app/core/metadata/meta-tag.actions.ts +++ b/src/app/core/metadata/meta-tag.actions.ts @@ -1,6 +1,5 @@ import { type } from '../../shared/ngrx/type'; import { Action } from '@ngrx/store'; -import { MetaDefinition } from '@angular/platform-browser'; // tslint:disable:max-classes-per-file export const MetaTagTypes = { diff --git a/src/app/core/metadata/meta-tag.reducer.spec.ts b/src/app/core/metadata/meta-tag.reducer.spec.ts new file mode 100644 index 0000000000..1fcd7d83e3 --- /dev/null +++ b/src/app/core/metadata/meta-tag.reducer.spec.ts @@ -0,0 +1,50 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + +import { metaTagReducer } from './meta-tag.reducer'; +import { AddMetaTagAction, ClearMetaTagAction } from './meta-tag.actions'; + +const nullAction = { type: null }; + +describe('metaTagReducer', () => { + it('should start with an empty array', () => { + const state0 = metaTagReducer(undefined, nullAction); + expect(state0.tagsInUse).toEqual([]); + }); + + it('should return the current state on invalid action', () => { + const state0 = { + tagsInUse: ['foo', 'bar'], + }; + + const state1 = metaTagReducer(state0, nullAction); + expect(state1).toEqual(state0); + }); + + it('should add tags on AddMetaTagAction', () => { + const state0 = { + tagsInUse: ['foo'], + }; + + const state1 = metaTagReducer(state0, new AddMetaTagAction('bar')); + const state2 = metaTagReducer(state1, new AddMetaTagAction('baz')); + + expect(state1.tagsInUse).toEqual(['foo', 'bar']); + expect(state2.tagsInUse).toEqual(['foo', 'bar', 'baz']); + }); + + it('should clear tags on ClearMetaTagAction', () => { + const state0 = { + tagsInUse: ['foo', 'bar'], + }; + + const state1 = metaTagReducer(state0, new ClearMetaTagAction()); + + expect(state1.tagsInUse).toEqual([]); + }); +}); From fb8f28f17d1047986e3ace7331e3f32111b7c46a Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Thu, 1 Jul 2021 22:37:07 +0200 Subject: [PATCH 86/97] 79768: Update & add MetadataService unit tests --- .../core/metadata/metadata.service.spec.ts | 50 ++++++++++++++++--- src/app/core/metadata/metadata.service.ts | 16 +++--- 2 files changed, 49 insertions(+), 17 deletions(-) diff --git a/src/app/core/metadata/metadata.service.spec.ts b/src/app/core/metadata/metadata.service.spec.ts index d18897cc55..f946120cd2 100644 --- a/src/app/core/metadata/metadata.service.spec.ts +++ b/src/app/core/metadata/metadata.service.spec.ts @@ -1,6 +1,6 @@ import { fakeAsync, tick } from '@angular/core/testing'; import { Meta, Title } from '@angular/platform-browser'; -import { Router, NavigationEnd } from '@angular/router'; +import { NavigationEnd, Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { Observable, of } from 'rxjs'; @@ -8,11 +8,8 @@ import { Observable, of } from 'rxjs'; import { RemoteData } from '../data/remote-data'; import { Item } from '../shared/item.model'; -import { ItemMock, MockBitstream1, MockBitstream3, } from '../../shared/mocks/item.mock'; -import { - createSuccessfulRemoteDataObject$, - createSuccessfulRemoteDataObject -} from '../../shared/remote-data.utils'; +import { ItemMock, MockBitstream1, MockBitstream3 } from '../../shared/mocks/item.mock'; +import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { PaginatedList } from '../data/paginated-list.model'; import { Bitstream } from '../shared/bitstream.model'; import { MetadataValue } from '../shared/metadata.models'; @@ -24,6 +21,11 @@ import { createPaginatedList } from '../../shared/testing/utils.test'; import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; import { DSONameService } from '../breadcrumbs/dso-name.service'; import { HardRedirectService } from '../services/hard-redirect.service'; +import { getMockStore, MockStore } from '@ngrx/store/testing'; +import { CoreState } from '../core.reducers'; +import { MetaTagState } from './meta-tag.reducer'; +import { AddMetaTagAction, ClearMetaTagAction } from './meta-tag.actions'; +import { Community } from '../shared/community.model'; describe('MetadataService', () => { let metadataService: MetadataService; @@ -41,6 +43,10 @@ describe('MetadataService', () => { let hardRedirectService: HardRedirectService; let router: Router; + let store; + + const initialState = { 'core': { metaTag: { tagsInUse: ['title', 'description'] }}}; + beforeEach(() => { rootService = jasmine.createSpyObj({ @@ -53,7 +59,7 @@ describe('MetadataService', () => { findByItemAndName: mockBundleRD$([MockBitstream3]) }); translateService = getMockTranslateService(); - meta = jasmine.createSpyObj({ + meta = jasmine.createSpyObj('meta', { addTag: {}, removeTag: {} }); @@ -73,6 +79,11 @@ describe('MetadataService', () => { hardRedirectService = jasmine.createSpyObj( { getRequestOrigin: 'https://request.org', }); + + //@ts-ignore + store = getMockStore({ initialState }); + spyOn(store, 'dispatch'); + metadataService = new MetadataService( router, translateService, @@ -83,6 +94,7 @@ describe('MetadataService', () => { bitstreamDataService, undefined, rootService, + store, hardRedirectService ); }); @@ -332,6 +344,30 @@ describe('MetadataService', () => { }); }); + describe('tagstore', () => { + beforeEach(fakeAsync(() => { + (metadataService as any).processRouteChange({ + data: { + value: { + dso: createSuccessfulRemoteDataObject(ItemMock), + } + } + }); + tick(); + })); + + it('should remove previous tags on route change', fakeAsync(() => { + expect(meta.removeTag).toHaveBeenCalledWith('property=\'title\''); + expect(meta.removeTag).toHaveBeenCalledWith('property=\'description\''); + })); + + it('should clear all tags and add new ones on route change', () => { + expect(store.dispatch.calls.argsFor(0)).toEqual([new ClearMetaTagAction()]); + expect(store.dispatch.calls.argsFor(1)).toEqual([new AddMetaTagAction('title')]); + expect(store.dispatch.calls.argsFor(2)).toEqual([new AddMetaTagAction('description')]); + }); + }); + const mockType = (mockItem: Item, type: string): Item => { const typedMockItem = Object.assign(new Item(), mockItem) as Item; typedMockItem.metadata['dc.type'] = [{ value: type }] as MetadataValue[]; diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts index 8c1e1027dd..f42747fbc5 100644 --- a/src/app/core/metadata/metadata.service.ts +++ b/src/app/core/metadata/metadata.service.ts @@ -5,10 +5,10 @@ import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; -import { BehaviorSubject, combineLatest, Observable, of as observableOf, EMPTY } from 'rxjs'; -import { filter, map, take, switchMap, expand } from 'rxjs/operators'; +import { BehaviorSubject, combineLatest, EMPTY, Observable, of as observableOf } from 'rxjs'; +import { expand, filter, map, switchMap, take } from 'rxjs/operators'; -import { hasValue, hasNoValue } from '../../shared/empty.util'; +import { hasNoValue, hasValue } from '../../shared/empty.util'; import { DSONameService } from '../breadcrumbs/dso-name.service'; import { BitstreamDataService } from '../data/bitstream-data.service'; import { BitstreamFormatDataService } from '../data/bitstream-format-data.service'; @@ -18,10 +18,7 @@ import { BitstreamFormat } from '../shared/bitstream-format.model'; import { Bitstream } from '../shared/bitstream.model'; import { DSpaceObject } from '../shared/dspace-object.model'; import { Item } from '../shared/item.model'; -import { - getFirstSucceededRemoteDataPayload, - getFirstCompletedRemoteData -} from '../shared/operators'; +import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../shared/operators'; import { RootDataService } from '../data/root-data.service'; import { getBitstreamDownloadRoute } from '../../app-routing-paths'; import { BundleDataService } from '../data/bundle-data.service'; @@ -31,11 +28,10 @@ import { PaginatedList } from '../data/paginated-list.model'; import { URLCombiner } from '../url-combiner/url-combiner'; import { HardRedirectService } from '../services/hard-redirect.service'; import { MetaTagState } from './meta-tag.reducer'; -import { Store, createSelector, select, MemoizedSelector } from '@ngrx/store'; +import { createSelector, select, Store } from '@ngrx/store'; import { AddMetaTagAction, ClearMetaTagAction } from './meta-tag.actions'; import { coreSelector } from '../core.selectors'; import { CoreState } from '../core.reducers'; -import { ObjectCacheEntry, ObjectCacheState } from '../cache/object-cache.reducer'; /** * The base selector function to select the metaTag section in the store @@ -84,7 +80,7 @@ export class MetadataService { private bitstreamDataService: BitstreamDataService, private bitstreamFormatDataService: BitstreamFormatDataService, private rootService: RootDataService, - private store: Store, + private store: Store, private hardRedirectService: HardRedirectService, ) { } From 8caa9163165df0b669eb8ad9dd4f5d23b9540529 Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Thu, 1 Jul 2021 22:37:55 +0200 Subject: [PATCH 87/97] 79768: Fix typo --- src/app/core/metadata/metadata.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts index f42747fbc5..bd0ad66c7b 100644 --- a/src/app/core/metadata/metadata.service.ts +++ b/src/app/core/metadata/metadata.service.ts @@ -184,7 +184,7 @@ export class MetadataService { private setDescriptionTag(): void { // TODO: truncate abstract const value = this.getMetaTagValue('dc.description.abstract'); - this.addMetaTag('desciption', value); + this.addMetaTag('description', value); } /** From 5ed41b3f9bc8a611fe034697efc49c1127580e37 Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Thu, 1 Jul 2021 22:47:26 +0200 Subject: [PATCH 88/97] 79768: Fix unused imports & lint issue --- src/app/core/metadata/metadata.service.spec.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/app/core/metadata/metadata.service.spec.ts b/src/app/core/metadata/metadata.service.spec.ts index f946120cd2..b3404e84d5 100644 --- a/src/app/core/metadata/metadata.service.spec.ts +++ b/src/app/core/metadata/metadata.service.spec.ts @@ -21,11 +21,8 @@ import { createPaginatedList } from '../../shared/testing/utils.test'; import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; import { DSONameService } from '../breadcrumbs/dso-name.service'; import { HardRedirectService } from '../services/hard-redirect.service'; -import { getMockStore, MockStore } from '@ngrx/store/testing'; -import { CoreState } from '../core.reducers'; -import { MetaTagState } from './meta-tag.reducer'; +import { getMockStore } from '@ngrx/store/testing'; import { AddMetaTagAction, ClearMetaTagAction } from './meta-tag.actions'; -import { Community } from '../shared/community.model'; describe('MetadataService', () => { let metadataService: MetadataService; @@ -80,7 +77,7 @@ describe('MetadataService', () => { getRequestOrigin: 'https://request.org', }); - //@ts-ignore + // @ts-ignore store = getMockStore({ initialState }); spyOn(store, 'dispatch'); From a91f16ed62ab4bf050a21c900de9f5d6b8108c91 Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Thu, 1 Jul 2021 23:11:57 +0200 Subject: [PATCH 89/97] 79768: Fix followLink syntax --- src/app/core/metadata/metadata.service.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts index bd0ad66c7b..10e37b4282 100644 --- a/src/app/core/metadata/metadata.service.ts +++ b/src/app/core/metadata/metadata.service.ts @@ -292,13 +292,7 @@ export class MetadataService { true, true, followLink('primaryBitstream'), - followLink('bitstreams', - undefined, - true, - true, - true, - followLink('format') - ) + followLink('bitstreams', {}, followLink('format')), ).pipe( getFirstSucceededRemoteDataPayload(), switchMap((bundle: Bundle) => From badf901361aefb8bed5def41a35d288e6233addd Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Fri, 2 Jul 2021 13:46:17 +0200 Subject: [PATCH 90/97] Use ds-file-download-link component to allow bitstream download during submission --- .../file-download-link.component.html | 5 ++-- .../file-download-link.component.ts | 11 ++++++++ .../file/section-upload-file.component.html | 5 ++-- .../section-upload-file.component.spec.ts | 26 ------------------- .../file/section-upload-file.component.ts | 21 +++++++-------- 5 files changed, 26 insertions(+), 42 deletions(-) diff --git a/src/app/shared/file-download-link/file-download-link.component.html b/src/app/shared/file-download-link/file-download-link.component.html index f1843da5c6..497502d586 100644 --- a/src/app/shared/file-download-link/file-download-link.component.html +++ b/src/app/shared/file-download-link/file-download-link.component.html @@ -1,5 +1,6 @@ - - + + + diff --git a/src/app/shared/file-download-link/file-download-link.component.ts b/src/app/shared/file-download-link/file-download-link.component.ts index 4423b6f5b7..e2f7633275 100644 --- a/src/app/shared/file-download-link/file-download-link.component.ts +++ b/src/app/shared/file-download-link/file-download-link.component.ts @@ -18,6 +18,17 @@ export class FileDownloadLinkComponent implements OnInit { * Optional bitstream instead of href and file name */ @Input() bitstream: Bitstream; + + /** + * Additional css classes to apply to link + */ + @Input() cssClasses = ''; + + /** + * Optional bitstream link, show in same tab or a new tab. + */ + @Input() isBlank = false; + bitstreamPath: string; ngOnInit() { diff --git a/src/app/submission/sections/upload/file/section-upload-file.component.html b/src/app/submission/sections/upload/file/section-upload-file.component.html index 64df1155bf..221d396a39 100644 --- a/src/app/submission/sections/upload/file/section-upload-file.component.html +++ b/src/app/submission/sections/upload/file/section-upload-file.component.html @@ -10,8 +10,9 @@
- - + + +