From 819610476ef91fcee5748d3f49788ce79e2c86c6 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Fri, 20 Jul 2018 16:04:02 +0200 Subject: [PATCH 001/457] 54472: Intermediate commit --- .../create-community-page.component.html | 0 .../create-community-page.component.ts | 9 +++++++++ 2 files changed, 9 insertions(+) create mode 100644 src/app/+community-page/create-community-page/create-community-page.component.html create mode 100644 src/app/+community-page/create-community-page/create-community-page.component.ts diff --git a/src/app/+community-page/create-community-page/create-community-page.component.html b/src/app/+community-page/create-community-page/create-community-page.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/+community-page/create-community-page/create-community-page.component.ts b/src/app/+community-page/create-community-page/create-community-page.component.ts new file mode 100644 index 0000000000..ffc6642cdf --- /dev/null +++ b/src/app/+community-page/create-community-page/create-community-page.component.ts @@ -0,0 +1,9 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'ds-create-community', + templateUrl: './create-community-page.component.html' +}) +export class CreateCommunityPageComponent { + +} From 0da645931cf5eea983abd805c5547189d10f0312 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Mon, 23 Jul 2018 14:29:32 +0200 Subject: [PATCH 002/457] 54472: Create Community page --- resources/i18n/en.json | 15 ++++++++++ .../community-form.component.html | 27 +++++++++++++++++ .../community-form.component.scss | 7 +++++ .../community-form.component.ts | 30 +++++++++++++++++++ .../community-page-routing.module.ts | 2 ++ .../+community-page/community-page.module.ts | 4 +++ .../create-community-page.component.html | 8 +++++ .../create-community-page.component.scss | 1 + .../create-community-page.component.ts | 5 ++++ src/app/core/comcol/comcol.service.ts | 0 10 files changed, 99 insertions(+) create mode 100644 src/app/+community-page/community-form/community-form.component.html create mode 100644 src/app/+community-page/community-form/community-form.component.scss create mode 100644 src/app/+community-page/community-form/community-form.component.ts create mode 100644 src/app/+community-page/create-community-page/create-community-page.component.scss create mode 100644 src/app/core/comcol/comcol.service.ts diff --git a/resources/i18n/en.json b/resources/i18n/en.json index ba70b87e12..c0a213fe1b 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -22,6 +22,21 @@ }, "sub-collection-list": { "head": "Collections of this Community" + }, + "edit": { + "name": "Name", + "description": "Short Description", + "introductory": "Introductory text (HTML)", + "copyright": "Copyright text (HTML)", + "news": "News (HTML)", + "submit": "Submit", + "cancel": "Cancel", + "required": { + "name": "Please enter a community name" + } + }, + "create": { + "head": "Create a Community" } }, "item": { diff --git a/src/app/+community-page/community-form/community-form.component.html b/src/app/+community-page/community-form/community-form.component.html new file mode 100644 index 0000000000..bc45b582ac --- /dev/null +++ b/src/app/+community-page/community-form/community-form.component.html @@ -0,0 +1,27 @@ +
+
+ + +
{{ 'community.edit.required.name' | translate }}
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
diff --git a/src/app/+community-page/community-form/community-form.component.scss b/src/app/+community-page/community-form/community-form.component.scss new file mode 100644 index 0000000000..d5811186e7 --- /dev/null +++ b/src/app/+community-page/community-form/community-form.component.scss @@ -0,0 +1,7 @@ +@import '../../../styles/variables.scss'; + +// temporary fix for bootstrap 4 beta btn color issue +.btn-secondary { + background-color: $input-bg; + color: $input-color; +} diff --git a/src/app/+community-page/community-form/community-form.component.ts b/src/app/+community-page/community-form/community-form.component.ts new file mode 100644 index 0000000000..64d08ab862 --- /dev/null +++ b/src/app/+community-page/community-form/community-form.component.ts @@ -0,0 +1,30 @@ +import { Component, EventEmitter, Output } from '@angular/core'; +import { isNotEmpty } from '../../shared/empty.util'; + +@Component({ + selector: 'ds-community-form', + styleUrls: ['./community-form.component.scss'], + templateUrl: './community-form.component.html' +}) +export class CommunityFormComponent { + + name: string; + description: string; + introductory: string; + copyright: string; + news: string; + + nameRequiredError = false; + + @Output() submitted: EventEmitter = new EventEmitter(); + + onSubmit(data: any) { + if (isNotEmpty(data.name)) { + this.submitted.emit(data); + this.nameRequiredError = false; + } else { + this.nameRequiredError = true; + } + } + +} diff --git a/src/app/+community-page/community-page-routing.module.ts b/src/app/+community-page/community-page-routing.module.ts index 6fd5cc8cb5..249b01ea18 100644 --- a/src/app/+community-page/community-page-routing.module.ts +++ b/src/app/+community-page/community-page-routing.module.ts @@ -2,10 +2,12 @@ import { NgModule } from '@angular/core'; import { RouterModule } from '@angular/router'; import { CommunityPageComponent } from './community-page.component'; +import { CreateCommunityPageComponent } from './create-community-page/create-community-page.component'; @NgModule({ imports: [ RouterModule.forChild([ + { path: 'create', component: CreateCommunityPageComponent }, { path: ':id', component: CommunityPageComponent, pathMatch: 'full' } ]) ] diff --git a/src/app/+community-page/community-page.module.ts b/src/app/+community-page/community-page.module.ts index e00c3910c5..292e6aaf9c 100644 --- a/src/app/+community-page/community-page.module.ts +++ b/src/app/+community-page/community-page.module.ts @@ -6,6 +6,8 @@ import { SharedModule } from '../shared/shared.module'; import { CommunityPageComponent } from './community-page.component'; import { CommunityPageSubCollectionListComponent } from './sub-collection-list/community-page-sub-collection-list.component'; import { CommunityPageRoutingModule } from './community-page-routing.module'; +import { CreateCommunityPageComponent } from './create-community-page/create-community-page.component'; +import { CommunityFormComponent } from './community-form/community-form.component'; @NgModule({ imports: [ @@ -16,6 +18,8 @@ import { CommunityPageRoutingModule } from './community-page-routing.module'; declarations: [ CommunityPageComponent, CommunityPageSubCollectionListComponent, + CreateCommunityPageComponent, + CommunityFormComponent ] }) export class CommunityPageModule { diff --git a/src/app/+community-page/create-community-page/create-community-page.component.html b/src/app/+community-page/create-community-page/create-community-page.component.html index e69de29bb2..ea270db92b 100644 --- a/src/app/+community-page/create-community-page/create-community-page.component.html +++ b/src/app/+community-page/create-community-page/create-community-page.component.html @@ -0,0 +1,8 @@ +
+
+
+ +
+
+ +
diff --git a/src/app/+community-page/create-community-page/create-community-page.component.scss b/src/app/+community-page/create-community-page/create-community-page.component.scss new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/src/app/+community-page/create-community-page/create-community-page.component.scss @@ -0,0 +1 @@ + diff --git a/src/app/+community-page/create-community-page/create-community-page.component.ts b/src/app/+community-page/create-community-page/create-community-page.component.ts index ffc6642cdf..db2e4f25fa 100644 --- a/src/app/+community-page/create-community-page/create-community-page.component.ts +++ b/src/app/+community-page/create-community-page/create-community-page.component.ts @@ -2,8 +2,13 @@ import { Component } from '@angular/core'; @Component({ selector: 'ds-create-community', + styleUrls: ['./create-community-page.component.scss'], templateUrl: './create-community-page.component.html' }) export class CreateCommunityPageComponent { + onSubmit(data: any) { + console.log('yay, made it with name: ' + data.name); + } + } diff --git a/src/app/core/comcol/comcol.service.ts b/src/app/core/comcol/comcol.service.ts new file mode 100644 index 0000000000..e69de29bb2 From 6f60cd68e282c6988ddb6c5ea2d5cf2d43619753 Mon Sep 17 00:00:00 2001 From: Giuseppe Date: Thu, 26 Jul 2018 18:36:36 +0200 Subject: [PATCH 003/457] Merged submission module code --- config/environment.default.js | 37 + package.json | 4 +- resources/i18n/en.json | 95 ++ src/app/+item-page/item-page.module.ts | 4 +- .../+login-page/login-page-routing.module.ts | 2 +- src/app/+login-page/login-page.component.ts | 49 +- .../submit-page-routing.module.ts | 20 + src/app/+submit-page/submit-page.module.ts | 17 + .../workflowitems-edit-page-routing.module.ts | 20 + .../workflowitems-edit-page.module.ts | 18 + ...workspaceitems-edit-page-routing.module.ts | 20 + .../workspaceitems-edit-page.module.ts | 18 + src/app/app-routing.module.ts | 3 + src/app/app.component.html | 2 - src/app/app.component.scss | 6 +- src/app/app.module.ts | 2 + .../models/normalized-collection.model.ts | 16 +- .../cache/models/normalized-license.model.ts | 21 + .../cache/models/normalized-object-factory.ts | 21 + .../normalized-resource-policy.model.ts | 45 + src/app/core/cache/response-cache.models.ts | 31 + .../submission-uploads-config.service.ts | 18 + src/app/core/core.effects.ts | 4 +- src/app/core/core.module.ts | 29 +- src/app/core/core.reducers.ts | 5 +- .../data/base-response-parsing.service.ts | 2 +- src/app/core/data/collection-data.service.ts | 1 + src/app/core/data/comcol-data.service.spec.ts | 1 + src/app/core/data/community-data.service.ts | 1 + .../data/config-response-parsing.service.ts | 2 +- src/app/core/data/data.service.ts | 111 +- src/app/core/data/item-data.service.ts | 1 + src/app/core/data/request.models.ts | 85 +- src/app/core/data/request.service.ts | 28 +- src/app/core/eperson/eperson-data.ts | 12 + .../core/eperson/eperson-object-factory.ts | 21 + .../eperson-response-parsing.service.ts | 47 + src/app/core/eperson/eperson-type.ts | 5 + src/app/core/eperson/eperson.service.ts | 53 + src/app/core/eperson/group-eperson.service.ts | 55 + .../eperson/models/NormalizedEperson.model.ts | 9 +- src/app/core/eperson/models/group.model.ts | 2 + .../models/authority-value.model.ts | 5 + .../json-patch-operation-path-combiner.ts | 45 + .../builder/json-patch-operations-builder.ts | 111 ++ .../json-patch-operations.actions.ts | 279 +++++ .../json-patch-operations.effects.ts | 20 + .../json-patch-operations.reducer.ts | 292 +++++ .../json-patch-operations.service.ts | 127 ++ src/app/core/json-patch/json-patch.model.ts | 14 + src/app/core/json-patch/selectors.ts | 34 + src/app/core/shared/collection.model.ts | 14 +- .../config-access-condition-option.model.ts | 8 + .../shared/config/config-object-factory.ts | 8 +- .../config/config-submission-section.model.ts | 5 +- .../config/config-submission-uploads.model.ts | 21 + src/app/core/shared/config/config-type.ts | 4 +- src/app/core/shared/dspace-object.model.ts | 6 +- src/app/core/shared/file.service.ts | 35 + src/app/core/shared/hal-endpoint.service.ts | 3 +- src/app/core/shared/item.model.ts | 2 + src/app/core/shared/license.model.ts | 14 + src/app/core/shared/operators.ts | 4 +- src/app/core/shared/patch-request.model.ts | 14 + src/app/core/shared/resource-policy.model.ts | 34 + src/app/core/shared/resource-type.ts | 4 + .../submit-data-response-definition.model.ts | 11 + .../core/submission/models/edititem.model.ts | 4 + .../models/normalized-edititem.model.ts | 47 + .../normalized-submission-object.model.ts | 8 + .../models/normalized-workflowitem.model.ts | 47 + .../models/normalized-workspaceitem.model.ts | 51 + .../models/submission-object.model.ts | 43 + ...sion-upload-file-access-condition.model.ts | 7 + .../submission/models/workflowitem.model.ts | 4 + ...rkspaceitem-section-deduplication.model.ts | 21 + .../workspaceitem-section-form.model.ts | 5 + .../workspaceitem-section-license.model.ts | 5 + .../workspaceitem-section-recycle.model.ts | 8 + ...workspaceitem-section-upload-file.model.ts | 15 + .../workspaceitem-section-upload.model.ts | 5 + .../models/workspaceitem-sections.model.ts | 65 ++ .../submission/models/workspaceitem.model.ts | 5 + .../normalized-submission-object-factory.ts | 69 ++ .../submission/submission-resource-type.ts | 24 + .../submission-response-parsing.service.ts | 99 ++ .../core/submission/submission-scope-type.ts | 5 + .../submission/workflowitem-data.service.ts | 35 + .../submission/workspaceitem-data.service.ts | 35 + src/app/shared/alerts/alerts.component.html | 9 + src/app/shared/alerts/alerts.component.scss | 3 + src/app/shared/alerts/alerts.component.ts | 44 + src/app/shared/alerts/aletrs-type.ts | 6 + src/app/shared/empty.util.ts | 45 + .../dynamic-group/dynamic-group.components.ts | 3 +- .../shared/form/builder/parsers/row-parser.ts | 3 +- src/app/shared/shared.module.ts | 4 +- .../truncatable-part.component.ts | 5 +- .../shared/uploader/uploader.component.scss | 2 +- .../edit/submission-edit.component.html | 7 + .../edit/submission-edit.component.scss | 0 .../edit/submission-edit.component.ts | 74 ++ .../submission-form-collection.component.html | 37 + .../submission-form-collection.component.scss | 17 + .../submission-form-collection.component.ts | 168 +++ .../submission-form-footer.component.html | 52 + .../submission-form-footer.component.scss | 0 .../submission-form-footer.component.ts | 70 ++ ...submission-form-section-add.component.html | 14 + ...submission-form-section-add.component.scss | 9 + .../submission-form-section-add.component.ts | 33 + .../form/submission-form.component.html | 36 + .../form/submission-form.component.scss | 21 + .../form/submission-form.component.ts | 139 +++ .../submission-upload-files.component.html | 7 + .../submission-upload-files.component.ts | 96 ++ .../objects/submission-objects.actions.ts | 1032 +++++++++++++++++ .../objects/submission-objects.effects.ts | 338 ++++++ .../objects/submission-objects.reducer.ts | 844 ++++++++++++++ .../section-container.component.html | 47 + .../section-container.component.scss | 11 + .../container/section-container.component.ts | 52 + .../deduplication/deduplication.service.ts | 51 + .../match/deduplication-match.component.html | 89 ++ .../match/deduplication-match.component.ts | 169 +++ .../section-deduplication.component.html | 39 + .../section-deduplication.component.ts | 73 ++ .../default/section-default.component.html | 6 + .../default/section-default.component.scss | 0 .../default/section-default.component.ts | 21 + .../sections/form/form-operations.service.ts | 242 ++++ .../sections/form/section-form.component.html | 9 + .../sections/form/section-form.component.scss | 1 + .../sections/form/section-form.component.ts | 248 ++++ .../license/section-license.component.html | 7 + .../license/section-license.component.scss | 0 .../license/section-license.component.ts | 139 +++ .../sections/license/section-license.model.ts | 17 + .../sections/models/section-data.model.ts | 15 + .../sections/models/section.model.ts | 24 + .../recycle/section-recycle.component.html | 39 + .../recycle/section-recycle.component.scss | 0 .../recycle/section-recycle.component.ts | 55 + .../submission/sections/sections-decorator.ts | 16 + src/app/submission/sections/sections-type.ts | 9 + .../submission/sections/sections.directive.ts | 155 +++ .../submission/sections/sections.service.ts | 154 +++ .../accessConditions.component.html | 9 + .../accessConditions.component.ts | 35 + .../upload/file/edit/file-edit.component.html | 8 + .../upload/file/edit/file-edit.component.ts | 259 +++++ .../upload/file/edit/files-edit.model.ts | 115 ++ .../sections/upload/file/file.component.html | 56 + .../sections/upload/file/file.component.ts | 181 +++ .../upload/file/view/file-view.component.html | 25 + .../upload/file/view/file-view.component.ts | 29 + .../upload/section-upload.component.html | 50 + .../upload/section-upload.component.scss | 0 .../upload/section-upload.component.ts | 185 +++ .../sections/upload/section-upload.service.ts | 60 + src/app/submission/selectors.ts | 60 + .../submission/server-submission.service.ts | 24 + src/app/submission/submission-rest.service.ts | 124 ++ src/app/submission/submission.effects.ts | 5 + src/app/submission/submission.module.ts | 90 ++ src/app/submission/submission.reducers.ts | 16 + src/app/submission/submission.service.ts | 223 ++++ .../submit/submission-submit.component.html | 8 + .../submit/submission-submit.component.scss | 0 .../submit/submission-submit.component.ts | 67 ++ .../utils/parseSectionErrorPaths.ts | 41 + .../submission/utils/parseSectionErrors.ts | 27 + src/config/global-config.interface.ts | 2 + src/config/submission-config.interface.ts | 16 + src/modules/app/browser-app.module.ts | 6 + src/modules/app/server-app.module.ts | 8 +- src/routes.ts | 5 + src/styles/_custom_variables.scss | 2 + yarn.lock | 14 +- 179 files changed, 9143 insertions(+), 77 deletions(-) create mode 100644 src/app/+submit-page/submit-page-routing.module.ts create mode 100644 src/app/+submit-page/submit-page.module.ts create mode 100644 src/app/+workflowitems-edit-page/workflowitems-edit-page-routing.module.ts create mode 100644 src/app/+workflowitems-edit-page/workflowitems-edit-page.module.ts create mode 100644 src/app/+workspaceitems-edit-page/workspaceitems-edit-page-routing.module.ts create mode 100644 src/app/+workspaceitems-edit-page/workspaceitems-edit-page.module.ts create mode 100644 src/app/core/cache/models/normalized-license.model.ts create mode 100644 src/app/core/cache/models/normalized-resource-policy.model.ts create mode 100644 src/app/core/config/submission-uploads-config.service.ts create mode 100644 src/app/core/eperson/eperson-data.ts create mode 100644 src/app/core/eperson/eperson-object-factory.ts create mode 100644 src/app/core/eperson/eperson-response-parsing.service.ts create mode 100644 src/app/core/eperson/eperson-type.ts create mode 100644 src/app/core/eperson/eperson.service.ts create mode 100644 src/app/core/eperson/group-eperson.service.ts create mode 100644 src/app/core/json-patch/builder/json-patch-operation-path-combiner.ts create mode 100644 src/app/core/json-patch/builder/json-patch-operations-builder.ts create mode 100644 src/app/core/json-patch/json-patch-operations.actions.ts create mode 100644 src/app/core/json-patch/json-patch-operations.effects.ts create mode 100644 src/app/core/json-patch/json-patch-operations.reducer.ts create mode 100644 src/app/core/json-patch/json-patch-operations.service.ts create mode 100644 src/app/core/json-patch/json-patch.model.ts create mode 100644 src/app/core/json-patch/selectors.ts create mode 100644 src/app/core/shared/config/config-access-condition-option.model.ts create mode 100644 src/app/core/shared/config/config-submission-uploads.model.ts create mode 100644 src/app/core/shared/file.service.ts create mode 100644 src/app/core/shared/license.model.ts create mode 100644 src/app/core/shared/patch-request.model.ts create mode 100644 src/app/core/shared/resource-policy.model.ts create mode 100644 src/app/core/shared/submit-data-response-definition.model.ts create mode 100644 src/app/core/submission/models/edititem.model.ts create mode 100644 src/app/core/submission/models/normalized-edititem.model.ts create mode 100644 src/app/core/submission/models/normalized-submission-object.model.ts create mode 100644 src/app/core/submission/models/normalized-workflowitem.model.ts create mode 100644 src/app/core/submission/models/normalized-workspaceitem.model.ts create mode 100644 src/app/core/submission/models/submission-object.model.ts create mode 100644 src/app/core/submission/models/submission-upload-file-access-condition.model.ts create mode 100644 src/app/core/submission/models/workflowitem.model.ts create mode 100644 src/app/core/submission/models/workspaceitem-section-deduplication.model.ts create mode 100644 src/app/core/submission/models/workspaceitem-section-form.model.ts create mode 100644 src/app/core/submission/models/workspaceitem-section-license.model.ts create mode 100644 src/app/core/submission/models/workspaceitem-section-recycle.model.ts create mode 100644 src/app/core/submission/models/workspaceitem-section-upload-file.model.ts create mode 100644 src/app/core/submission/models/workspaceitem-section-upload.model.ts create mode 100644 src/app/core/submission/models/workspaceitem-sections.model.ts create mode 100644 src/app/core/submission/models/workspaceitem.model.ts create mode 100644 src/app/core/submission/normalized-submission-object-factory.ts create mode 100644 src/app/core/submission/submission-resource-type.ts create mode 100644 src/app/core/submission/submission-response-parsing.service.ts create mode 100644 src/app/core/submission/submission-scope-type.ts create mode 100644 src/app/core/submission/workflowitem-data.service.ts create mode 100644 src/app/core/submission/workspaceitem-data.service.ts create mode 100644 src/app/shared/alerts/alerts.component.html create mode 100644 src/app/shared/alerts/alerts.component.scss create mode 100644 src/app/shared/alerts/alerts.component.ts create mode 100644 src/app/shared/alerts/aletrs-type.ts create mode 100644 src/app/submission/edit/submission-edit.component.html create mode 100644 src/app/submission/edit/submission-edit.component.scss create mode 100644 src/app/submission/edit/submission-edit.component.ts create mode 100644 src/app/submission/form/collection/submission-form-collection.component.html create mode 100644 src/app/submission/form/collection/submission-form-collection.component.scss create mode 100644 src/app/submission/form/collection/submission-form-collection.component.ts create mode 100644 src/app/submission/form/footer/submission-form-footer.component.html create mode 100644 src/app/submission/form/footer/submission-form-footer.component.scss create mode 100644 src/app/submission/form/footer/submission-form-footer.component.ts create mode 100644 src/app/submission/form/section-add/submission-form-section-add.component.html create mode 100644 src/app/submission/form/section-add/submission-form-section-add.component.scss create mode 100644 src/app/submission/form/section-add/submission-form-section-add.component.ts create mode 100644 src/app/submission/form/submission-form.component.html create mode 100644 src/app/submission/form/submission-form.component.scss create mode 100644 src/app/submission/form/submission-form.component.ts create mode 100644 src/app/submission/form/submission-upload-files/submission-upload-files.component.html create mode 100644 src/app/submission/form/submission-upload-files/submission-upload-files.component.ts create mode 100644 src/app/submission/objects/submission-objects.actions.ts create mode 100644 src/app/submission/objects/submission-objects.effects.ts create mode 100644 src/app/submission/objects/submission-objects.reducer.ts create mode 100644 src/app/submission/sections/container/section-container.component.html create mode 100644 src/app/submission/sections/container/section-container.component.scss create mode 100644 src/app/submission/sections/container/section-container.component.ts create mode 100644 src/app/submission/sections/deduplication/deduplication.service.ts create mode 100644 src/app/submission/sections/deduplication/match/deduplication-match.component.html create mode 100644 src/app/submission/sections/deduplication/match/deduplication-match.component.ts create mode 100644 src/app/submission/sections/deduplication/section-deduplication.component.html create mode 100644 src/app/submission/sections/deduplication/section-deduplication.component.ts create mode 100644 src/app/submission/sections/default/section-default.component.html create mode 100644 src/app/submission/sections/default/section-default.component.scss create mode 100644 src/app/submission/sections/default/section-default.component.ts create mode 100644 src/app/submission/sections/form/form-operations.service.ts create mode 100644 src/app/submission/sections/form/section-form.component.html create mode 100644 src/app/submission/sections/form/section-form.component.scss create mode 100644 src/app/submission/sections/form/section-form.component.ts create mode 100644 src/app/submission/sections/license/section-license.component.html create mode 100644 src/app/submission/sections/license/section-license.component.scss create mode 100644 src/app/submission/sections/license/section-license.component.ts create mode 100644 src/app/submission/sections/license/section-license.model.ts create mode 100644 src/app/submission/sections/models/section-data.model.ts create mode 100644 src/app/submission/sections/models/section.model.ts create mode 100644 src/app/submission/sections/recycle/section-recycle.component.html create mode 100644 src/app/submission/sections/recycle/section-recycle.component.scss create mode 100644 src/app/submission/sections/recycle/section-recycle.component.ts create mode 100644 src/app/submission/sections/sections-decorator.ts create mode 100644 src/app/submission/sections/sections-type.ts create mode 100644 src/app/submission/sections/sections.directive.ts create mode 100644 src/app/submission/sections/sections.service.ts create mode 100644 src/app/submission/sections/upload/accessConditions/accessConditions.component.html create mode 100644 src/app/submission/sections/upload/accessConditions/accessConditions.component.ts create mode 100644 src/app/submission/sections/upload/file/edit/file-edit.component.html create mode 100644 src/app/submission/sections/upload/file/edit/file-edit.component.ts create mode 100644 src/app/submission/sections/upload/file/edit/files-edit.model.ts create mode 100644 src/app/submission/sections/upload/file/file.component.html create mode 100644 src/app/submission/sections/upload/file/file.component.ts create mode 100644 src/app/submission/sections/upload/file/view/file-view.component.html create mode 100644 src/app/submission/sections/upload/file/view/file-view.component.ts create mode 100644 src/app/submission/sections/upload/section-upload.component.html create mode 100644 src/app/submission/sections/upload/section-upload.component.scss create mode 100644 src/app/submission/sections/upload/section-upload.component.ts create mode 100644 src/app/submission/sections/upload/section-upload.service.ts create mode 100644 src/app/submission/selectors.ts create mode 100644 src/app/submission/server-submission.service.ts create mode 100644 src/app/submission/submission-rest.service.ts create mode 100644 src/app/submission/submission.effects.ts create mode 100644 src/app/submission/submission.module.ts create mode 100644 src/app/submission/submission.reducers.ts create mode 100644 src/app/submission/submission.service.ts create mode 100644 src/app/submission/submit/submission-submit.component.html create mode 100644 src/app/submission/submit/submission-submit.component.scss create mode 100644 src/app/submission/submit/submission-submit.component.ts create mode 100644 src/app/submission/utils/parseSectionErrorPaths.ts create mode 100644 src/app/submission/utils/parseSectionErrors.ts create mode 100644 src/config/submission-config.interface.ts diff --git a/config/environment.default.js b/config/environment.default.js index a6ef738f41..1d121f5fbe 100644 --- a/config/environment.default.js +++ b/config/environment.default.js @@ -41,6 +41,43 @@ module.exports = { // NOTE: 'fade' | 'fromTop' | 'fromRight' | 'fromBottom' | 'fromLeft' | 'rotate' | 'scale' animate: 'scale' }, + // Submission settings + submission: { + autosave: { + // NOTE: which metadata trigger an autosave + metadata: ['dc.title', 'dc.identifier.doi', 'dc.identifier.pmid', 'dc.identifier.arxiv'], + // NOTE: every how many minutes submission is saved automatically + timer: 5 + }, + metadata: { + // NOTE: allow to set icons used to represent metadata belonging to a relation group + icons: [ + /** + * NOTE: example of configuration + * { + * // NOTE: metadata name + * name: 'dc.author', + * config: { + * // NOTE: used when metadata value has an authority + * withAuthority: { + * // NOTE: fontawesome (v4.x) icon classes and bootstrap color utility classes can be used + * style: 'fa-user' + * }, + * // NOTE: used when metadata value has not an authority + * withoutAuthority: { + * style: 'fa-user text-muted' + * } + * } + * } + */ + // default configuration + { + name: 'default', + config: {} + } + ] + } + }, // Angular Universal settings universal: { preboot: true, diff --git a/package.json b/package.json index 7ded007e83..c44674844e 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "@angular/platform-server": "^5.2.5", "@angular/router": "^5.2.5", "@angularclass/bootloader": "1.0.1", - "@ng-bootstrap/ng-bootstrap": "^1.0.0", + "@ng-bootstrap/ng-bootstrap": "1.1.2", "@ng-dynamic-forms/core": "5.4.7", "@ng-dynamic-forms/ui-ng-bootstrap": "5.4.7", "@ngrx/effects": "^5.1.0", @@ -101,6 +101,7 @@ "core-js": "2.5.3", "express": "4.16.2", "express-session": "1.15.6", + "file-saver": "^1.3.8", "font-awesome": "4.7.0", "http-server": "0.11.1", "https": "1.0.0", @@ -133,6 +134,7 @@ "@types/deep-freeze": "0.1.1", "@types/express": "^4.11.1", "@types/express-serve-static-core": "4.11.1", + "@types/file-saver": "^1.3.0", "@types/hammerjs": "2.0.35", "@types/jasmine": "^2.8.6", "@types/js-cookie": "2.1.0", diff --git a/resources/i18n/en.json b/resources/i18n/en.json index ba70b87e12..c2c8d8c464 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -210,6 +210,11 @@ "license": { "notgranted": "You must grant this license to complete your submission. If you are unable to grant this license at this time you may save your work and return later or remove the submission." } + }, + "submission": { + "sections": { + "init-form-error": "An error occurred during section initialize, please check your input-form configuration. Details are below :

" + } } }, "form": { @@ -252,5 +257,95 @@ "errors": { "invalid-user": "Invalid email or password." } + }, + "submission": { + "general":{ + "cannot_submit": "You have not the privilege to make a new submission.", + "deposit": "Deposit", + "discard": { + "submit": "Discard", + "confirm": { + "cancel": "Cancel", + "submit": "Yes, I'm sure", + "title": "Discard submission", + "info": "This operation can't be undone. Are you sure?" + } + }, + "save": "Save", + "save-later": "Save for later" + }, + "submit": { + "title": "Submission" + }, + "edit": { + "title": "Edit Submission" + }, + "mydspace": { + + }, + "sections": { + + "general": { + "add-more": "Add more", + "sections_not_valid": "There are incomplete sections.", + "deposit_success_notice": "Submission deposited successfully.", + "deposit_error_notice": "There was an issue when submitting the item, please try again later.", + "discard_success_notice": "Submission discarded successfully.", + "discard_error_notice": "There was an issue when discarding the item, please try again later.", + "save_success_notice": "Submission saved successfully.", + "metadata-extracted": "New metadata have been extracted and added to the {{sectionId}} section.", + "metadata-extracted-new-section": "New {{sectionId}} section has been added to submission." + }, + "submit.progressbar.describe.stepone": "Describe", + "submit.progressbar.describe.steptwo": "Describe", + "submit.progressbar.describe.stepcustom": "Describe", + "submit.progressbar.describe.deduplication": "Potential duplicates", + "submit.progressbar.describe.recycle": "Recycle", + "submit.progressbar.upload": "Upload files", + "submit.progressbar.license": "Deposit license", + "submit.progressbar.cclicense": "Creative commons license", + + "upload": { + "info": "Here you will find all the files currently in the item. You can update the fle metadata and access conditions or upload additional files just dragging&dropping them everywhere in the page", + "drop-message": "Drop files to attach them to the item", + "upload-successful": "Upload successful", + "upload-failed": "Upload failed", + "header.policy.default.nolist": "Uploaded files in the {{collectionName}} collection will be accessible according to the following group(s):", + "header.policy.default.withlist": "Please note that uploaded files in the {{collectionName}} collection will be accessible, in addition to what is explicity decided for the single file, with the following group(s):", + "form": { + "access-condition-label": "Access condition type", + "from-label": "Access grant from", + "from-placeholder": "From", + "until-label": "Access grant until", + "until-placeholder": "Until", + "group-label": "Group" + } + }, + "deduplication": { + "duplicated": "It's a duplicate", + "not_duplicated": "It's not a duplicate", + "duplicated_ctrl": "Mark the record to merge", + "duplicated_help": "Click here if this is a duplicate of your item", + "not_duplicated_help": "Click here if this is not a duplicate of your item", + "note_help": "Please enter your reason for the duplication into the box below.", + "note_placeholder": "Describe the reason of duplication", + "clear_decision": "Undo", + "clear_decision_help": "Click for clear the decision about this pontential duplicate", + "your_decision": "Your choice:", + "submitter_decision": "Submitter choice:", + "disclaimer": "The system has identified some potential duplicates. Please carefully review the list and flag each occurency with the appropriate choice or discard this submission.", + "disclaimer_ctrl": "The system has identified some potential duplicates. Please carefully review the list and the submitter comments and perform the appropriate action." + }, + "recycle": { + "disclaimer": "The following existent information are not valid within the selected collection. Please copy them to the appropriate metadata if applicable. Use the discard button to remove these information when done." + } + } + }, + "uploader": { + "drag-message": "Drag & Drop your files here", + "or": ", or", + "browse": "browse", + "queue-lenght": "Queue length", + "processing": "Processing" } } diff --git a/src/app/+item-page/item-page.module.ts b/src/app/+item-page/item-page.module.ts index bd801923e3..d574681b21 100644 --- a/src/app/+item-page/item-page.module.ts +++ b/src/app/+item-page/item-page.module.ts @@ -1,7 +1,7 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { SharedModule } from './../shared/shared.module'; +import { SharedModule } from '../shared/shared.module'; import { ItemPageComponent } from './simple/item-page.component'; import { ItemPageRoutingModule } from './item-page-routing.module'; @@ -18,11 +18,13 @@ import { FileSectionComponent } from './simple/field-components/file-section/fil import { CollectionsComponent } from './field-components/collections/collections.component'; import { FullItemPageComponent } from './full/full-item-page.component'; import { FullFileSectionComponent } from './full/field-components/file-section/full-file-section.component'; +import { SubmissionModule } from '../submission/submission.module'; @NgModule({ imports: [ CommonModule, SharedModule, + SubmissionModule, ItemPageRoutingModule ], declarations: [ diff --git a/src/app/+login-page/login-page-routing.module.ts b/src/app/+login-page/login-page-routing.module.ts index 4e932c50ce..d3c6425dd3 100644 --- a/src/app/+login-page/login-page-routing.module.ts +++ b/src/app/+login-page/login-page-routing.module.ts @@ -6,7 +6,7 @@ import { LoginPageComponent } from './login-page.component'; @NgModule({ imports: [ RouterModule.forChild([ - { path: '', component: LoginPageComponent, data: { title: 'login.title' } } + { path: '', pathMatch: 'full', component: LoginPageComponent, data: { title: 'login.title' } } ]) ] }) diff --git a/src/app/+login-page/login-page.component.ts b/src/app/+login-page/login-page.component.ts index 2752973130..0c6f0a62bc 100644 --- a/src/app/+login-page/login-page.component.ts +++ b/src/app/+login-page/login-page.component.ts @@ -1,20 +1,61 @@ -import { Component, OnDestroy } from '@angular/core'; +import { Component, OnDestroy, OnInit } from '@angular/core'; import { Store } from '@ngrx/store'; import { AppState } from '../app.reducer'; -import { ResetAuthenticationMessagesAction } from '../core/auth/auth.actions'; +import { + AddAuthenticationMessageAction, + AuthenticatedAction, + AuthenticationSuccessAction, + ResetAuthenticationMessagesAction +} from '../core/auth/auth.actions'; +import { Subscription } from 'rxjs/Subscription'; +import { hasValue, isNotEmpty } from '../shared/empty.util'; +import { ActivatedRoute } from '@angular/router'; +import { AuthTokenInfo } from '../core/auth/models/auth-token-info.model'; +import { Observable } from 'rxjs/Observable'; +import { isAuthenticated } from '../core/auth/selectors'; @Component({ selector: 'ds-login-page', styleUrls: ['./login-page.component.scss'], templateUrl: './login-page.component.html' }) -export class LoginPageComponent implements OnDestroy { +export class LoginPageComponent implements OnDestroy, OnInit { + sub: Subscription; - constructor(private store: Store) {} + constructor(private route: ActivatedRoute, + private store: Store) {} + + ngOnInit() { + const queryParamsObs = this.route.queryParams; + const authenticated = this.store.select(isAuthenticated); + this.sub = Observable.combineLatest(queryParamsObs, authenticated) + .filter(([params, auth]) => isNotEmpty(params.token) || isNotEmpty(params.expired)) + .take(1) + .subscribe(([params, auth]) => { + const token = params.token; + let authToken: AuthTokenInfo; + if (!auth) { + if (isNotEmpty(token)) { + authToken = new AuthTokenInfo(token); + this.store.dispatch(new AuthenticatedAction(authToken)); + } else if (isNotEmpty(params.expired)) { + this.store.dispatch(new AddAuthenticationMessageAction('auth.messages.expired')); + } + } else { + if (isNotEmpty(token)) { + authToken = new AuthTokenInfo(token); + this.store.dispatch(new AuthenticationSuccessAction(authToken)); + } + } + }) + } ngOnDestroy() { + if (hasValue(this.sub)) { + this.sub.unsubscribe(); + } // Clear all authentication messages when leaving login page this.store.dispatch(new ResetAuthenticationMessagesAction()); } diff --git a/src/app/+submit-page/submit-page-routing.module.ts b/src/app/+submit-page/submit-page-routing.module.ts new file mode 100644 index 0000000000..c583db2cb6 --- /dev/null +++ b/src/app/+submit-page/submit-page-routing.module.ts @@ -0,0 +1,20 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; + +import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; +import { SubmissionSubmitComponent } from '../submission/submit/submission-submit.component'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { + canActivate: [AuthenticatedGuard], + path: '', + pathMatch: 'full', + component: SubmissionSubmitComponent, + data: { title: 'submission.submit.title' } + } + ]) + ] +}) +export class SubmitPageRoutingModule { } diff --git a/src/app/+submit-page/submit-page.module.ts b/src/app/+submit-page/submit-page.module.ts new file mode 100644 index 0000000000..6c791d3d77 --- /dev/null +++ b/src/app/+submit-page/submit-page.module.ts @@ -0,0 +1,17 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { SharedModule } from '../shared/shared.module'; +import { SubmitPageRoutingModule } from './submit-page-routing.module'; +import { SubmissionModule } from '../submission/submission.module'; + +@NgModule({ + imports: [ + SubmitPageRoutingModule, + CommonModule, + SharedModule, + SubmissionModule, + ], +}) +export class SubmitPageModule { + +} diff --git a/src/app/+workflowitems-edit-page/workflowitems-edit-page-routing.module.ts b/src/app/+workflowitems-edit-page/workflowitems-edit-page-routing.module.ts new file mode 100644 index 0000000000..750f98435f --- /dev/null +++ b/src/app/+workflowitems-edit-page/workflowitems-edit-page-routing.module.ts @@ -0,0 +1,20 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; + +import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; +import { SubmissionEditComponent } from '../submission/edit/submission-edit.component'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { path: '', redirectTo: '/home', pathMatch: 'full' }, + { + canActivate: [AuthenticatedGuard], + path: ':id/edit', + component: SubmissionEditComponent, + data: { title: 'submission.edit.title' } + } + ]) + ] +}) +export class WorkflowitemsEditPageRoutingModule { } diff --git a/src/app/+workflowitems-edit-page/workflowitems-edit-page.module.ts b/src/app/+workflowitems-edit-page/workflowitems-edit-page.module.ts new file mode 100644 index 0000000000..936816f2c3 --- /dev/null +++ b/src/app/+workflowitems-edit-page/workflowitems-edit-page.module.ts @@ -0,0 +1,18 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { SharedModule } from '../shared/shared.module'; +import { WorkflowitemsEditPageRoutingModule } from './workflowitems-edit-page-routing.module'; +import { SubmissionModule } from '../submission/submission.module'; + +@NgModule({ + imports: [ + WorkflowitemsEditPageRoutingModule, + CommonModule, + SharedModule, + SubmissionModule, + ], + declarations: [] +}) +export class WorkflowitemsEditPageModule { + +} diff --git a/src/app/+workspaceitems-edit-page/workspaceitems-edit-page-routing.module.ts b/src/app/+workspaceitems-edit-page/workspaceitems-edit-page-routing.module.ts new file mode 100644 index 0000000000..07d59dfdd3 --- /dev/null +++ b/src/app/+workspaceitems-edit-page/workspaceitems-edit-page-routing.module.ts @@ -0,0 +1,20 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; + +import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; +import { SubmissionEditComponent } from '../submission/edit/submission-edit.component'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { path: '', redirectTo: '/home', pathMatch: 'full' }, + { + canActivate: [AuthenticatedGuard], + path: ':id/edit', + component: SubmissionEditComponent, + data: { title: 'submission.edit.title' } + } + ]) + ] +}) +export class WorkspaceitemsEditPageRoutingModule { } diff --git a/src/app/+workspaceitems-edit-page/workspaceitems-edit-page.module.ts b/src/app/+workspaceitems-edit-page/workspaceitems-edit-page.module.ts new file mode 100644 index 0000000000..611304f651 --- /dev/null +++ b/src/app/+workspaceitems-edit-page/workspaceitems-edit-page.module.ts @@ -0,0 +1,18 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { SharedModule } from '../shared/shared.module'; +import { WorkspaceitemsEditPageRoutingModule } from './workspaceitems-edit-page-routing.module'; +import { SubmissionModule } from '../submission/submission.module'; + +@NgModule({ + imports: [ + WorkspaceitemsEditPageRoutingModule, + CommonModule, + SharedModule, + SubmissionModule, + ], + declarations: [] +}) +export class WorkspaceitemsEditPageModule { + +} diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 4bc8c43152..efb3499c01 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -15,6 +15,9 @@ import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component'; { path: 'admin', loadChildren: './+admin/admin.module#AdminModule' }, { path: 'login', loadChildren: './+login-page/login-page.module#LoginPageModule' }, { path: 'logout', loadChildren: './+logout-page/logout-page.module#LogoutPageModule' }, + { path: 'submit', loadChildren: './+submit-page/submit-page.module#SubmitPageModule' }, + { path: 'workspaceitems', loadChildren: './+workspaceitems-edit-page/workspaceitems-edit-page.module#WorkspaceitemsEditPageModule' }, + { path: 'workflowitems', loadChildren: './+workflowitems-edit-page/workflowitems-edit-page.module#WorkflowitemsEditPageModule' }, { path: '**', pathMatch: 'full', component: PageNotFoundComponent }, ]) ], diff --git a/src/app/app.component.html b/src/app/app.component.html index d806bb8323..f040e740f6 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -13,5 +13,3 @@ - - diff --git a/src/app/app.component.scss b/src/app/app.component.scss index 00a3e56121..45c6e61045 100644 --- a/src/app/app.component.scss +++ b/src/app/app.component.scss @@ -27,7 +27,11 @@ body { } .main-content { - flex: 1 0 auto; + flex: 1 1 100%; margin-top: $content-spacing; margin-bottom: $content-spacing; } +.alert.hide { + padding: 0; + margin: 0; +} diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 528c84fd3b..f8bd852ee8 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -31,6 +31,7 @@ import { DSpaceRouterStateSerializer } from './shared/ngrx/dspace-router-state-s import { NotificationsBoardComponent } from './shared/notifications/notifications-board/notifications-board.component'; import { NotificationComponent } from './shared/notifications/notification/notification.component'; import { SharedModule } from './shared/shared.module'; +import { ScrollToModule } from '@nicky-lenaers/ngx-scroll-to'; export function getConfig() { return ENV_CONFIG; @@ -58,6 +59,7 @@ if (!ENV_CONFIG.production) { HttpClientModule, AppRoutingModule, CoreModule.forRoot(), + ScrollToModule.forRoot(), NgbModule.forRoot(), TranslateModule.forRoot(), EffectsModule.forRoot(appEffects), diff --git a/src/app/core/cache/models/normalized-collection.model.ts b/src/app/core/cache/models/normalized-collection.model.ts index 22e0d20eaa..f7ed096287 100644 --- a/src/app/core/cache/models/normalized-collection.model.ts +++ b/src/app/core/cache/models/normalized-collection.model.ts @@ -1,4 +1,4 @@ -import { autoserialize, inheritSerialization, autoserializeAs } from 'cerialize'; +import { autoserialize, inheritSerialization } from 'cerialize'; import { NormalizedDSpaceObject } from './normalized-dspace-object.model'; import { Collection } from '../../shared/collection.model'; @@ -15,6 +15,20 @@ export class NormalizedCollection extends NormalizedDSpaceObject { @autoserialize handle: string; + /** + * The Bitstream that represents the license of this Collection + */ + @autoserialize + @relationship(ResourceType.License, false) + license: string; + + /** + * The Bitstream that represents the default Access Conditions of this Collection + */ + @autoserialize + @relationship(ResourceType.ResourcePolicy, false) + defaultAccessConditions: string; + /** * The Bitstream that represents the logo of this Collection */ diff --git a/src/app/core/cache/models/normalized-license.model.ts b/src/app/core/cache/models/normalized-license.model.ts new file mode 100644 index 0000000000..84f1671766 --- /dev/null +++ b/src/app/core/cache/models/normalized-license.model.ts @@ -0,0 +1,21 @@ +import { autoserialize, inheritSerialization } from 'cerialize'; +import { mapsTo } from '../builders/build-decorators'; +import { NormalizedDSpaceObject } from './normalized-dspace-object.model'; +import { License } from '../../shared/license.model'; + +@mapsTo(License) +@inheritSerialization(NormalizedDSpaceObject) +export class NormalizedLicense extends NormalizedDSpaceObject { + + /** + * Is the license custom? + */ + @autoserialize + custom: boolean; + + /** + * The text of the license + */ + @autoserialize + text: string; +} diff --git a/src/app/core/cache/models/normalized-object-factory.ts b/src/app/core/cache/models/normalized-object-factory.ts index 5b13d55ac8..095309f515 100644 --- a/src/app/core/cache/models/normalized-object-factory.ts +++ b/src/app/core/cache/models/normalized-object-factory.ts @@ -6,6 +6,12 @@ import { GenericConstructor } from '../../shared/generic-constructor'; import { NormalizedCommunity } from './normalized-community.model'; import { ResourceType } from '../../shared/resource-type'; import { NormalizedObject } from './normalized-object.model'; +import { NormalizedLicense } from './normalized-license.model'; +import { NormalizedResourcePolicy } from './normalized-resource-policy.model'; +import { NormalizedWorkspaceItem } from '../../submission/models/normalized-workspaceitem.model'; +import { NormalizedEpersonModel } from '../../eperson/models/NormalizedEperson.model'; +import { NormalizedGroupModel } from '../../eperson/models/NormalizedGroup.model'; +import { NormalizedWorkflowItem } from '../../submission/models/normalized-workflowitem.model'; export class NormalizedObjectFactory { public static getConstructor(type: ResourceType): GenericConstructor { @@ -25,6 +31,21 @@ export class NormalizedObjectFactory { case ResourceType.Community: { return NormalizedCommunity } + case ResourceType.License: { + return NormalizedLicense + } + case ResourceType.ResourcePolicy: { + return NormalizedResourcePolicy + } + case ResourceType.Workspaceitem: { + return NormalizedWorkspaceItem + } + case ResourceType.Eperson: { + return NormalizedEpersonModel + } + case ResourceType.Group: { + return NormalizedGroupModel + } default: { return undefined; } diff --git a/src/app/core/cache/models/normalized-resource-policy.model.ts b/src/app/core/cache/models/normalized-resource-policy.model.ts new file mode 100644 index 0000000000..14d4b00fcc --- /dev/null +++ b/src/app/core/cache/models/normalized-resource-policy.model.ts @@ -0,0 +1,45 @@ +import { mapsTo } from '../builders/build-decorators'; +import { autoserialize, inheritSerialization } from 'cerialize'; +import { NormalizedDSpaceObject } from './normalized-dspace-object.model'; +import { ResourcePolicy } from '../../shared/resource-policy.model'; + +@mapsTo(ResourcePolicy) +@inheritSerialization(NormalizedDSpaceObject) +export class NormalizedResourcePolicy extends NormalizedDSpaceObject { + + /** + * The action of the resource policy + */ + @autoserialize + action: string; + + /** + * The identifier of the resource policy + */ + @autoserialize + id: string; + + /** + * The group uuid bound to the resource policy + */ + @autoserialize + groupUUID: string; + + /** + * The end date of the resource policy + */ + @autoserialize + endDate: string; + + /** + * The start date of the resource policy + */ + @autoserialize + startDate: string; + + /** + * The type of the resource policy + */ + @autoserialize + rpType: string +} diff --git a/src/app/core/cache/response-cache.models.ts b/src/app/core/cache/response-cache.models.ts index 9b1b5b89eb..31ac797e5a 100644 --- a/src/app/core/cache/response-cache.models.ts +++ b/src/app/core/cache/response-cache.models.ts @@ -10,6 +10,7 @@ import { MetadataSchema } from '../metadata/metadataschema.model'; import { RegistryMetadatafieldsResponse } from '../registry/registry-metadatafields-response.model'; import { RegistryBitstreamformatsResponse } from '../registry/registry-bitstreamformats-response.model'; import { AuthStatus } from '../auth/models/auth-status.model'; +import { NormalizedObject } from './models/normalized-object.model'; /* tslint:disable:max-classes-per-file */ export class RestResponse { @@ -176,4 +177,34 @@ export class IntegrationSuccessResponse extends RestResponse { } } +export class PostPatchSuccessResponse extends RestResponse { + constructor( + public dataDefinition: any[], + public statusCode: string, + public pageInfo?: PageInfo + ) { + super(true, statusCode); + } +} + +export class SubmissionSuccessResponse extends RestResponse { + constructor( + public dataDefinition: Array, + public statusCode: string, + public pageInfo?: PageInfo + ) { + super(true, statusCode); + } +} + +export class EpersonSuccessResponse extends RestResponse { + constructor( + public epersonDefinition: NormalizedObject[], + public statusCode: string, + public pageInfo?: PageInfo + ) { + super(true, statusCode); + } +} + /* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/config/submission-uploads-config.service.ts b/src/app/core/config/submission-uploads-config.service.ts new file mode 100644 index 0000000000..88b9de9182 --- /dev/null +++ b/src/app/core/config/submission-uploads-config.service.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@angular/core'; +import { ConfigService } from './config.service'; +import { ResponseCacheService } from '../cache/response-cache.service'; +import { RequestService } from '../data/request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; + +@Injectable() +export class SubmissionUploadsConfigService extends ConfigService { + protected linkPath = 'submissionuploads'; + protected browseEndpoint = ''; + + constructor( + protected responseCache: ResponseCacheService, + protected requestService: RequestService, + protected halService: HALEndpointService) { + super(); + } +} diff --git a/src/app/core/core.effects.ts b/src/app/core/core.effects.ts index bc534a36b0..bb6a96b7ae 100644 --- a/src/app/core/core.effects.ts +++ b/src/app/core/core.effects.ts @@ -4,11 +4,13 @@ import { ResponseCacheEffects } from './cache/response-cache.effects'; import { UUIDIndexEffects } from './index/index.effects'; import { RequestEffects } from './data/request.effects'; import { AuthEffects } from './auth/auth.effects'; +import { JsonPatchOperationsEffects } from './json-patch/json-patch-operations.effects'; export const coreEffects = [ ResponseCacheEffects, RequestEffects, ObjectCacheEffects, UUIDIndexEffects, - AuthEffects + AuthEffects, + JsonPatchOperationsEffects, ]; diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 8536169688..0e916511b9 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -1,9 +1,4 @@ -import { - NgModule, - Optional, - SkipSelf, - ModuleWithProviders -} from '@angular/core'; +import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core'; import { CommonModule } from '@angular/common'; import { StoreModule } from '@ngrx/store'; @@ -24,7 +19,9 @@ import { DSOResponseParsingService } from './data/dso-response-parsing.service'; import { SearchResponseParsingService } from './data/search-response-parsing.service'; import { DSpaceRESTv2Service } from './dspace-rest-v2/dspace-rest-v2.service'; import { FormBuilderService } from '../shared/form/builder/form-builder.service'; +import { FormOperationsService } from '../submission/sections/form/form-operations.service'; import { FormService } from '../shared/form/form.service'; +import { GroupEpersonService } from './eperson/group-eperson.service'; import { HostWindowService } from '../shared/host-window.service'; import { ItemDataService } from './data/item-data.service'; import { MetadataService } from './metadata/metadata.service'; @@ -43,8 +40,12 @@ import { RouteService } from '../shared/services/route.service'; import { SubmissionDefinitionsConfigService } from './config/submission-definitions-config.service'; import { SubmissionFormsConfigService } from './config/submission-forms-config.service'; import { SubmissionSectionsConfigService } from './config/submission-sections-config.service'; +import { SubmissionResponseParsingService } from './submission/submission-response-parsing.service'; +import { EpersonResponseParsingService } from './eperson/eperson-response-parsing.service'; +import { JsonPatchOperationsBuilder } from './json-patch/builder/json-patch-operations-builder'; import { AuthorityService } from './integration/authority.service'; import { IntegrationResponseParsingService } from './integration/integration-response-parsing.service'; +import { WorkspaceitemDataService } from './submission/workspaceitem-data.service'; import { UUIDService } from './shared/uuid.service'; import { AuthenticatedGuard } from './auth/authenticated.guard'; import { AuthRequestService } from './auth/auth-request.service'; @@ -60,8 +61,12 @@ import { RegistryMetadataschemasResponseParsingService } from './data/registry-m import { MetadataschemaParsingService } from './data/metadataschema-parsing.service'; import { RegistryMetadatafieldsResponseParsingService } from './data/registry-metadatafields-response-parsing.service'; import { RegistryBitstreamformatsResponseParsingService } from './data/registry-bitstreamformats-response-parsing.service'; +import { JsonPatchOperationsService } from './json-patch/json-patch-operations.service'; +import { WorkflowitemDataService } from './submission/workflowitem-data.service'; import { NotificationsService } from '../shared/notifications/notifications.service'; import { UploaderService } from '../shared/uploader/uploader.service'; +import { FileService } from './shared/file.service'; +import { SubmissionRestService } from '../submission/submission-rest.service'; const IMPORTS = [ CommonModule, @@ -90,7 +95,10 @@ const PROVIDERS = [ DynamicFormService, DynamicFormValidationService, FormBuilderService, + FormOperationsService, FormService, + EpersonResponseParsingService, + GroupEpersonService, HALEndpointService, HostWindowService, ItemDataService, @@ -119,11 +127,20 @@ const PROVIDERS = [ RouteService, SubmissionDefinitionsConfigService, SubmissionFormsConfigService, + SubmissionRestService, SubmissionSectionsConfigService, + SubmissionResponseParsingService, + JsonPatchOperationsBuilder, + JsonPatchOperationsService, AuthorityService, IntegrationResponseParsingService, UploaderService, UUIDService, + NotificationsService, + WorkspaceitemDataService, + WorkflowitemDataService, + UploaderService, + FileService, // register AuthInterceptor as HttpInterceptor { provide: HTTP_INTERCEPTORS, diff --git a/src/app/core/core.reducers.ts b/src/app/core/core.reducers.ts index c764a2acff..f4fe29c619 100644 --- a/src/app/core/core.reducers.ts +++ b/src/app/core/core.reducers.ts @@ -5,6 +5,7 @@ import { objectCacheReducer, ObjectCacheState } from './cache/object-cache.reduc import { indexReducer, IndexState } from './index/index.reducer'; import { requestReducer, RequestState } from './data/request.reducer'; import { authReducer, AuthState } from './auth/auth.reducer'; +import { jsonPatchOperationsReducer, JsonPatchOperationsState } from './json-patch/json-patch-operations.reducer'; export interface CoreState { 'data/object': ObjectCacheState, @@ -12,6 +13,7 @@ export interface CoreState { 'data/request': RequestState, 'index': IndexState, 'auth': AuthState, + 'json/patch': JsonPatchOperationsState } export const coreReducers: ActionReducerMap = { @@ -19,7 +21,8 @@ export const coreReducers: ActionReducerMap = { 'data/response': responseCacheReducer, 'data/request': requestReducer, 'index': indexReducer, - 'auth': authReducer + 'auth': authReducer, + 'json/patch': jsonPatchOperationsReducer }; export const coreSelector = createFeatureSelector('core'); diff --git a/src/app/core/data/base-response-parsing.service.ts b/src/app/core/data/base-response-parsing.service.ts index bde0857946..9932c51cbf 100644 --- a/src/app/core/data/base-response-parsing.service.ts +++ b/src/app/core/data/base-response-parsing.service.ts @@ -17,7 +17,7 @@ function isPaginatedResponse(halObj: any) { /* tslint:disable:max-classes-per-file */ -class ProcessRequestDTO { +export class ProcessRequestDTO { [key: string]: ObjectDomain[] } diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts index 7d1e463dbe..c9f60b9709 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -15,6 +15,7 @@ import { HALEndpointService } from '../shared/hal-endpoint.service'; @Injectable() export class CollectionDataService extends ComColDataService { protected linkPath = 'collections'; + protected forceBypassCache = false; constructor( protected responseCache: ResponseCacheService, diff --git a/src/app/core/data/comcol-data.service.spec.ts b/src/app/core/data/comcol-data.service.spec.ts index b5727fb22f..a635066e72 100644 --- a/src/app/core/data/comcol-data.service.spec.ts +++ b/src/app/core/data/comcol-data.service.spec.ts @@ -22,6 +22,7 @@ class NormalizedTestObject extends NormalizedObject { } class TestService extends ComColDataService { + protected forceBypassCache = false; constructor( protected responseCache: ResponseCacheService, diff --git a/src/app/core/data/community-data.service.ts b/src/app/core/data/community-data.service.ts index 88ad3a5287..daa83ed150 100644 --- a/src/app/core/data/community-data.service.ts +++ b/src/app/core/data/community-data.service.ts @@ -16,6 +16,7 @@ import { HALEndpointService } from '../shared/hal-endpoint.service'; export class CommunityDataService extends ComColDataService { protected linkPath = 'communities'; protected cds = this; + protected forceBypassCache = false; constructor( protected responseCache: ResponseCacheService, diff --git a/src/app/core/data/config-response-parsing.service.ts b/src/app/core/data/config-response-parsing.service.ts index dfbbfc50c7..4713a9be8e 100644 --- a/src/app/core/data/config-response-parsing.service.ts +++ b/src/app/core/data/config-response-parsing.service.ts @@ -34,7 +34,7 @@ export class ConfigResponseParsingService extends BaseResponseParsingService imp return new ErrorResponse( Object.assign( new Error('Unexpected response from config endpoint'), - {statusText: data.statusCode} + { statusText: data.statusCode } ) ); } diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index f532ff05ba..ba447c32de 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -10,6 +10,7 @@ import { PaginatedList } from './paginated-list'; import { RemoteData } from './remote-data'; import { FindAllOptions, FindAllRequest, FindByIDRequest, GetRequest } from './request.models'; import { RequestService } from './request.service'; +import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { NormalizedObject } from '../cache/models/normalized-object.model'; export abstract class DataService { @@ -19,6 +20,7 @@ export abstract class DataService protected abstract store: Store; protected abstract linkPath: string; protected abstract halService: HALEndpointService; + protected abstract forceBypassCache = false; public abstract getScopedEndpoint(scope: string): Observable @@ -52,6 +54,36 @@ export abstract class DataService } } + protected getSearchByHref(endpoint, searchByLink, options: FindAllOptions = {}): Observable { + let result: Observable; + const args = []; + + if (hasValue(options.scopeID)) { + result = Observable.of(`${endpoint}/${searchByLink}?uuid=${options.scopeID}`); + } else { + result = Observable.of(endpoint); + } + + if (hasValue(options.currentPage) && typeof options.currentPage === 'number') { + /* TODO: this is a temporary fix for the pagination start index (0 or 1) discrepancy between the rest and the frontend respectively */ + args.push(`page=${options.currentPage - 1}`); + } + + if (hasValue(options.elementsPerPage)) { + args.push(`size=${options.elementsPerPage}`); + } + + if (hasValue(options.sort)) { + args.push(`sort=${options.sort.field},${options.sort.direction}`); + } + + if (isNotEmpty(args)) { + return result.map((href: string) => new URLCombiner(href, `?${args.join('&')}`).toString()); + } else { + return result; + } + } + findAll(options: FindAllOptions = {}): Observable>> { const hrefObs = this.halService.getEndpoint(this.linkPath).filter((href: string) => isNotEmpty(href)) .flatMap((endpoint: string) => this.getFindAllHref(endpoint, options)); @@ -61,7 +93,7 @@ export abstract class DataService .take(1) .subscribe((href: string) => { const request = new FindAllRequest(this.requestService.generateRequestId(), href, options); - this.requestService.configure(request); + this.requestService.configure(request, this.forceBypassCache); }); return this.rdbService.buildList(hrefObs) as Observable>>; @@ -80,32 +112,69 @@ export abstract class DataService .take(1) .subscribe((href: string) => { const request = new FindByIDRequest(this.requestService.generateRequestId(), href, id); - this.requestService.configure(request); + this.requestService.configure(request, this.forceBypassCache); }); return this.rdbService.buildSingle(hrefObs); } - findByHref(href: string): Observable> { - this.requestService.configure(new GetRequest(this.requestService.generateRequestId(), href)); + findByHref(href: string, options?: HttpOptions): Observable> { + this.requestService.configure(new GetRequest(this.requestService.generateRequestId(), href, null, options), this.forceBypassCache); return this.rdbService.buildSingle(href); } - // TODO implement, after the structure of the REST server's POST response is finalized - // create(dso: DSpaceObject): Observable> { - // const postHrefObs = this.getEndpoint(); - // - // // TODO ID is unknown at this point - // const idHrefObs = postHrefObs.map((href: string) => this.getFindByIDHref(href, dso.id)); - // - // postHrefObs - // .filter((href: string) => hasValue(href)) - // .take(1) - // .subscribe((href: string) => { - // const request = new RestRequest(this.requestService.generateRequestId(), href, RestRequestMethod.Post, dso); - // this.requestService.configure(request); - // }); - // - // return this.rdbService.buildSingle(idHrefObs, this.normalizedResourceType); - // } + // TODO remove when search will be completed + public searchBySubmitter(options: FindAllOptions = {}): Observable>> { + return this.searchBy('submitter', options); + } + + // TODO remove when search will be completed + searchByUser(options: FindAllOptions = {}): Observable>> { + return this.searchBy('user', options); + } + + // TODO remove when search will be completed + protected searchBy(searchBy: string, options: FindAllOptions = {}): Observable>> { + let url = null; + switch (searchBy) { + case 'user': { + url = 'search/findByUser'; + break; + } + case 'submitter': { + url = 'search/findBySubmitter'; + break; + } + } + + const hrefObs = this.halService.getEndpoint(this.linkPath).filter((href: string) => isNotEmpty(href)) + .flatMap((endpoint: string) => this.getSearchByHref(endpoint, url, options)); + hrefObs + .filter((href: string) => hasValue(href)) + .take(1) + .subscribe((href: string) => { + const request = new FindAllRequest(this.requestService.generateRequestId(), href, options); + this.requestService.configure(request, this.forceBypassCache); + }); + + return this.rdbService.buildList(hrefObs) as Observable>>; + } + +// TODO implement, after the structure of the REST server's POST response is finalized +// create(dso: DSpaceObject): Observable> { +// const postHrefObs = this.getEndpoint(); +// +// // TODO ID is unknown at this point +// const idHrefObs = postHrefObs.map((href: string) => this.getFindByIDHref(href, dso.id)); +// +// postHrefObs +// .filter((href: string) => hasValue(href)) +// .take(1) +// .subscribe((href: string) => { +// const request = new RestRequest(this.requestService.generateRequestId(), href, RestRequestMethod.Post, dso); +// this.requestService.configure(request); +// }); +// +// return this.rdbService.buildSingle(idHrefObs, this.normalizedResourceType); +// } } diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index 6b0937d8e4..d0e8f360ae 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -19,6 +19,7 @@ import { HALEndpointService } from '../shared/hal-endpoint.service'; @Injectable() export class ItemDataService extends DataService { protected linkPath = 'items'; + protected forceBypassCache = false; constructor( protected responseCache: ResponseCacheService, diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts index 7015b0b0f1..56d2109646 100644 --- a/src/app/core/data/request.models.ts +++ b/src/app/core/data/request.models.ts @@ -11,6 +11,8 @@ import { ConfigResponseParsingService } from './config-response-parsing.service' import { AuthResponseParsingService } from '../auth/auth-response-parsing.service'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { HttpHeaders } from '@angular/common/http'; +import { SubmissionResponseParsingService } from '../submission/submission-response-parsing.service'; +import { EpersonResponseParsingService } from '../eperson/eperson-response-parsing.service'; import { IntegrationResponseParsingService } from '../integration/integration-response-parsing.service'; /* tslint:disable:max-classes-per-file */ @@ -184,8 +186,8 @@ export class BrowseEntriesRequest extends GetRequest { } export class ConfigRequest extends GetRequest { - constructor(uuid: string, href: string) { - super(uuid, href); + constructor(uuid: string, href: string, public options?: HttpOptions) { + super(uuid, href, null, options); } getResponseParser(): GenericConstructor { @@ -222,6 +224,85 @@ export class IntegrationRequest extends GetRequest { return IntegrationResponseParsingService; } } + +export class SubmissionFindAllRequest extends GetRequest { + constructor(uuid: string, href: string, public body?: FindAllOptions) { + super(uuid, href); + } + + getResponseParser(): GenericConstructor { + return SubmissionResponseParsingService; + } +} + +export class SubmissionFindByIDRequest extends GetRequest { + constructor(uuid: string, + href: string, + public resourceID: string) { + super(uuid, href); + } + + getResponseParser(): GenericConstructor { + return SubmissionResponseParsingService; + } +} + +export class SubmissionRequest extends GetRequest { + constructor(uuid: string, href: string) { + super(uuid, href); + } + + getResponseParser(): GenericConstructor { + return SubmissionResponseParsingService; + } +} + +export class SubmissionDeleteRequest extends DeleteRequest { + constructor(public uuid: string, + public href: string) { + super(uuid, href); + } + + getResponseParser(): GenericConstructor { + return SubmissionResponseParsingService; + } +} + +export class SubmissionPatchRequest extends PatchRequest { + constructor(public uuid: string, + public href: string, + public body?: any) { + super(uuid, href, body); + } + + getResponseParser(): GenericConstructor { + return SubmissionResponseParsingService; + } +} + +export class SubmissionPostRequest extends PostRequest { + constructor(public uuid: string, + public href: string, + public body?: any, + public options?: HttpOptions) { + super(uuid, href, body, options); + } + + getResponseParser(): GenericConstructor { + return SubmissionResponseParsingService; + } +} + +export class EpersonRequest extends GetRequest { + constructor(uuid: string, href: string) { + super(uuid, href); + } + + getResponseParser(): GenericConstructor { + return EpersonResponseParsingService; + } +} + export class RequestError extends Error { statusText: string; } diff --git a/src/app/core/data/request.service.ts b/src/app/core/data/request.service.ts index 12933f83fc..6f836286ba 100644 --- a/src/app/core/data/request.service.ts +++ b/src/app/core/data/request.service.ts @@ -1,6 +1,8 @@ import { Injectable } from '@angular/core'; -import { createSelector, MemoizedSelector, Store } from '@ngrx/store'; +import { MemoizedSelector, Store } from '@ngrx/store'; + +import { remove } from 'lodash'; import { Observable } from 'rxjs/Observable'; import { hasValue } from '../../shared/empty.util'; @@ -67,12 +69,28 @@ export class RequestService { .flatMap((uuid: string) => this.getByUUID(uuid)); } - // TODO to review "overrideRequest" param when https://github.com/DSpace/dspace-angular/issues/217 will be fixed + private clearRequestsOnTheirWayToTheStore(href) { + this.getByHref(href) + .take(1) + .subscribe((re: RequestEntry) => { + if (!hasValue(re)) { + this.responseCache.remove(href); + } else if (!re.responsePending) { + this.responseCache.remove(href); + remove(this.requestsOnTheirWayToTheStore, (item) => item === href); + } + }); + } + + // TODO to review "forceBypassCache" param when https://github.com/DSpace/dspace-angular/issues/217 will be fixed configure(request: RestRequest, forceBypassCache: boolean = false): void { const isGetRequest = request.method === RestRequestMethod.Get; - if (!isGetRequest || !this.isCachedOrPending(request) || forceBypassCache) { + if (forceBypassCache) { + this.clearRequestsOnTheirWayToTheStore(request.href); + } + if (!isGetRequest || !this.isCachedOrPending(request) || (forceBypassCache && !this.isPending(request))) { this.dispatchRequest(request); - if (isGetRequest && !forceBypassCache) { + if (isGetRequest) { this.trackRequestsOnTheirWayToTheStore(request); } } @@ -121,7 +139,7 @@ export class RequestService { */ private trackRequestsOnTheirWayToTheStore(request: GetRequest) { this.requestsOnTheirWayToTheStore = [...this.requestsOnTheirWayToTheStore, request.href]; - this.store.select(this.entryFromUUIDSelector(request.href)) + this.getByHref(request.href) .filter((re: RequestEntry) => hasValue(re)) .take(1) .subscribe((re: RequestEntry) => { diff --git a/src/app/core/eperson/eperson-data.ts b/src/app/core/eperson/eperson-data.ts new file mode 100644 index 0000000000..a0d69a726f --- /dev/null +++ b/src/app/core/eperson/eperson-data.ts @@ -0,0 +1,12 @@ +import { PageInfo } from '../shared/page-info.model'; +import { NormalizedObject } from '../cache/models/normalized-object.model'; + +/** + * A class to represent the data retrieved by a Eperson service + */ +export class EpersonData { + constructor( + public pageInfo: PageInfo, + public payload: NormalizedObject[] + ) { } +} diff --git a/src/app/core/eperson/eperson-object-factory.ts b/src/app/core/eperson/eperson-object-factory.ts new file mode 100644 index 0000000000..e2d27d7164 --- /dev/null +++ b/src/app/core/eperson/eperson-object-factory.ts @@ -0,0 +1,21 @@ +import { EpersonType } from './eperson-type'; +import { GenericConstructor } from '../shared/generic-constructor'; +import { NormalizedEpersonModel } from './models/NormalizedEperson.model'; +import { NormalizedGroupModel } from './models/NormalizedGroup.model'; +import { NormalizedObject } from '../cache/models/normalized-object.model'; + +export class EpersonObjectFactory { + public static getConstructor(type): GenericConstructor { + switch (type) { + case EpersonType.EpersonsModel: { + return NormalizedEpersonModel + } + case EpersonType.GroupsModel: { + return NormalizedGroupModel + } + default: { + return undefined; + } + } + } +} diff --git a/src/app/core/eperson/eperson-response-parsing.service.ts b/src/app/core/eperson/eperson-response-parsing.service.ts new file mode 100644 index 0000000000..6972149798 --- /dev/null +++ b/src/app/core/eperson/eperson-response-parsing.service.ts @@ -0,0 +1,47 @@ +import { Inject, Injectable } from '@angular/core'; +import { RestRequest } from '../data/request.models'; +import { ResponseParsingService } from '../data/parsing.service'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { + EpersonSuccessResponse, ErrorResponse, + RestResponse +} from '../cache/response-cache.models'; +import { isNotEmpty } from '../../shared/empty.util'; +import { EpersonObjectFactory } from './eperson-object-factory'; +import { EpersonType } from './eperson-type'; + +import { BaseResponseParsingService } from '../data/base-response-parsing.service'; +import { GLOBAL_CONFIG } from '../../../config'; +import { GlobalConfig } from '../../../config/global-config.interface'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { DSpaceObject } from '../shared/dspace-object.model'; +import { NormalizedObject } from '../cache/models/normalized-object.model'; + +@Injectable() +export class EpersonResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { + + protected objectFactory = EpersonObjectFactory; + protected toCache = false; + + constructor( + @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + protected objectCache: ObjectCacheService, + ) { + super(); + } + + parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { + if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links)) { + const epersonDefinition = this.process(data.payload, request.href); + return new EpersonSuccessResponse(epersonDefinition[Object.keys(epersonDefinition)[0]], data.statusCode, this.processPageInfo(data.payload)); + } else { + return new ErrorResponse( + Object.assign( + new Error('Unexpected response from EPerson endpoint'), + {statusText: data.statusCode} + ) + ); + } + } + +} diff --git a/src/app/core/eperson/eperson-type.ts b/src/app/core/eperson/eperson-type.ts new file mode 100644 index 0000000000..ca0bbf04bd --- /dev/null +++ b/src/app/core/eperson/eperson-type.ts @@ -0,0 +1,5 @@ + +export enum EpersonType { + EpersonsModel = 'eperson', + GroupsModel = 'group', +} diff --git a/src/app/core/eperson/eperson.service.ts b/src/app/core/eperson/eperson.service.ts new file mode 100644 index 0000000000..bcd9448f5e --- /dev/null +++ b/src/app/core/eperson/eperson.service.ts @@ -0,0 +1,53 @@ +import { Observable } from 'rxjs/Observable'; +import { RequestService } from '../data/request.service'; +import { ResponseCacheService } from '../cache/response-cache.service'; +import { EpersonSuccessResponse, ErrorResponse, RestResponse } from '../cache/response-cache.models'; +import { EpersonRequest, GetRequest } from '../data/request.models'; +import { ResponseCacheEntry } from '../cache/response-cache.reducer'; +import { isNotEmpty } from '../../shared/empty.util'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { EpersonData } from './eperson-data'; + +export abstract class EpersonService { + protected request: EpersonRequest; + protected abstract responseCache: ResponseCacheService; + protected abstract requestService: RequestService; + protected abstract linkPath: string; + protected abstract browseEndpoint: string; + protected abstract halService: HALEndpointService; + + protected getEperson(request: GetRequest): Observable { + const [successResponse, errorResponse] = this.responseCache.get(request.href) + .map((entry: ResponseCacheEntry) => entry.response) + .partition((response: RestResponse) => response.isSuccessful); + return Observable.merge( + errorResponse.flatMap((response: ErrorResponse) => + Observable.throw(new Error(`Couldn't retrieve the EPerson`))), + successResponse + .filter((response: EpersonSuccessResponse) => isNotEmpty(response)) + .map((response: EpersonSuccessResponse) => new EpersonData(response.pageInfo, response.epersonDefinition)) + .distinctUntilChanged()); + } + + public getDataByHref(href: string): Observable { + const request = new EpersonRequest(this.requestService.generateRequestId(), href); + this.requestService.configure(request); + + return this.getEperson(request); + } + + public getDataByUuid(uuid: string): Observable { + return this.halService.getEndpoint(this.linkPath) + .map((endpoint: string) => this.getDataByIDHref(endpoint, uuid)) + .filter((href: string) => isNotEmpty(href)) + .distinctUntilChanged() + .map((endpointURL: string) => new EpersonRequest(this.requestService.generateRequestId(), endpointURL)) + .do((request: GetRequest) => this.requestService.configure(request)) + .flatMap((request: GetRequest) => this.getEperson(request)) + .distinctUntilChanged(); + } + + protected getDataByIDHref(endpoint, resourceID): string { + return `${endpoint}/${resourceID}`; + } +} diff --git a/src/app/core/eperson/group-eperson.service.ts b/src/app/core/eperson/group-eperson.service.ts new file mode 100644 index 0000000000..1f990ef5a0 --- /dev/null +++ b/src/app/core/eperson/group-eperson.service.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@angular/core'; + +import { EpersonService } from './eperson.service'; +import { ResponseCacheService } from '../cache/response-cache.service'; +import { RequestService } from '../data/request.service'; +import { isNotEmpty } from '../../shared/empty.util'; +import { EpersonRequest, GetRequest } from '../data/request.models'; +import { EpersonData } from './eperson-data'; +import { EpersonSuccessResponse, ErrorResponse, RestResponse } from '../cache/response-cache.models'; +import { Observable } from 'rxjs/Observable'; +import { ResponseCacheEntry } from '../cache/response-cache.reducer'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { BrowseService } from '../browse/browse.service'; + +@Injectable() +export class GroupEpersonService extends EpersonService { + protected linkPath = 'groups'; + protected browseEndpoint = ''; + + constructor( + protected responseCache: ResponseCacheService, + protected requestService: RequestService, + protected bs: BrowseService, + protected halService: HALEndpointService) { + super(); + } + + protected getSearchHref(endpoint, groupName): string { + return `${endpoint}/search/isMemberOf?groupName=${groupName}`; + } + + isMemberOf(groupName: string) { + return this.halService.getEndpoint(this.linkPath) + .map((endpoint: string) => this.getSearchHref(endpoint, groupName)) + .filter((href: string) => isNotEmpty(href)) + .distinctUntilChanged() + .map((endpointURL: string) => new EpersonRequest(this.requestService.generateRequestId(), endpointURL)) + .do((request: GetRequest) => this.requestService.configure(request)) + .flatMap((request: GetRequest) => this.getSearch(request)) + .distinctUntilChanged(); + } + + protected getSearch(request: GetRequest): Observable { + const [successResponse, errorResponse] = this.responseCache.get(request.href) + .map((entry: ResponseCacheEntry) => entry.response) + .partition((response: RestResponse) => response.isSuccessful); + return Observable.merge( + errorResponse.flatMap((response: ErrorResponse) => + Observable.of(new EpersonData(undefined, undefined))), + successResponse + .filter((response: EpersonSuccessResponse) => isNotEmpty(response)) + .map((response: EpersonSuccessResponse) => new EpersonData(response.pageInfo, response.epersonDefinition)) + .distinctUntilChanged()); + } +} diff --git a/src/app/core/eperson/models/NormalizedEperson.model.ts b/src/app/core/eperson/models/NormalizedEperson.model.ts index 0c0b2490d6..c25581a11b 100644 --- a/src/app/core/eperson/models/NormalizedEperson.model.ts +++ b/src/app/core/eperson/models/NormalizedEperson.model.ts @@ -1,10 +1,12 @@ -import { autoserialize, inheritSerialization } from 'cerialize'; +import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; import { CacheableObject } from '../../cache/object-cache.reducer'; import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model'; import { NormalizedDSpaceObject } from '../../cache/models/normalized-dspace-object.model'; import { Eperson } from './eperson.model'; import { mapsTo, relationship } from '../../cache/builders/build-decorators'; import { ResourceType } from '../../shared/resource-type'; +import { Group } from './group.model'; +import { NormalizedGroupModel } from './NormalizedGroup.model'; @mapsTo(Eperson) @inheritSerialization(NormalizedDSpaceObject) @@ -13,9 +15,8 @@ export class NormalizedEpersonModel extends NormalizedDSpaceObject implements Ca @autoserialize public handle: string; - @autoserialize - @relationship(ResourceType.Group, true) - groups: string[]; + @autoserializeAs(NormalizedGroupModel) + groups: Group[]; @autoserialize public netid: string; diff --git a/src/app/core/eperson/models/group.model.ts b/src/app/core/eperson/models/group.model.ts index cd41ce9e25..6ebedba48d 100644 --- a/src/app/core/eperson/models/group.model.ts +++ b/src/app/core/eperson/models/group.model.ts @@ -2,6 +2,8 @@ import { DSpaceObject } from '../../shared/dspace-object.model'; export class Group extends DSpaceObject { + public groups: Group[]; + public handle: string; public permanent: boolean; diff --git a/src/app/core/integration/models/authority-value.model.ts b/src/app/core/integration/models/authority-value.model.ts index e2ef9ce9db..82c8099e9f 100644 --- a/src/app/core/integration/models/authority-value.model.ts +++ b/src/app/core/integration/models/authority-value.model.ts @@ -1,5 +1,6 @@ import { IntegrationModel } from './integration.model'; import { autoserialize } from 'cerialize'; +import { isNotEmpty } from '../../../shared/empty.util'; export class AuthorityValueModel extends IntegrationModel { @@ -17,4 +18,8 @@ export class AuthorityValueModel extends IntegrationModel { @autoserialize language: string; + + hasValue(): boolean { + return isNotEmpty(this.value); + } } diff --git a/src/app/core/json-patch/builder/json-patch-operation-path-combiner.ts b/src/app/core/json-patch/builder/json-patch-operation-path-combiner.ts new file mode 100644 index 0000000000..1650f57d63 --- /dev/null +++ b/src/app/core/json-patch/builder/json-patch-operation-path-combiner.ts @@ -0,0 +1,45 @@ +/** + * Combines a variable number of strings representing parts + * of a relative REST URL in to a single, absolute REST URL + * + */ +import { isNotUndefined } from '../../../shared/empty.util'; + +export interface JsonPatchOperationPathObject { + rootElement: string; + subRootElement: string; + path: string; +} + +export class JsonPatchOperationPathCombiner { + private _rootElement: string; + private _subRootElement: string; + + constructor(rootElement, ...subRootElements: string[]) { + this._rootElement = rootElement; + this._subRootElement = subRootElements.join('/'); + } + + get rootElement(): string { + return this._rootElement; + } + + get subRootElement(): string { + return this._subRootElement; + } + + public getPath(fragment?: string|string[]): JsonPatchOperationPathObject { + if (isNotUndefined(fragment) && Array.isArray(fragment)) { + fragment = fragment.join('/'); + } + + let path; + if (isNotUndefined(fragment)) { + path = '/' + this._rootElement + '/' + this._subRootElement + '/' + fragment; + } else { + path = '/' + this._rootElement + '/' + this._subRootElement; + } + + return {rootElement: this._rootElement, subRootElement: this._subRootElement, path: path}; + } +} 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 new file mode 100644 index 0000000000..1aa973f09f --- /dev/null +++ b/src/app/core/json-patch/builder/json-patch-operations-builder.ts @@ -0,0 +1,111 @@ +import { Store } from '@ngrx/store'; +import { CoreState } from '../../core.reducers'; +import { + NewPatchAddOperationAction, + NewPatchRemoveOperationAction, + NewPatchReplaceOperationAction +} from '../json-patch-operations.actions'; +import { JsonPatchOperationPathObject } from './json-patch-operation-path-combiner'; +import { Injectable } from '@angular/core'; +import { isEmpty, isNotEmpty } from '../../../shared/empty.util'; +import { dateToGMTString } from '../../../shared/date.util'; +import { AuthorityValueModel } from '../../integration/models/authority-value.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'; + +@Injectable() +export class JsonPatchOperationsBuilder { + + constructor(private store: Store) { + } + + add(path: JsonPatchOperationPathObject, value, first = false, plain = false) { + this.store.dispatch( + new NewPatchAddOperationAction( + path.rootElement, + path.subRootElement, + path.path, this.prepareValue(value, plain, first))); + } + + replace(path: JsonPatchOperationPathObject, value, plain = false) { + this.store.dispatch( + new NewPatchReplaceOperationAction( + path.rootElement, + path.subRootElement, + path.path, + this.prepareValue(value, plain, false))); + } + + remove(path: JsonPatchOperationPathObject) { + this.store.dispatch( + new NewPatchRemoveOperationAction( + path.rootElement, + path.subRootElement, + path.path)); + } + + protected prepareValue(value: any, plain: boolean, first: boolean) { + let operationValue: any = null; + if (isNotEmpty(value)) { + if (plain) { + operationValue = value; + } else { + if (Array.isArray(value)) { + operationValue = []; + value.forEach((entry) => { + if ((typeof entry === 'object')) { + operationValue.push(this.prepareObjectValue(entry)); + } else { + operationValue.push(new FormFieldMetadataValueObject(entry)); + // operationValue.push({value: entry}); + // operationValue.push(entry); + } + }); + } else if (typeof value === 'object') { + operationValue = this.prepareObjectValue(value); + } else { + operationValue = new FormFieldMetadataValueObject(value); + } + } + } + return (first && !Array.isArray(operationValue)) ? [operationValue] : operationValue; + } + + protected prepareObjectValue(value: any) { + let operationValue = Object.create({}); + if (isEmpty(value) || value instanceof FormFieldMetadataValueObject) { + operationValue = value; + } else if (value instanceof Date) { + operationValue = new FormFieldMetadataValueObject(dateToGMTString(value)); + } else if (value instanceof AuthorityValueModel) { + operationValue = this.prepareAuthorityValue(value); + } else if (value instanceof FormFieldLanguageValueObject) { + operationValue = new FormFieldMetadataValueObject(value.value, value.language); + } else if (value.hasOwnProperty('value')) { + operationValue = new FormFieldMetadataValueObject(value.value); + // operationValue = value; + } else { + Object.keys(value) + .forEach((key) => { + if (typeof value[key] === 'object') { + operationValue[key] = this.prepareObjectValue(value[key]); + } else { + operationValue[key] = value[key]; + } + }); + // operationValue = {value: value}; + } + return operationValue; + } + + protected prepareAuthorityValue(value: any) { + let operationValue: any = null; + if (isNotEmpty(value.id)) { + operationValue = new FormFieldMetadataValueObject(value.value, value.language, value.id); + } else { + operationValue = new FormFieldMetadataValueObject(value.value, value.language); + } + return operationValue; + } + +} diff --git a/src/app/core/json-patch/json-patch-operations.actions.ts b/src/app/core/json-patch/json-patch-operations.actions.ts new file mode 100644 index 0000000000..cb3e3b0d38 --- /dev/null +++ b/src/app/core/json-patch/json-patch-operations.actions.ts @@ -0,0 +1,279 @@ +import { Action } from '@ngrx/store'; + +import { type } from '../../shared/ngrx/type'; + +/** + * For each action type in an action group, make a simple + * enum object for all of this group's action types. + * + * The 'type' utility function coerces strings into string + * literal types and runs a simple check to guarantee all + * action types in the application are unique. + */ +export const JsonPatchOperationsActionTypes = { + NEW_JSON_PATCH_ADD_OPERATION: type('dspace/core/patch/NEW_JSON_PATCH_ADD_OPERATION'), + NEW_JSON_PATCH_COPY_OPERATION: type('dspace/core/patch/NEW_JSON_PATCH_COPY_OPERATION'), + NEW_JSON_PATCH_MOVE_OPERATION: type('dspace/core/patch/NEW_JSON_PATCH_MOVE_OPERATION'), + NEW_JSON_PATCH_REMOVE_OPERATION: type('dspace/core/patch/NEW_JSON_PATCH_REMOVE_OPERATION'), + NEW_JSON_PATCH_REPLACE_OPERATION: type('dspace/core/patch/NEW_JSON_PATCH_REPLACE_OPERATION'), + COMMIT_JSON_PATCH_OPERATIONS: type('dspace/core/patch/COMMIT_JSON_PATCH_OPERATIONS'), + ROLLBACK_JSON_PATCH_OPERATIONS: type('dspace/core/patch/ROLLBACK_JSON_PATCH_OPERATIONS'), + FLUSH_JSON_PATCH_OPERATIONS: type('dspace/core/patch/FLUSH_JSON_PATCH_OPERATIONS'), + START_TRANSACTION_JSON_PATCH_OPERATIONS: type('dspace/core/patch/START_TRANSACTION_JSON_PATCH_OPERATIONS'), +}; + +/* tslint:disable:max-classes-per-file */ + +/** + * An ngrx action to commit the current transaction + */ +export class CommitPatchOperationsAction implements Action { + type = JsonPatchOperationsActionTypes.COMMIT_JSON_PATCH_OPERATIONS; + payload: { + resourceType: string; + resourceId: string; + }; + + /** + * Create a new CommitPatchOperationsAction + * + * @param resourceType + * the resource's type + * @param resourceId + * the resource's ID + */ + constructor(resourceType: string, resourceId: string) { + this.payload = { resourceType, resourceId }; + } +} + +/** + * An ngrx action to rollback the current transaction + */ +export class RollbacktPatchOperationsAction implements Action { + type = JsonPatchOperationsActionTypes.ROLLBACK_JSON_PATCH_OPERATIONS; + payload: { + resourceType: string; + resourceId: string; + }; + + /** + * Create a new CommitPatchOperationsAction + * + * @param resourceType + * the resource's type + * @param resourceId + * the resource's ID + */ + constructor(resourceType: string, resourceId: string) { + this.payload = { resourceType, resourceId }; + } +} + +/** + * An ngrx action to initiate a transaction block + */ +export class StartTransactionPatchOperationsAction implements Action { + type = JsonPatchOperationsActionTypes.START_TRANSACTION_JSON_PATCH_OPERATIONS; + payload: { + resourceType: string; + resourceId: string; + startTime: number; + }; + + /** + * Create a new CommitPatchOperationsAction + * + * @param resourceType + * the resource's type + * @param resourceId + * the resource's ID + * @param startTime + * the start timestamp + */ + constructor(resourceType: string, resourceId: string, startTime: number) { + this.payload = { resourceType, resourceId, startTime }; + } +} + +/** + * An ngrx action to flush list of the JSON Patch operations + */ +export class FlushPatchOperationsAction implements Action { + type = JsonPatchOperationsActionTypes.FLUSH_JSON_PATCH_OPERATIONS; + payload: { + resourceType: string; + resourceId: string; + }; + + /** + * Create a new FlushPatchOperationsAction + * + * @param resourceType + * the resource's type + * @param resourceId + * the resource's ID + */ + constructor(resourceType: string, resourceId: string) { + this.payload = { resourceType, resourceId }; + } +} + +/** + * An ngrx action to Add new HTTP/PATCH ADD operations to state + */ +export class NewPatchAddOperationAction implements Action { + type = JsonPatchOperationsActionTypes.NEW_JSON_PATCH_ADD_OPERATION; + payload: { + resourceType: string; + resourceId: string; + path: string; + value: any + }; + + /** + * Create a new NewPatchAddOperationAction + * + * @param resourceType + * the resource's type where to add operation + * @param resourceId + * the resource's ID + * @param path + * the path of the operation + * @param value + * the operation's payload + */ + constructor(resourceType: string, resourceId: string, path: string, value: any) { + this.payload = { resourceType, resourceId, path, value }; + } +} + +/** + * An ngrx action to add new JSON Patch COPY operation to state + */ +export class NewPatchCopyOperationAction implements Action { + type = JsonPatchOperationsActionTypes.NEW_JSON_PATCH_COPY_OPERATION; + payload: { + resourceType: string; + resourceId: string; + from: string; + path: string; + }; + + /** + * Create a new NewPatchCopyOperationAction + * + * @param resourceType + * the resource's type + * @param resourceId + * the resource's ID + * @param from + * the path to copy the value from + * @param path + * the path where to copy the value + */ + constructor(resourceType: string, resourceId: string, from: string, path: string) { + this.payload = { resourceType, resourceId, from, path }; + } +} + +/** + * An ngrx action to Add new JSON Patch MOVE operation to state + */ +export class NewPatchMoveOperationAction implements Action { + type = JsonPatchOperationsActionTypes.NEW_JSON_PATCH_MOVE_OPERATION; + payload: { + resourceType: string; + resourceId: string; + from: string; + path: string; + }; + + /** + * Create a new NewPatchMoveOperationAction + * + * @param resourceType + * the resource's type + * @param resourceId + * the resource's ID + * @param from + * the path to move the value from + * @param path + * the path where to move the value + */ + constructor(resourceType: string, resourceId: string, from: string, path: string) { + this.payload = { resourceType, resourceId, from, path }; + } +} + +/** + * An ngrx action to Add new JSON Patch REMOVE operation to state + */ +export class NewPatchRemoveOperationAction implements Action { + type = JsonPatchOperationsActionTypes.NEW_JSON_PATCH_REMOVE_OPERATION; + payload: { + resourceType: string; + resourceId: string; + path: string; + }; + + /** + * Create a new NewPatchRemoveOperationAction + * + * @param resourceType + * the resource's type + * @param resourceId + * the resource's ID + * @param path + * the path of the operation + */ + constructor(resourceType: string, resourceId: string, path: string) { + this.payload = { resourceType, resourceId, path }; + } +} + +/** + * An ngrx action to add new JSON Patch REPLACE operation to state + */ +export class NewPatchReplaceOperationAction implements Action { + type = JsonPatchOperationsActionTypes.NEW_JSON_PATCH_REPLACE_OPERATION; + payload: { + resourceType: string; + resourceId: string; + path: string; + value: any + }; + + /** + * Create a new NewPatchReplaceOperationAction + * + * @param resourceType + * the resource's type + * @param resourceId + * the resource's ID + * @param path + * the path of the operation + * @param value + * the operation's payload + */ + constructor(resourceType: string, resourceId: string, path: string, value: any) { + this.payload = { resourceType, resourceId, path, value }; + } +} + +/* tslint:enable:max-classes-per-file */ + +/** + * Export a type alias of all actions in this action group + * so that reducers can easily compose action types + */ +export type PatchOperationsActions + = CommitPatchOperationsAction + | FlushPatchOperationsAction + | NewPatchAddOperationAction + | NewPatchCopyOperationAction + | NewPatchMoveOperationAction + | NewPatchRemoveOperationAction + | NewPatchReplaceOperationAction + | RollbacktPatchOperationsAction + | StartTransactionPatchOperationsAction diff --git a/src/app/core/json-patch/json-patch-operations.effects.ts b/src/app/core/json-patch/json-patch-operations.effects.ts new file mode 100644 index 0000000000..79fd63fafa --- /dev/null +++ b/src/app/core/json-patch/json-patch-operations.effects.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@angular/core'; +import { Effect, Actions } from '@ngrx/effects'; + +import { + CommitPatchOperationsAction, FlushPatchOperationsAction, + JsonPatchOperationsActionTypes +} from './json-patch-operations.actions'; + +@Injectable() +export class JsonPatchOperationsEffects { + + @Effect() commit$ = this.actions$ + .ofType(JsonPatchOperationsActionTypes.COMMIT_JSON_PATCH_OPERATIONS) + .map((action: CommitPatchOperationsAction) => { + return new FlushPatchOperationsAction(action.payload.resourceType, action.payload.resourceId); + }); + + constructor(private actions$: Actions) {} + +} diff --git a/src/app/core/json-patch/json-patch-operations.reducer.ts b/src/app/core/json-patch/json-patch-operations.reducer.ts new file mode 100644 index 0000000000..4eb5a48d23 --- /dev/null +++ b/src/app/core/json-patch/json-patch-operations.reducer.ts @@ -0,0 +1,292 @@ +import { hasValue, isNotEmpty, isNotUndefined, isNull } from '../../shared/empty.util'; + +import { + FlushPatchOperationsAction, + PatchOperationsActions, + JsonPatchOperationsActionTypes, + NewPatchAddOperationAction, + NewPatchCopyOperationAction, + NewPatchMoveOperationAction, + NewPatchRemoveOperationAction, + NewPatchReplaceOperationAction, + CommitPatchOperationsAction, + StartTransactionPatchOperationsAction, + RollbacktPatchOperationsAction +} from './json-patch-operations.actions'; +import { JsonPatchOperationModel, JsonPatchOperationType } from './json-patch.model'; + +export interface JsonPatchOperationObject { + operation: JsonPatchOperationModel; + timeAdded: number; +} + +export interface JsonPatchOperationsEntry { + body: JsonPatchOperationObject[]; +} + +export interface JsonPatchOperationsResourceEntry { + children: { [resourceId: string]: JsonPatchOperationsEntry }; + transactionStartTime: number; + commitPending: boolean; +} + +/** + * The JSON patch operations State + * + * Consists of a map with a namespace as key, + * and an array of JsonPatchOperationModel as values + */ +export interface JsonPatchOperationsState { + [resourceType: string]: JsonPatchOperationsResourceEntry; +} + +const initialState: JsonPatchOperationsState = Object.create(null); + +export function jsonPatchOperationsReducer(state = initialState, action: PatchOperationsActions): JsonPatchOperationsState { + switch (action.type) { + + case JsonPatchOperationsActionTypes.COMMIT_JSON_PATCH_OPERATIONS: { + return commitOperations(state, action as CommitPatchOperationsAction); + } + + case JsonPatchOperationsActionTypes.FLUSH_JSON_PATCH_OPERATIONS: { + return flushOperation(state, action as FlushPatchOperationsAction); + } + + case JsonPatchOperationsActionTypes.NEW_JSON_PATCH_ADD_OPERATION: { + return newOperation(state, action as NewPatchAddOperationAction); + } + + case JsonPatchOperationsActionTypes.NEW_JSON_PATCH_COPY_OPERATION: { + return newOperation(state, action as NewPatchCopyOperationAction); + } + + case JsonPatchOperationsActionTypes.NEW_JSON_PATCH_MOVE_OPERATION: { + return newOperation(state, action as NewPatchMoveOperationAction); + } + + case JsonPatchOperationsActionTypes.NEW_JSON_PATCH_REMOVE_OPERATION: { + return newOperation(state, action as NewPatchRemoveOperationAction); + } + + case JsonPatchOperationsActionTypes.NEW_JSON_PATCH_REPLACE_OPERATION: { + return newOperation(state, action as NewPatchReplaceOperationAction); + } + + case JsonPatchOperationsActionTypes.ROLLBACK_JSON_PATCH_OPERATIONS: { + return rollbackOperations(state, action as RollbacktPatchOperationsAction); + } + + case JsonPatchOperationsActionTypes.START_TRANSACTION_JSON_PATCH_OPERATIONS: { + return startTransactionPatchOperations(state, action as StartTransactionPatchOperationsAction); + } + + default: { + return state; + } + } +} + +/** + * Set the transaction start time. + * + * @param state + * the current state + * @param action + * an StartTransactionPatchOperationsAction + * @return JsonPatchOperationsState + * the new state. + */ +function startTransactionPatchOperations(state: JsonPatchOperationsState, action: StartTransactionPatchOperationsAction): JsonPatchOperationsState { + if (hasValue(state[ action.payload.resourceType ]) + && isNull(state[ action.payload.resourceType ].transactionStartTime)) { + return Object.assign({}, state, { + [action.payload.resourceType]: Object.assign({}, state[ action.payload.resourceType ], { + children: state[ action.payload.resourceType ].children, + transactionStartTime: action.payload.startTime, + commitPending: true + }) + }); + } else { + return state; + } +} + +/** + * Set commit pending state. + * + * @param state + * the current state + * @param action + * an CommitPatchOperationsAction + * @return JsonPatchOperationsState + * the new state, with the section new validity status. + */ +function commitOperations(state: JsonPatchOperationsState, action: CommitPatchOperationsAction): JsonPatchOperationsState { + if (hasValue(state[ action.payload.resourceType ]) + && state[ action.payload.resourceType ].commitPending) { + return Object.assign({}, state, { + [action.payload.resourceType]: Object.assign({}, state[ action.payload.resourceType ], { + children: state[ action.payload.resourceType ].children, + transactionStartTime: state[ action.payload.resourceType ].transactionStartTime, + commitPending: false + }) + }); + } else { + return state; + } +} + +/** + * Set commit pending state. + * + * @param state + * the current state + * @param action + * an RollbacktPatchOperationsAction + * @return JsonPatchOperationsState + * the new state. + */ +function rollbackOperations(state: JsonPatchOperationsState, action: RollbacktPatchOperationsAction): JsonPatchOperationsState { + if (hasValue(state[ action.payload.resourceType ]) + && state[ action.payload.resourceType ].commitPending) { + return Object.assign({}, state, { + [action.payload.resourceType]: Object.assign({}, state[ action.payload.resourceType ], { + children: state[ action.payload.resourceType ].children, + transactionStartTime: null, + commitPending: false + }) + }); + } else { + return state; + } +} + +/** + * Add new JSON patch operation list. + * + * @param state + * the current state + * @param action + * an NewPatchAddOperationAction + * @return JsonPatchOperationsState + * the new state, with the section new validity status. + */ +function newOperation(state: JsonPatchOperationsState, action): JsonPatchOperationsState { + const newState = Object.assign({}, state); + const newBody = addOperationToList( + (hasValue(newState[ action.payload.resourceType ]) + && hasValue(newState[ action.payload.resourceType ].children) + && hasValue(newState[ action.payload.resourceType ].children[ action.payload.resourceId ]) + && isNotEmpty(newState[ action.payload.resourceType ].children[ action.payload.resourceId ].body)) + ? newState[ action.payload.resourceType ].children[ action.payload.resourceId ].body : Array.of(), + action.type, + action.payload.path, + hasValue(action.payload.value) ? action.payload.value : null); + + if (hasValue(newState[ action.payload.resourceType ]) + && hasValue(newState[ action.payload.resourceType ].children)) { + return Object.assign({}, state, { + [action.payload.resourceType]: Object.assign({}, state[ action.payload.resourceType ], { + children: Object.assign({}, state[ action.payload.resourceType ].children, { + [action.payload.resourceId]: { + body: newBody, + } + }), + transactionStartTime: state[ action.payload.resourceType ].transactionStartTime, + commitPending: isNotUndefined(state[ action.payload.resourceType ].commitPending) ? state[ action.payload.resourceType ].commitPending : false + }) + }); + } else { + return Object.assign({}, state, { + [action.payload.resourceType]: Object.assign({}, { + children: { + [action.payload.resourceId]: { + body: newBody, + } + }, + transactionStartTime: null, + commitPending: false + }) + }); + } +} + +/** + * Set the section validity. + * + * @param state + * the current state + * @param action + * an LoadSubmissionFormAction + * @return SubmissionObjectState + * the new state, with the section new validity status. + */ +function flushOperation(state: JsonPatchOperationsState, action: FlushPatchOperationsAction): JsonPatchOperationsState { + if (hasValue(state[ action.payload.resourceType ])) { + let newChildren; + if (isNotUndefined(action.payload.resourceId)) { + // flush only specified child's operations + if (hasValue(state[ action.payload.resourceType ].children) + && hasValue(state[ action.payload.resourceType ].children[ action.payload.resourceId ])) { + newChildren = Object.assign({}, state[ action.payload.resourceType ].children, { + [action.payload.resourceId]: { + body: state[ action.payload.resourceType ].children[ action.payload.resourceId ].body + .filter((entry) => entry.timeAdded > state[ action.payload.resourceType ].transactionStartTime) + } + }); + } else { + newChildren = state[ action.payload.resourceType ].children; + } + } else { + // flush all children's operations + newChildren = state[ action.payload.resourceType ].children; + Object.keys(newChildren) + .forEach((resourceId) => { + newChildren = Object.assign({}, newChildren, { + [resourceId]: { + body: newChildren[ resourceId ].body + .filter((entry) => entry.timeAdded > state[ action.payload.resourceType ].transactionStartTime) + } + }); + }) + } + return Object.assign({}, state, { + [action.payload.resourceType]: Object.assign({}, state[ action.payload.resourceType ], { + children: newChildren, + transactionStartTime: null, + commitPending: state[ action.payload.resourceType ].commitPending + }) + }); + } else { + return state; + } +} + +function addOperationToList(body: JsonPatchOperationObject[], actionType, targetPath, value?) { + const newBody = Array.from(body); + switch (actionType) { + case JsonPatchOperationsActionTypes.NEW_JSON_PATCH_ADD_OPERATION: + newBody.push(makeOperationEntry({ + op: JsonPatchOperationType.add, + path: targetPath, + value: value + })); + break; + case JsonPatchOperationsActionTypes.NEW_JSON_PATCH_REPLACE_OPERATION: + newBody.push(makeOperationEntry({ + op: JsonPatchOperationType.replace, + path: targetPath, + value: value + })); + break; + case JsonPatchOperationsActionTypes.NEW_JSON_PATCH_REMOVE_OPERATION: + newBody.push(makeOperationEntry({ op: JsonPatchOperationType.remove, path: targetPath })); + break; + } + return newBody; +} + +function makeOperationEntry(operation) { + return { operation: operation, timeAdded: new Date().getTime() }; +} diff --git a/src/app/core/json-patch/json-patch-operations.service.ts b/src/app/core/json-patch/json-patch-operations.service.ts new file mode 100644 index 0000000000..8328a6d4a7 --- /dev/null +++ b/src/app/core/json-patch/json-patch-operations.service.ts @@ -0,0 +1,127 @@ +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs/Observable'; +import { hasValue, isEmpty, isNotEmpty, isNotUndefined, isUndefined } from '../../shared/empty.util'; +import { ErrorResponse, PostPatchSuccessResponse, RestResponse } from '../cache/response-cache.models'; +import { ResponseCacheEntry } from '../cache/response-cache.reducer'; +import { ResponseCacheService } from '../cache/response-cache.service'; +import { PatchRequest, RestRequest, SubmissionPatchRequest } from '../data/request.models'; +import { RequestService } from '../data/request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { CoreState } from '../core.reducers'; +import { Store } from '@ngrx/store'; +import { jsonPatchOperationsByResourceType } from './selectors'; +import { JsonPatchOperationsResourceEntry } from './json-patch-operations.reducer'; +import { + CommitPatchOperationsAction, + RollbacktPatchOperationsAction, + StartTransactionPatchOperationsAction +} from './json-patch-operations.actions'; +import { JsonPatchOperationModel } from './json-patch.model'; + +@Injectable() +export class JsonPatchOperationsService { + protected linkPath; + + constructor(protected responseCache: ResponseCacheService, + protected requestService: RequestService, + protected store: Store, + protected halService: HALEndpointService) { + } + + protected submitData(request: RestRequest): Observable { + const [successResponse, errorResponse] = this.responseCache.get(request.href) + .map((entry: ResponseCacheEntry) => entry.response) + .partition((response: RestResponse) => response.isSuccessful); + return Observable.merge( + errorResponse.flatMap((response: ErrorResponse) => + Observable.throw(new Error(`Couldn't send data to server`))), + successResponse + .filter((response: PostPatchSuccessResponse) => isNotEmpty(response)) + .map((response: PostPatchSuccessResponse) => response.dataDefinition) + .distinctUntilChanged()); + } + + protected submitJsonPatchOperations(hrefObs: Observable, resourceType: string, resourceId?: string) { + let startTransactionTime = null; + const [patchRequestObs, emptyRequestObs] = hrefObs + .flatMap((endpointURL: string) => { + return this.store.select(jsonPatchOperationsByResourceType(resourceType)) + .take(1) + .filter((operationsList: JsonPatchOperationsResourceEntry) => isUndefined(operationsList) || !(operationsList.commitPending)) + .do(() => startTransactionTime = new Date().getTime()) + .map((operationsList: JsonPatchOperationsResourceEntry) => { + const body: JsonPatchOperationModel[] = []; + if (isNotEmpty(operationsList)) { + if (isNotEmpty(resourceId)) { + if (isNotUndefined(operationsList.children[resourceId]) && isNotEmpty(operationsList.children[resourceId].body)) { + operationsList.children[resourceId].body.forEach((entry) => { + body.push(entry.operation); + }); + } + } else { + Object.keys(operationsList.children) + .filter((key) => operationsList.children.hasOwnProperty(key)) + .filter((key) => hasValue(operationsList.children[key])) + .filter((key) => hasValue(operationsList.children[key].body)) + .forEach((key) => { + operationsList.children[key].body.forEach((entry) => { + body.push(entry.operation); + }); + }) + } + } + return new SubmissionPatchRequest(this.requestService.generateRequestId(), endpointURL, body); + }); + }) + .partition((request: PatchRequest) => isNotEmpty(request.body)); + + return Observable.merge( + emptyRequestObs + .filter((request: PatchRequest) => isEmpty(request.body)) + .do(() => startTransactionTime = null) + .map(() => null), + patchRequestObs + .filter((request: PatchRequest) => isNotEmpty(request.body)) + .do(() => this.store.dispatch(new StartTransactionPatchOperationsAction(resourceType, resourceId, startTransactionTime))) + .do((request: PatchRequest) => this.requestService.configure(request, true)) + .flatMap((request: PatchRequest) => { + const [successResponse, errorResponse] = this.responseCache.get(request.href) + .filter((entry: ResponseCacheEntry) => startTransactionTime < entry.timeAdded) + .take(1) + .map((entry: ResponseCacheEntry) => entry.response) + .partition((response: RestResponse) => response.isSuccessful); + return Observable.merge( + errorResponse + .do(() => this.store.dispatch(new RollbacktPatchOperationsAction(resourceType, resourceId))) + .flatMap((response: ErrorResponse) => Observable.of(new Error(`Couldn't patch operations`))), + successResponse + .filter((response: PostPatchSuccessResponse) => isNotEmpty(response)) + .do(() => this.store.dispatch(new CommitPatchOperationsAction(resourceType, resourceId))) + .map((response: PostPatchSuccessResponse) => response.dataDefinition) + .distinctUntilChanged()); + }) + ); + } + + protected getEndpointByIDHref(endpoint, resourceID): string { + return isNotEmpty(resourceID) ? `${endpoint}/${resourceID}` : `${endpoint}`; + } + + public jsonPatchByResourceType(linkName: string, scopeId: string, resourceType: string,) { + const hrefObs = this.halService.getEndpoint(linkName) + .filter((href: string) => isNotEmpty(href)) + .distinctUntilChanged() + .map((endpointURL: string) => this.getEndpointByIDHref(endpointURL, scopeId)); + + return this.submitJsonPatchOperations(hrefObs, resourceType); + } + + public jsonPatchByResourceID(linkName: string, scopeId: string, resourceType: string, resourceId: string) { + const hrefObs = this.halService.getEndpoint(linkName) + .filter((href: string) => isNotEmpty(href)) + .distinctUntilChanged() + .map((endpointURL: string) => this.getEndpointByIDHref(endpointURL, scopeId)); + + return this.submitJsonPatchOperations(hrefObs, resourceType, resourceId); + } +} diff --git a/src/app/core/json-patch/json-patch.model.ts b/src/app/core/json-patch/json-patch.model.ts new file mode 100644 index 0000000000..c0553fde97 --- /dev/null +++ b/src/app/core/json-patch/json-patch.model.ts @@ -0,0 +1,14 @@ +export enum JsonPatchOperationType { + test = 'test', + remove = 'remove', + add = 'add', + replace = 'replace', + move = 'move', + copy = 'copy', +} + +export class JsonPatchOperationModel { + op: JsonPatchOperationType; + path: string; + value: any; +} diff --git a/src/app/core/json-patch/selectors.ts b/src/app/core/json-patch/selectors.ts new file mode 100644 index 0000000000..547d4b82e8 --- /dev/null +++ b/src/app/core/json-patch/selectors.ts @@ -0,0 +1,34 @@ +// @TODO: Merge with keySelector function present in 'src/app/core/shared/selectors.ts' +import { createSelector, MemoizedSelector, Selector } from '@ngrx/store'; +import { hasValue } from '../../shared/empty.util'; +import { coreSelector, CoreState } from '../core.reducers'; +import { JsonPatchOperationsEntry, JsonPatchOperationsResourceEntry } from './json-patch-operations.reducer'; + +export function keySelector(parentSelector: Selector, subState: string, key: string): MemoizedSelector { + return createSelector(parentSelector, (state: T) => { + if (hasValue(state[subState])) { + return state[subState][key]; + } else { + return undefined; + } + }); +} + +export function subStateSelector(parentSelector: Selector, subState: string): MemoizedSelector { + return createSelector(parentSelector, (state: T) => { + if (hasValue(state[subState])) { + return state[subState]; + } else { + return undefined; + } + }); +} + +export function jsonPatchOperationsByResourceType(resourceType: string): MemoizedSelector { + return keySelector(coreSelector,'json/patch', resourceType); +} + +export function jsonPatchOperationsByResourcId(resourceType: string, resourceId: string): MemoizedSelector { + const resourceTypeSelector = jsonPatchOperationsByResourceType(resourceType); + return subStateSelector(resourceTypeSelector, resourceId); +} diff --git a/src/app/core/shared/collection.model.ts b/src/app/core/shared/collection.model.ts index b2f8d90a65..c2287e39a7 100644 --- a/src/app/core/shared/collection.model.ts +++ b/src/app/core/shared/collection.model.ts @@ -3,6 +3,8 @@ import { Bitstream } from './bitstream.model'; import { Item } from './item.model'; import { RemoteData } from '../data/remote-data'; import { Observable } from 'rxjs/Observable'; +import { License } from './license.model'; +import { ResourcePolicy } from './resource-policy.model'; export class Collection extends DSpaceObject { @@ -39,7 +41,7 @@ export class Collection extends DSpaceObject { * The license of this Collection * Corresponds to the metadata field dc.rights.license */ - get license(): string { + get dcLicense(): string { return this.findMetadata('dc.rights.license'); } @@ -51,11 +53,21 @@ export class Collection extends DSpaceObject { return this.findMetadata('dc.description.tableofcontents'); } + /** + * The deposit license of this Collection + */ + license: Observable>; + /** * The Bitstream that represents the logo of this Collection */ logo: Observable>; + /** + * The default access conditions of this Collection + */ + defaultAccessConditions: Observable>; + /** * An array of Collections that are direct parents of this Collection */ diff --git a/src/app/core/shared/config/config-access-condition-option.model.ts b/src/app/core/shared/config/config-access-condition-option.model.ts new file mode 100644 index 0000000000..1f2e826af7 --- /dev/null +++ b/src/app/core/shared/config/config-access-condition-option.model.ts @@ -0,0 +1,8 @@ +export class AccessConditionOption { + name: string; + groupUUID: string; + hasStartDate: boolean; + hasEndDate: boolean; + maxStartDate: string; + maxEndDate: string; +} diff --git a/src/app/core/shared/config/config-object-factory.ts b/src/app/core/shared/config/config-object-factory.ts index 4cb5016983..b43d4456f4 100644 --- a/src/app/core/shared/config/config-object-factory.ts +++ b/src/app/core/shared/config/config-object-factory.ts @@ -1,5 +1,4 @@ - -import { GenericConstructor } from '../../shared/generic-constructor'; +import { GenericConstructor } from '../generic-constructor'; import { SubmissionSectionModel } from './config-submission-section.model'; import { SubmissionFormsModel } from './config-submission-forms.model'; @@ -7,6 +6,7 @@ import { SubmissionDefinitionsModel } from './config-submission-definitions.mode import { ConfigType } from './config-type'; import { ConfigObject } from './config.model'; import { ConfigAuthorityModel } from './config-authority.model'; +import { SubmissionUploadsModel } from './config-submission-uploads.model'; export class ConfigObjectFactory { public static getConstructor(type): GenericConstructor { @@ -23,6 +23,10 @@ export class ConfigObjectFactory { case ConfigType.SubmissionSections: { return SubmissionSectionModel } + case ConfigType.SubmissionUpload: + case ConfigType.SubmissionUploads: { + return SubmissionUploadsModel + } case ConfigType.Authority: { return ConfigAuthorityModel } diff --git a/src/app/core/shared/config/config-submission-section.model.ts b/src/app/core/shared/config/config-submission-section.model.ts index 0eb9daaeab..69b5af2d1a 100644 --- a/src/app/core/shared/config/config-submission-section.model.ts +++ b/src/app/core/shared/config/config-submission-section.model.ts @@ -1,5 +1,6 @@ -import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; +import { autoserialize, inheritSerialization } from 'cerialize'; import { ConfigObject } from './config.model'; +import { SectionsType } from '../../../submission/sections/sections-type'; @inheritSerialization(ConfigObject) export class SubmissionSectionModel extends ConfigObject { @@ -11,7 +12,7 @@ export class SubmissionSectionModel extends ConfigObject { mandatory: boolean; @autoserialize - sectionType: string; + sectionType: SectionsType; @autoserialize visibility: { diff --git a/src/app/core/shared/config/config-submission-uploads.model.ts b/src/app/core/shared/config/config-submission-uploads.model.ts new file mode 100644 index 0000000000..c970ff013f --- /dev/null +++ b/src/app/core/shared/config/config-submission-uploads.model.ts @@ -0,0 +1,21 @@ +import {autoserialize, autoserializeAs, inheritSerialization} from 'cerialize'; +import { ConfigObject } from './config.model'; +import { AccessConditionOption } from './config-access-condition-option.model'; +import {SubmissionFormsModel} from './config-submission-forms.model'; + +@inheritSerialization(ConfigObject) +export class SubmissionUploadsModel extends ConfigObject { + + @autoserialize + accessConditionOptions: AccessConditionOption[]; + + @autoserializeAs(SubmissionFormsModel) + metadata: SubmissionFormsModel[]; + + @autoserialize + required: boolean; + + @autoserialize + maxSize: number; + +} diff --git a/src/app/core/shared/config/config-type.ts b/src/app/core/shared/config/config-type.ts index 17ed099229..a240035eb9 100644 --- a/src/app/core/shared/config/config-type.ts +++ b/src/app/core/shared/config/config-type.ts @@ -2,7 +2,6 @@ * TODO replace with actual string enum after upgrade to TypeScript 2.4: * https://github.com/Microsoft/TypeScript/pull/15486 */ -import { ResourceType } from '../resource-type'; export enum ConfigType { SubmissionDefinitions = 'submissiondefinitions', @@ -11,5 +10,6 @@ export enum ConfigType { SubmissionForms = 'submissionforms', SubmissionSections = 'submissionsections', SubmissionSection = 'submissionsection', - Authority = 'authority' + SubmissionUploads = 'submissionuploads', + SubmissionUpload = 'submissionupload', } diff --git a/src/app/core/shared/dspace-object.model.ts b/src/app/core/shared/dspace-object.model.ts index 5e62e3e321..52a7925bab 100644 --- a/src/app/core/shared/dspace-object.model.ts +++ b/src/app/core/shared/dspace-object.model.ts @@ -60,9 +60,9 @@ export class DSpaceObject implements CacheableObject, ListableObject { * @return string */ findMetadata(key: string, language?: string): string { - const metadatum = this.metadata.find((m: Metadatum) => { + const metadatum = (this.metadata) ? this.metadata.find((m: Metadatum) => { return m.key === key && (isEmpty(language) || m.language === language) - }); + }) : null; if (isNotEmpty(metadatum)) { return metadatum.value; } else { @@ -81,7 +81,7 @@ export class DSpaceObject implements CacheableObject, ListableObject { * @return Array */ filterMetadata(keys: string[]): Metadatum[] { - return this.metadata.filter((metadatum: Metadatum) => { + return (this.metadata || []).filter((metadatum: Metadatum) => { return keys.some((key) => key === metadatum.key); }); } diff --git a/src/app/core/shared/file.service.ts b/src/app/core/shared/file.service.ts new file mode 100644 index 0000000000..75dbcefda8 --- /dev/null +++ b/src/app/core/shared/file.service.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@angular/core'; +import { HttpHeaders } from '@angular/common/http'; + +import { DSpaceRESTv2Service, HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; +import { RestRequestMethod } from '../data/request.models'; +import { saveAs } from 'file-saver'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; + +@Injectable() +export class FileService { + constructor( + private restService: DSpaceRESTv2Service + ) { } + + downloadFile(url: string) { + const headers = new HttpHeaders(); + const options: HttpOptions = Object.create({headers, responseType: 'blob'}); + return this.restService.request(RestRequestMethod.Get, url, null, options) + .subscribe((data) => { + saveAs(data.payload as Blob, this.getFileNameFromResponseContentDisposition(data)); + }); + } + + /** + * Derives file name from the http response + * by looking inside content-disposition + * @param res http DSpaceRESTV2Response + */ + getFileNameFromResponseContentDisposition(res: DSpaceRESTV2Response) { + const contentDisposition = res.headers.get('content-disposition') || ''; + const matches = /filename="([^;]+)"/ig.exec(contentDisposition) || []; + const fileName = (matches[1] || 'untitled').trim().replace(/\.[^/.]+$/, ''); + return fileName; + }; +} diff --git a/src/app/core/shared/hal-endpoint.service.ts b/src/app/core/shared/hal-endpoint.service.ts index 3bedeb9915..7ca8a1b8c7 100644 --- a/src/app/core/shared/hal-endpoint.service.ts +++ b/src/app/core/shared/hal-endpoint.service.ts @@ -55,8 +55,7 @@ export class HALEndpointService { return endpointMap[subPath]; } else { /*TODO remove if/else block once the rest response contains _links for facets*/ - currentPath += '/' + subPath; - return currentPath; + return currentPath + '/' + subPath; } }), ]) diff --git a/src/app/core/shared/item.model.ts b/src/app/core/shared/item.model.ts index dd60ad9b01..be22c28b91 100644 --- a/src/app/core/shared/item.model.ts +++ b/src/app/core/shared/item.model.ts @@ -88,8 +88,10 @@ export class Item extends DSpaceObject { */ getBitstreamsByBundleName(bundleName: string): Observable { return this.bitstreams + .filter((rd: RemoteData) => rd.hasSucceeded) .map((rd: RemoteData) => rd.payload) .filter((bitstreams: Bitstream[]) => hasValue(bitstreams)) + .first() .startWith([]) .map((bitstreams) => { return bitstreams diff --git a/src/app/core/shared/license.model.ts b/src/app/core/shared/license.model.ts new file mode 100644 index 0000000000..a04422242a --- /dev/null +++ b/src/app/core/shared/license.model.ts @@ -0,0 +1,14 @@ +import { DSpaceObject } from './dspace-object.model'; + +export class License extends DSpaceObject { + + /** + * Is the license custom? + */ + custom: boolean; + + /** + * The text of the license + */ + text: string; +} diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts index c0b9be3fbf..e9b5d1ecbd 100644 --- a/src/app/core/shared/operators.ts +++ b/src/app/core/shared/operators.ts @@ -38,9 +38,9 @@ export const getResourceLinksFromResponse = () => map((entry: ResponseCacheEntry) => (entry.response as DSOSuccessResponse).resourceSelfLinks), ); -export const configureRequest = (requestService: RequestService) => +export const configureRequest = (requestService: RequestService, forceBypassCache?: boolean) => (source: Observable): Observable => - source.pipe(tap((request: RestRequest) => requestService.configure(request))); + source.pipe(tap((request: RestRequest) => requestService.configure(request, forceBypassCache))); export const getRemoteDataPayload = () => (source: Observable>): Observable => diff --git a/src/app/core/shared/patch-request.model.ts b/src/app/core/shared/patch-request.model.ts new file mode 100644 index 0000000000..ab99b17dfd --- /dev/null +++ b/src/app/core/shared/patch-request.model.ts @@ -0,0 +1,14 @@ +export enum PatchOperationType { + test = 'test', + remove = 'remove', + add = 'add', + replace = 'replace', + move = 'move', + copy = 'copy', +} + +export class PatchOperationModel { + op: PatchOperationType; + path: string; + value: any; +} diff --git a/src/app/core/shared/resource-policy.model.ts b/src/app/core/shared/resource-policy.model.ts new file mode 100644 index 0000000000..0cbcd8d883 --- /dev/null +++ b/src/app/core/shared/resource-policy.model.ts @@ -0,0 +1,34 @@ +import { DSpaceObject } from './dspace-object.model'; + +export class ResourcePolicy extends DSpaceObject { + + /** + * The action of the resource policy + */ + action: string; + + /** + * The identifier of the resource policy + */ + id: string; + + /** + * The group uuid bound to the resource policy + */ + groupUUID: string; + + /** + * The end date of the resource policy + */ + endDate: string; + + /** + * The start date of the resource policy + */ + startDate: string; + + /** + * The type of the resource policy + */ + rpType: string +} diff --git a/src/app/core/shared/resource-type.ts b/src/app/core/shared/resource-type.ts index b774188f63..38ac496f5d 100644 --- a/src/app/core/shared/resource-type.ts +++ b/src/app/core/shared/resource-type.ts @@ -8,4 +8,8 @@ export enum ResourceType { Community = 'community', Eperson = 'eperson', Group = 'group', + ResourcePolicy = 'resourcePolicy', + License = 'license', + Workflowitem = 'workflowitem', + Workspaceitem = 'workspaceitem', } diff --git a/src/app/core/shared/submit-data-response-definition.model.ts b/src/app/core/shared/submit-data-response-definition.model.ts new file mode 100644 index 0000000000..7220a82b7e --- /dev/null +++ b/src/app/core/shared/submit-data-response-definition.model.ts @@ -0,0 +1,11 @@ +import { autoserialize } from 'cerialize'; + +export class SubmitDataResponseDefinitionObject { + + @autoserialize + public name: string; + + @autoserialize + public type: string; + +} diff --git a/src/app/core/submission/models/edititem.model.ts b/src/app/core/submission/models/edititem.model.ts new file mode 100644 index 0000000000..9c8da2ab5a --- /dev/null +++ b/src/app/core/submission/models/edititem.model.ts @@ -0,0 +1,4 @@ +import { Workspaceitem } from './workspaceitem.model'; + +export class EditItem extends Workspaceitem { +} diff --git a/src/app/core/submission/models/normalized-edititem.model.ts b/src/app/core/submission/models/normalized-edititem.model.ts new file mode 100644 index 0000000000..49dc3e6bd4 --- /dev/null +++ b/src/app/core/submission/models/normalized-edititem.model.ts @@ -0,0 +1,47 @@ +import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; +import { mapsTo, relationship } from '../../cache/builders/build-decorators'; +import { NormalizedWorkspaceItem } from './normalized-workspaceitem.model'; +import { NormalizedSubmissionObject } from './normalized-submission-object.model'; +import { ResourceType } from '../../shared/resource-type'; +import { SubmissionDefinitionsModel } from '../../shared/config/config-submission-definitions.model'; +import { WorkspaceitemSectionsObject } from './workspaceitem-sections.model'; +import { SubmissionObjectError } from './submission-object.model'; +import { EditItem } from './edititem.model'; + +@mapsTo(EditItem) +@inheritSerialization(NormalizedWorkspaceItem) +export class NormalizedEditItem extends NormalizedSubmissionObject { + + /** + * The item identifier + */ + @autoserialize + id: string; + + /** + * The item last modified date + */ + @autoserialize + lastModified: Date; + + @autoserialize + @relationship(ResourceType.Collection, true) + collection: string[]; + + @autoserialize + @relationship(ResourceType.Item, true) + item: string[]; + + @autoserialize + sections: WorkspaceitemSectionsObject; + + @autoserializeAs(SubmissionDefinitionsModel) + submissionDefinition: SubmissionDefinitionsModel; + + @autoserialize + @relationship(ResourceType.Eperson, true) + submitter: string[]; + + @autoserialize + errors: SubmissionObjectError[] +} diff --git a/src/app/core/submission/models/normalized-submission-object.model.ts b/src/app/core/submission/models/normalized-submission-object.model.ts new file mode 100644 index 0000000000..771902dbc8 --- /dev/null +++ b/src/app/core/submission/models/normalized-submission-object.model.ts @@ -0,0 +1,8 @@ +import { NormalizedDSpaceObject } from '../../cache/models/normalized-dspace-object.model'; + +/** + * An abstract model class for a DSpaceObject. + */ +export abstract class NormalizedSubmissionObject extends NormalizedDSpaceObject { + +} diff --git a/src/app/core/submission/models/normalized-workflowitem.model.ts b/src/app/core/submission/models/normalized-workflowitem.model.ts new file mode 100644 index 0000000000..166e126f41 --- /dev/null +++ b/src/app/core/submission/models/normalized-workflowitem.model.ts @@ -0,0 +1,47 @@ +import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; +import { mapsTo, relationship } from '../../cache/builders/build-decorators'; +import { Workflowitem } from './workflowitem.model'; +import { NormalizedWorkspaceItem } from './normalized-workspaceitem.model'; +import { NormalizedSubmissionObject } from './normalized-submission-object.model'; +import { ResourceType } from '../../shared/resource-type'; +import { SubmissionDefinitionsModel } from '../../shared/config/config-submission-definitions.model'; +import { WorkspaceitemSectionsObject } from './workspaceitem-sections.model'; +import { SubmissionObjectError } from './submission-object.model'; + +@mapsTo(Workflowitem) +@inheritSerialization(NormalizedWorkspaceItem) +export class NormalizedWorkflowItem extends NormalizedSubmissionObject { + + /** + * The workspaceitem identifier + */ + @autoserialize + id: string; + + /** + * The workspaceitem last modified date + */ + @autoserialize + lastModified: Date; + + @autoserialize + @relationship(ResourceType.Collection, true) + collection: string[]; + + @autoserialize + @relationship(ResourceType.Item, true) + item: string[]; + + @autoserialize + sections: WorkspaceitemSectionsObject; + + @autoserializeAs(SubmissionDefinitionsModel) + submissionDefinition: SubmissionDefinitionsModel; + + @autoserialize + @relationship(ResourceType.Eperson, true) + submitter: string[]; + + @autoserialize + errors: SubmissionObjectError[] +} diff --git a/src/app/core/submission/models/normalized-workspaceitem.model.ts b/src/app/core/submission/models/normalized-workspaceitem.model.ts new file mode 100644 index 0000000000..33a0f2b262 --- /dev/null +++ b/src/app/core/submission/models/normalized-workspaceitem.model.ts @@ -0,0 +1,51 @@ +import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; + +import { Workspaceitem } from './workspaceitem.model'; +import { WorkspaceitemSectionsObject } from './workspaceitem-sections.model'; + +import { NormalizedSubmissionObject } from './normalized-submission-object.model'; +import { NormalizedDSpaceObject } from '../../cache/models/normalized-dspace-object.model'; +import { mapsTo, relationship } from '../../cache/builders/build-decorators'; +import { NormalizedCollection } from '../../cache/models/normalized-collection.model'; +import { ResourceType } from '../../shared/resource-type'; +import { SubmissionDefinitionsModel } from '../../shared/config/config-submission-definitions.model'; +import { Eperson } from '../../eperson/models/eperson.model'; +import { SubmissionObjectError } from './submission-object.model'; + +@mapsTo(Workspaceitem) +@inheritSerialization(NormalizedDSpaceObject) +export class NormalizedWorkspaceItem extends NormalizedSubmissionObject { + + /** + * The workspaceitem identifier + */ + @autoserialize + id: string; + + /** + * The workspaceitem last modified date + */ + @autoserialize + lastModified: Date; + + @autoserialize + @relationship(ResourceType.Collection, true) + collection: string[]; + + @autoserialize + @relationship(ResourceType.Item, true) + item: string[]; + + @autoserialize + sections: WorkspaceitemSectionsObject; + + @autoserializeAs(SubmissionDefinitionsModel) + submissionDefinition: SubmissionDefinitionsModel; + + @autoserialize + @relationship(ResourceType.Eperson, true) + submitter: string[]; + + @autoserialize + errors: SubmissionObjectError[] +} diff --git a/src/app/core/submission/models/submission-object.model.ts b/src/app/core/submission/models/submission-object.model.ts new file mode 100644 index 0000000000..f7140383af --- /dev/null +++ b/src/app/core/submission/models/submission-object.model.ts @@ -0,0 +1,43 @@ +import { CacheableObject } from '../../cache/object-cache.reducer'; +import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model'; +import { DSpaceObject } from '../../shared/dspace-object.model'; +import { Eperson } from '../../eperson/models/eperson.model'; +import { RemoteData } from '../../data/remote-data'; +import { Collection } from '../../shared/collection.model'; +import { Item } from '../../shared/item.model'; +import { SubmissionDefinitionsModel } from '../../shared/config/config-submission-definitions.model'; +import { Observable } from 'rxjs/Observable'; +import { WorkspaceitemSectionsObject } from './workspaceitem-sections.model'; + +export interface SubmissionObjectError { + message: string, + paths: string[], +} + +/** + * An abstract model class for a DSpaceObject. + */ +export abstract class SubmissionObject extends DSpaceObject implements CacheableObject, ListableObject { + + /** + * The workspaceitem identifier + */ + id: string; + + /** + * The workspaceitem last modified date + */ + lastModified: Date; + + collection: Observable> | Collection[]; + + item: Observable> | Item[]; + + sections: WorkspaceitemSectionsObject; + + submissionDefinition: SubmissionDefinitionsModel; + + submitter: Observable> | Eperson[]; + + errors: SubmissionObjectError[]; +} diff --git a/src/app/core/submission/models/submission-upload-file-access-condition.model.ts b/src/app/core/submission/models/submission-upload-file-access-condition.model.ts new file mode 100644 index 0000000000..ca2f21de47 --- /dev/null +++ b/src/app/core/submission/models/submission-upload-file-access-condition.model.ts @@ -0,0 +1,7 @@ +export class SubmissionUploadFileAccessConditionObject { + id: string; + name: string; + groupUUID: string; + startDate: string; + endDate: string; +} diff --git a/src/app/core/submission/models/workflowitem.model.ts b/src/app/core/submission/models/workflowitem.model.ts new file mode 100644 index 0000000000..3df49c91f7 --- /dev/null +++ b/src/app/core/submission/models/workflowitem.model.ts @@ -0,0 +1,4 @@ +import { Workspaceitem } from './workspaceitem.model'; + +export class Workflowitem extends Workspaceitem { +} diff --git a/src/app/core/submission/models/workspaceitem-section-deduplication.model.ts b/src/app/core/submission/models/workspaceitem-section-deduplication.model.ts new file mode 100644 index 0000000000..d74191f87c --- /dev/null +++ b/src/app/core/submission/models/workspaceitem-section-deduplication.model.ts @@ -0,0 +1,21 @@ +import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model'; +import { WorkspaceitemSectionUploadFileObject } from './workspaceitem-section-upload-file.model'; +import { FormFieldChangedObject } from '../../../shared/form/builder/models/form-field-unexpected-object.model'; + +import { DSpaceObject } from '../../shared/dspace-object.model'; + +export interface WorkspaceitemSectionDeduplicationObject { + matches: DeduplicationSchema[]; +} + +export interface DeduplicationSchema { + submitterDecision?: string; // [reject|verify] + submitterNote?: string; + submitterTime?: string; // (readonly) + + workflowDecision?: string; // [reject|verify] + workflowNote?: string; + workflowTime?: string; // (readonly) + + matchObject?: DSpaceObject; // item, workspaceItem, workflowItem +} diff --git a/src/app/core/submission/models/workspaceitem-section-form.model.ts b/src/app/core/submission/models/workspaceitem-section-form.model.ts new file mode 100644 index 0000000000..e0abad9130 --- /dev/null +++ b/src/app/core/submission/models/workspaceitem-section-form.model.ts @@ -0,0 +1,5 @@ +import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model'; + +export interface WorkspaceitemSectionFormObject { + [metadata: string]: FormFieldMetadataValueObject; +} diff --git a/src/app/core/submission/models/workspaceitem-section-license.model.ts b/src/app/core/submission/models/workspaceitem-section-license.model.ts new file mode 100644 index 0000000000..4a86503a04 --- /dev/null +++ b/src/app/core/submission/models/workspaceitem-section-license.model.ts @@ -0,0 +1,5 @@ +export interface WorkspaceitemSectionLicenseObject { + url: string; + acceptanceDate: string; + granted: boolean; +} diff --git a/src/app/core/submission/models/workspaceitem-section-recycle.model.ts b/src/app/core/submission/models/workspaceitem-section-recycle.model.ts new file mode 100644 index 0000000000..760114e73a --- /dev/null +++ b/src/app/core/submission/models/workspaceitem-section-recycle.model.ts @@ -0,0 +1,8 @@ +import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model'; +import { WorkspaceitemSectionUploadFileObject } from './workspaceitem-section-upload-file.model'; + +export interface WorkspaceitemSectionRecycleObject { + unexpected: any; + metadata: FormFieldMetadataValueObject[]; + files: WorkspaceitemSectionUploadFileObject[]; +} diff --git a/src/app/core/submission/models/workspaceitem-section-upload-file.model.ts b/src/app/core/submission/models/workspaceitem-section-upload-file.model.ts new file mode 100644 index 0000000000..a42a334b86 --- /dev/null +++ b/src/app/core/submission/models/workspaceitem-section-upload-file.model.ts @@ -0,0 +1,15 @@ +import { SubmissionUploadFileAccessConditionObject } from './submission-upload-file-access-condition.model'; +import { WorkspaceitemSectionFormObject } from './workspaceitem-section-form.model'; + +export class WorkspaceitemSectionUploadFileObject { + uuid: string; + metadata: WorkspaceitemSectionFormObject; + sizeBytes: number; + checkSum: { + checkSumAlgorithm: string; + value: string; + }; + url: string; + thumbnail: string; + accessConditions: SubmissionUploadFileAccessConditionObject[]; +} diff --git a/src/app/core/submission/models/workspaceitem-section-upload.model.ts b/src/app/core/submission/models/workspaceitem-section-upload.model.ts new file mode 100644 index 0000000000..b936b5d4d8 --- /dev/null +++ b/src/app/core/submission/models/workspaceitem-section-upload.model.ts @@ -0,0 +1,5 @@ +import { WorkspaceitemSectionUploadFileObject } from './workspaceitem-section-upload-file.model'; + +export interface WorkspaceitemSectionUploadObject { + files: WorkspaceitemSectionUploadFileObject[]; +} diff --git a/src/app/core/submission/models/workspaceitem-sections.model.ts b/src/app/core/submission/models/workspaceitem-sections.model.ts new file mode 100644 index 0000000000..3de9f9588e --- /dev/null +++ b/src/app/core/submission/models/workspaceitem-sections.model.ts @@ -0,0 +1,65 @@ +import { WorkspaceitemSectionFormObject } from './workspaceitem-section-form.model'; +import { WorkspaceitemSectionLicenseObject } from './workspaceitem-section-license.model'; +import { WorkspaceitemSectionUploadObject } from './workspaceitem-section-upload.model'; +import { isNotEmpty, isNotNull } from '../../../shared/empty.util'; +import { FormFieldLanguageValueObject } from '../../../shared/form/builder/models/form-field-language-value.model'; +import { WorkspaceitemSectionRecycleObject } from './workspaceitem-section-recycle.model'; +import { WorkspaceitemSectionDeduplicationObject } from './workspaceitem-section-deduplication.model'; +import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model'; + +export class WorkspaceitemSectionsObject { + [name: string]: WorkspaceitemSectionDataType; + +} + +export function isServerFormValue(obj: any): boolean { + return (typeof obj === 'object' + && obj.hasOwnProperty('value') + && obj.hasOwnProperty('language') + && obj.hasOwnProperty('authority') + && obj.hasOwnProperty('confidence') + && obj.hasOwnProperty('place')) +} + +export function normalizeSectionData(obj: any) { + let result: any = obj; + if (isNotNull(obj)) { + // If is an Instance of FormFieldMetadataValueObject normalize it + if (typeof obj === 'object' && isServerFormValue(obj)) { + // If authority property is set normalize as a FormFieldMetadataValueObject object + /* NOTE: Data received from server could have authority property equal to null, but into form + field's model is required a FormFieldMetadataValueObject object as field value, so double-check in + field's parser and eventually instantiate it */ + // if (isNotEmpty(obj.authority)) { + // result = new FormFieldMetadataValueObject(obj.value, obj.language, obj.authority, (obj.display || obj.value), obj.place, obj.confidence); + // } else if (isNotEmpty(obj.language)) { + // const languageValue = new FormFieldLanguageValueObject(obj.value, obj.language); + // result = languageValue; + // } else { + // // Normalize as a string value + // result = obj.value; + // } + result = new FormFieldMetadataValueObject(obj.value, obj.language, obj.authority, (obj.display || obj.value), obj.place, obj.confidence); + } else if (Array.isArray(obj)) { + result = []; + obj.forEach((item, index) => { + result[index] = normalizeSectionData(item); + }); + } else if (typeof obj === 'object') { + result = Object.create({}); + Object.keys(obj) + .forEach((key) => { + result[key] = normalizeSectionData(obj[key]); + }); + } + } + return result; +} + +export type WorkspaceitemSectionDataType + = WorkspaceitemSectionUploadObject + | WorkspaceitemSectionFormObject + | WorkspaceitemSectionLicenseObject + | WorkspaceitemSectionRecycleObject + | WorkspaceitemSectionDeduplicationObject + | string; diff --git a/src/app/core/submission/models/workspaceitem.model.ts b/src/app/core/submission/models/workspaceitem.model.ts new file mode 100644 index 0000000000..e927431d71 --- /dev/null +++ b/src/app/core/submission/models/workspaceitem.model.ts @@ -0,0 +1,5 @@ +import { SubmissionObject } from './submission-object.model'; + +export class Workspaceitem extends SubmissionObject { + +} diff --git a/src/app/core/submission/normalized-submission-object-factory.ts b/src/app/core/submission/normalized-submission-object-factory.ts new file mode 100644 index 0000000000..724d90f19d --- /dev/null +++ b/src/app/core/submission/normalized-submission-object-factory.ts @@ -0,0 +1,69 @@ +import { SubmissionDefinitionsModel } from '../shared/config/config-submission-definitions.model'; +import { SubmissionFormsModel } from '../shared/config/config-submission-forms.model'; +import { SubmissionSectionModel } from '../shared/config/config-submission-section.model'; +import { GenericConstructor } from '../shared/generic-constructor'; +import { NormalizedBitstream } from '../cache/models/normalized-bitstream.model'; +import { NormalizedBundle } from '../cache/models/normalized-bundle.model'; +import { NormalizedCollection } from '../cache/models/normalized-collection.model'; +import { NormalizedCommunity } from '../cache/models/normalized-community.model'; +import { NormalizedItem } from '../cache/models/normalized-item.model'; +import { NormalizedLicense } from '../cache/models/normalized-license.model'; +import { NormalizedWorkspaceItem } from './models/normalized-workspaceitem.model'; +import { NormalizedObject } from '../cache/models/normalized-object.model'; +import { ConfigObject } from '../shared/config/config.model'; +import { SubmissionResourceType } from './submission-resource-type'; +import { NormalizedResourcePolicy } from '../cache/models/normalized-resource-policy.model'; +import { NormalizedWorkflowItem } from './models/normalized-workflowitem.model'; +import { NormalizedEditItem } from './models/normalized-edititem.model'; + +export class NormalizedSubmissionObjectFactory { + public static getConstructor(type: SubmissionResourceType): GenericConstructor { + switch (type) { + case SubmissionResourceType.Bitstream: { + return NormalizedBitstream + } + case SubmissionResourceType.Bundle: { + return NormalizedBundle + } + case SubmissionResourceType.Item: { + return NormalizedItem + } + case SubmissionResourceType.Collection: { + return NormalizedCollection + } + case SubmissionResourceType.Community: { + return NormalizedCommunity + } + case SubmissionResourceType.ResourcePolicy: { + return NormalizedResourcePolicy + } + case SubmissionResourceType.License: { + return NormalizedLicense + } + case SubmissionResourceType.WorkspaceItem: { + return NormalizedWorkspaceItem + } + case SubmissionResourceType.WorkflowItem: { + return NormalizedWorkflowItem + } + case SubmissionResourceType.EditItem: { + return NormalizedEditItem + } + case SubmissionResourceType.SubmissionDefinition: + case SubmissionResourceType.SubmissionDefinitions: { + return SubmissionDefinitionsModel + } + case SubmissionResourceType.SubmissionForm: + case SubmissionResourceType.SubmissionForms: { + return SubmissionFormsModel + } + case SubmissionResourceType.SubmissionSection: + case SubmissionResourceType.SubmissionSections: { + return SubmissionSectionModel + } + default: { + return undefined; + } + } + } +} diff --git a/src/app/core/submission/submission-resource-type.ts b/src/app/core/submission/submission-resource-type.ts new file mode 100644 index 0000000000..0f43968190 --- /dev/null +++ b/src/app/core/submission/submission-resource-type.ts @@ -0,0 +1,24 @@ +/** + * TODO replace with actual string enum after upgrade to TypeScript 2.4: + * https://github.com/Microsoft/TypeScript/pull/15486 + */ +export enum SubmissionResourceType { + Bundle = 'bundle', + Bitstream = 'bitstream', + BitstreamFormat = 'bitstreamformat', + Item = 'item', + Collection = 'collection', + Community = 'community', + ResourcePolicy = 'resourcePolicies', + License = 'license', + WorkspaceItem = 'workspaceitem', + WorkflowItem = 'workflowitem', + EditItem = 'edititem', + SubmissionDefinitions = 'submissiondefinitions', + SubmissionDefinition = 'submissiondefinition', + SubmissionForm = 'submissionform', + SubmissionForms = 'submissionforms', + SubmissionSections = 'submissionsections', + SubmissionSection = 'submissionsection', + Authority = 'authority' +} diff --git a/src/app/core/submission/submission-response-parsing.service.ts b/src/app/core/submission/submission-response-parsing.service.ts new file mode 100644 index 0000000000..f1879cdeb0 --- /dev/null +++ b/src/app/core/submission/submission-response-parsing.service.ts @@ -0,0 +1,99 @@ +import { Inject, Injectable } from '@angular/core'; + +import { ResponseParsingService } from '../data/parsing.service'; +import { RestRequest } from '../data/request.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { ErrorResponse, RestResponse, SubmissionSuccessResponse } from '../cache/response-cache.models'; +import { isEmpty, isNotEmpty, isNotNull } from '../../shared/empty.util'; + +import { ConfigObject } from '../shared/config/config.model'; +import { BaseResponseParsingService, ProcessRequestDTO } from '../data/base-response-parsing.service'; +import { GLOBAL_CONFIG } from '../../../config'; +import { GlobalConfig } from '../../../config/global-config.interface'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { NormalizedSubmissionObjectFactory } from './normalized-submission-object-factory'; +import { NormalizedObject } from '../cache/models/normalized-object.model'; +import { SubmissionResourceType } from './submission-resource-type'; +import { NormalizedWorkspaceItem } from './models/normalized-workspaceitem.model'; +import { normalizeSectionData } from './models/workspaceitem-sections.model'; +import { NormalizedWorkflowItem } from './models/normalized-workflowitem.model'; +import { NormalizedEditItem } from './models/normalized-edititem.model'; + +@Injectable() +export class SubmissionResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { + + protected objectFactory = NormalizedSubmissionObjectFactory; + protected toCache = false; + + constructor(@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + protected objectCache: ObjectCacheService,) { + super(); + } + + parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { + if (isNotEmpty(data.payload) + && isNotEmpty(data.payload._links) + && (data.statusCode === '201' || data.statusCode === '200')) { + const dataDefinition = this.processResponse(data.payload, request.href); + return new SubmissionSuccessResponse(dataDefinition[Object.keys(dataDefinition)[0]], data.statusCode, this.processPageInfo(data.payload)); + } else if (isEmpty(data.payload) && data.statusCode === '204') { + // Response from a DELETE request + return new SubmissionSuccessResponse(null, data.statusCode); + } else { + return new ErrorResponse( + Object.assign( + new Error('Unexpected response from server'), + {statusText: data.statusCode} + ) + ); + } + } + + protected processResponse(data: any, requestHref: string): ProcessRequestDTO { + const dataDefinition = this.process(data, requestHref); + const normalizedDefinition = Object.create({}); + normalizedDefinition[Object.keys(dataDefinition)[0]] = []; + dataDefinition[Object.keys(dataDefinition)[0]].forEach((item, index) => { + let normalizedItem = Object.assign({}, item); + // In case data is an Instance of NormalizedWorkspaceItem normalize field value of all the section of type form + if (item instanceof NormalizedWorkspaceItem + || item instanceof NormalizedWorkflowItem + || item instanceof NormalizedEditItem) { + if (item.sections) { + const precessedSection = Object.create({}); + // Iterate over all workspaceitem's sections + Object.keys(item.sections) + .forEach((sectionId) => { + if (typeof item.sections[sectionId] === 'object' && isNotEmpty(item.sections[sectionId])) { + const normalizedSectionData = Object.create({}); + // Iterate over all sections property + Object.keys(item.sections[sectionId]) + .forEach((metdadataId) => { + const entry = item.sections[sectionId][metdadataId]; + // If entry is not an array, for sure is not a section of type form + if (isNotNull(entry) && Array.isArray(entry)) { + normalizedSectionData[metdadataId] = []; + entry.forEach((valueItem) => { + // Parse value and normalize it + const normValue = normalizeSectionData(valueItem); + if (isNotEmpty(normValue)) { + normalizedSectionData[metdadataId].push(normValue); + } + }); + } else { + normalizedSectionData[metdadataId] = entry; + } + }); + precessedSection[sectionId] = normalizedSectionData; + } + }); + normalizedItem = Object.assign({}, item, {sections: precessedSection}); + } + } + normalizedDefinition[Object.keys(dataDefinition)[0]][index] = normalizedItem; + }); + + return normalizedDefinition as ProcessRequestDTO; + } + +} diff --git a/src/app/core/submission/submission-scope-type.ts b/src/app/core/submission/submission-scope-type.ts new file mode 100644 index 0000000000..80d57c853f --- /dev/null +++ b/src/app/core/submission/submission-scope-type.ts @@ -0,0 +1,5 @@ +export enum SubmissionScopeType { + WorkspaceItem = 'WORKSPACE', + WorkflowItem = 'WORKFLOW', + EditItem = 'ITEM', +} diff --git a/src/app/core/submission/workflowitem-data.service.ts b/src/app/core/submission/workflowitem-data.service.ts new file mode 100644 index 0000000000..75abc93d6b --- /dev/null +++ b/src/app/core/submission/workflowitem-data.service.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@angular/core'; + +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs/Observable'; +import { BrowseService } from '../browse/browse.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ResponseCacheService } from '../cache/response-cache.service'; +import { CoreState } from '../core.reducers'; + +import { DataService } from '../data/data.service'; +import { RequestService } from '../data/request.service'; +import { NormalizedWorkflowItem } from './models/normalized-workflowitem.model'; +import { Workflowitem } from './models/workflowitem.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; + +@Injectable() +export class WorkflowitemDataService extends DataService { + protected linkPath = 'workflowitems'; + protected forceBypassCache = true; + + constructor( + protected responseCache: ResponseCacheService, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected bs: BrowseService, + protected halService: HALEndpointService) { + super(); + } + + public getScopedEndpoint(scopeID: string): Observable { + return this.halService.getEndpoint(this.linkPath); + } + +} diff --git a/src/app/core/submission/workspaceitem-data.service.ts b/src/app/core/submission/workspaceitem-data.service.ts new file mode 100644 index 0000000000..fb052ef49a --- /dev/null +++ b/src/app/core/submission/workspaceitem-data.service.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@angular/core'; + +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs/Observable'; +import { BrowseService } from '../browse/browse.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ResponseCacheService } from '../cache/response-cache.service'; +import { CoreState } from '../core.reducers'; + +import { DataService } from '../data/data.service'; +import { RequestService } from '../data/request.service'; +import { Workspaceitem } from './models/workspaceitem.model'; +import { NormalizedWorkspaceItem } from './models/normalized-workspaceitem.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; + +@Injectable() +export class WorkspaceitemDataService extends DataService { + protected linkPath = 'workspaceitems'; + protected forceBypassCache = true; + + constructor( + protected responseCache: ResponseCacheService, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected bs: BrowseService, + protected halService: HALEndpointService) { + super(); + } + + public getScopedEndpoint(scopeID: string): Observable { + return this.halService.getEndpoint(this.linkPath); + } + +} diff --git a/src/app/shared/alerts/alerts.component.html b/src/app/shared/alerts/alerts.component.html new file mode 100644 index 0000000000..ad2cd82bfd --- /dev/null +++ b/src/app/shared/alerts/alerts.component.html @@ -0,0 +1,9 @@ + diff --git a/src/app/shared/alerts/alerts.component.scss b/src/app/shared/alerts/alerts.component.scss new file mode 100644 index 0000000000..1a70081367 --- /dev/null +++ b/src/app/shared/alerts/alerts.component.scss @@ -0,0 +1,3 @@ +.close:focus { + outline: none !important; +} diff --git a/src/app/shared/alerts/alerts.component.ts b/src/app/shared/alerts/alerts.component.ts new file mode 100644 index 0000000000..c9fc0ec9cc --- /dev/null +++ b/src/app/shared/alerts/alerts.component.ts @@ -0,0 +1,44 @@ +import { ChangeDetectorRef, Component, EventEmitter, Input, Output, ViewEncapsulation } from '@angular/core'; +import { trigger } from '@angular/animations'; + +import { AlertType } from './aletrs-type'; +import { fadeOutLeave, fadeOutState } from '../animations/fade'; + +@Component({ + selector: 'ds-alert', + encapsulation: ViewEncapsulation.None, + animations: [ + trigger('enterLeave', [ + fadeOutLeave, fadeOutState, + ]) + ], + templateUrl: './alerts.component.html', + styleUrls: ['./alerts.component.scss'] +}) + +export class AlertsComponent { + + @Input() content: string; + @Input() dismissible = false; + @Input() type: AlertType; + @Output() close: EventEmitter = new EventEmitter(); + + public animate = 'fadeIn'; + public dismissed = false; + + constructor(private cdr: ChangeDetectorRef) { + } + + dismiss() { + if (this.dismissible) { + this.animate = 'fadeOut'; + this.cdr.detectChanges(); + setTimeout(() => { + this.dismissed = true; + this.close.emit(); + this.cdr.detectChanges(); + }, 300); + + } + } +} diff --git a/src/app/shared/alerts/aletrs-type.ts b/src/app/shared/alerts/aletrs-type.ts new file mode 100644 index 0000000000..aacfb451f9 --- /dev/null +++ b/src/app/shared/alerts/aletrs-type.ts @@ -0,0 +1,6 @@ +export enum AlertType { + Success = 'alert-success', + Error = 'alert-danger', + Info = 'alert-info', + Warning = 'alert-warning' +} diff --git a/src/app/shared/empty.util.ts b/src/app/shared/empty.util.ts index c1498d11af..e479989393 100644 --- a/src/app/shared/empty.util.ts +++ b/src/app/shared/empty.util.ts @@ -93,6 +93,51 @@ export const hasValueOperator = () => (source: Observable): Observable => source.pipe(filter((obj: T) => hasValue(obj))); +/** + * Returns true if the passed value is null or undefined. + * hasUndefinedValue(); // false + * hasUndefinedValue(null); // false + * hasUndefinedValue(undefined); // false + * hasUndefinedValue(''); // true + * hasUndefinedValue({undefined, obj}); // true + * hasUndefinedValue([undefined, val]); // true + */ +export function hasUndefinedValue(obj?: any): boolean { + let result = false; + + if (isUndefined(obj) || isNull(obj)) { + return false; + } + + const objectType = typeof obj; + + if (objectType === 'object') { + if (Object.keys(obj).length === 0) { + return false; + } + Object.entries(obj).forEach(([key, value]) => { + if (isUndefined(value)) { + result = true + } + }) + } + + return result; +} + +/** + * Returns true if the passed value is null or undefined. + * hasUndefinedValue(); // true + * hasUndefinedValue(null); // true + * hasUndefinedValue(undefined); // true + * hasUndefinedValue(''); // false + * hasUndefinedValue({undefined, obj}); // false + * hasUndefinedValue([undefined, val]); // false + */ +export function hasNoUndefinedValue(obj?: any): boolean { + return !hasUndefinedValue(obj); +} + /** * Verifies that a value is `null` or an empty string, empty array, * or empty function. diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-group/dynamic-group.components.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-group/dynamic-group.components.ts index a55e7aff9d..a954f58e99 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-group/dynamic-group.components.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-group/dynamic-group.components.ts @@ -89,7 +89,8 @@ export class DsDynamicGroupComponent implements OnDestroy, OnInit { this.chips = new Chips( initChipsValue, 'value', - this.model.mandatoryField); + this.model.mandatoryField, + this.EnvConfig.submission.metadata.icons); this.subs.push( this.chips.chipsItems .subscribe((subItems: any[]) => { diff --git a/src/app/shared/form/builder/parsers/row-parser.ts b/src/app/shared/form/builder/parsers/row-parser.ts index f315be451e..577845fab8 100644 --- a/src/app/shared/form/builder/parsers/row-parser.ts +++ b/src/app/shared/form/builder/parsers/row-parser.ts @@ -10,6 +10,7 @@ import { FormFieldModel } from '../models/form-field.model'; import { ParserType } from './parser-type'; import { ParserOptions } from './parser-options'; import { ParserFactory } from './parser-factory'; +import { TranslateService } from '@ngx-translate/core'; export const ROW_ID_PREFIX = 'df-row-group-config-'; @@ -49,7 +50,7 @@ export class RowParser { if (parserCo) { fieldModel = new parserCo(fieldData, this.initFormValues, parserOptions).parse(); } else { - throw new Error(`unknown form control model type defined with label "${fieldData.label}"`); + throw new Error(`unknown form control model type "${fieldData.input.type}" defined for Input field with label "${fieldData.label}".`, ); } if (fieldModel) { diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 57ba7dec4d..9c23440dbf 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -55,8 +55,6 @@ import { DsDynamicFormComponent } from './form/builder/ds-dynamic-form-ui/ds-dyn import { DynamicFormsCoreModule } from '@ng-dynamic-forms/core'; import { DynamicFormsNGBootstrapUIModule } from '@ng-dynamic-forms/ui-ng-bootstrap'; import { TextMaskModule } from 'angular2-text-mask'; -import { NotificationComponent } from './notifications/notification/notification.component'; -import { NotificationsBoardComponent } from './notifications/notifications-board/notifications-board.component'; import { DragClickDirective } from './utils/drag-click.directive'; import { TruncatePipe } from './utils/truncate.pipe'; import { TruncatableComponent } from './truncatable/truncatable.component'; @@ -72,6 +70,7 @@ import { NumberPickerComponent } from './number-picker/number-picker.component'; import { DsDatePickerComponent } from './form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component'; import { DsDynamicLookupComponent } from './form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component'; import { MockAdminGuard } from './mocks/mock-admin-guard.service'; +import { AlertsComponent } from './alerts/alerts.component'; const MODULES = [ // Do NOT include UniversalModule, HttpModule, or JsonpModule here @@ -104,6 +103,7 @@ const PIPES = [ const COMPONENTS = [ // put shared components here + AlertsComponent, AuthNavMenuComponent, ChipsComponent, ComcolPageContentComponent, diff --git a/src/app/shared/truncatable/truncatable-part/truncatable-part.component.ts b/src/app/shared/truncatable/truncatable-part/truncatable-part.component.ts index 0f695625ec..61fc031b56 100644 --- a/src/app/shared/truncatable/truncatable-part/truncatable-part.component.ts +++ b/src/app/shared/truncatable/truncatable-part/truncatable-part.component.ts @@ -1,5 +1,6 @@ import { Component, Input, OnDestroy, OnInit } from '@angular/core'; import { TruncatableService } from '../truncatable.service'; +import { hasValue } from '../../empty.util'; @Component({ selector: 'ds-truncatable-part', @@ -34,6 +35,8 @@ export class TruncatablePartComponent implements OnInit, OnDestroy { } ngOnDestroy(): void { - this.sub.unsubscribe(); + if (hasValue(this.sub)) { + this.sub.unsubscribe(); + } } } diff --git a/src/app/shared/uploader/uploader.component.scss b/src/app/shared/uploader/uploader.component.scss index 7e0f6fdd23..dd80a736ca 100644 --- a/src/app/shared/uploader/uploader.component.scss +++ b/src/app/shared/uploader/uploader.component.scss @@ -10,7 +10,7 @@ } .ds-base-drop-zone p { - height: 42px; + min-height: 42px; } .ds-document-drop-zone { diff --git a/src/app/submission/edit/submission-edit.component.html b/src/app/submission/edit/submission-edit.component.html new file mode 100644 index 0000000000..21b20997cf --- /dev/null +++ b/src/app/submission/edit/submission-edit.component.html @@ -0,0 +1,7 @@ +
+ +
diff --git a/src/app/submission/edit/submission-edit.component.scss b/src/app/submission/edit/submission-edit.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/submission/edit/submission-edit.component.ts b/src/app/submission/edit/submission-edit.component.ts new file mode 100644 index 0000000000..c4a9f52d33 --- /dev/null +++ b/src/app/submission/edit/submission-edit.component.ts @@ -0,0 +1,74 @@ +import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; +import { ActivatedRoute, ParamMap, Router } from '@angular/router'; + +import { Subscription } from 'rxjs/Subscription'; + +import { WorkspaceitemSectionsObject } from '../../core/submission/models/workspaceitem-sections.model'; +import { hasValue, isEmpty, isNotNull } from '../../shared/empty.util'; +import { SubmissionDefinitionsModel } from '../../core/shared/config/config-submission-definitions.model'; +import { SubmissionService } from '../submission.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { SubmissionObject } from '../../core/submission/models/submission-object.model'; + +@Component({ + selector: 'ds-submission-edit', + styleUrls: ['./submission-edit.component.scss'], + templateUrl: './submission-edit.component.html' +}) + +export class SubmissionEditComponent implements OnDestroy, OnInit { + public collectionId: string; + public sections: WorkspaceitemSectionsObject; + public selfUrl: string; + public submissionDefinition: SubmissionDefinitionsModel; + public submissionId: string; + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + private subs: Subscription[] = []; + + constructor(private changeDetectorRef: ChangeDetectorRef, + private notificationsService: NotificationsService, + private route: ActivatedRoute, + private router: Router, + private submissionService: SubmissionService, + private translate: TranslateService) { + } + + ngOnInit() { + this.subs.push(this.route.paramMap + .subscribe((params: ParamMap) => { + this.submissionId = params.get('id'); + this.subs.push( + this.submissionService.retrieveSubmission(this.submissionId) + .subscribe((submissionObject: SubmissionObject) => { + // NOTE new submission is retrieved on the browser side only + if (isNotNull(submissionObject)) { + if (isEmpty(submissionObject)) { + this.notificationsService.info(null, this.translate.get('submission.general.cannot_submit')); + this.router.navigate(['/mydspace']); + } else { + this.collectionId = submissionObject.collection[0].id; + this.selfUrl = submissionObject.self; + this.sections = submissionObject.sections; + this.submissionDefinition = submissionObject.submissionDefinition[0]; + this.changeDetectorRef.detectChanges(); + } + } + }) + ) + })); + } + + /** + * Method provided by Angular. Invoked when the instance is destroyed. + */ + ngOnDestroy() { + this.subs + .filter((sub) => hasValue(sub)) + .forEach((sub) => sub.unsubscribe()); + } +} diff --git a/src/app/submission/form/collection/submission-form-collection.component.html b/src/app/submission/form/collection/submission-form-collection.component.html new file mode 100644 index 0000000000..ebfd81233e --- /dev/null +++ b/src/app/submission/form/collection/submission-form-collection.component.html @@ -0,0 +1,37 @@ +
+
+
+ Collection +
+ + + +
+
diff --git a/src/app/submission/form/collection/submission-form-collection.component.scss b/src/app/submission/form/collection/submission-form-collection.component.scss new file mode 100644 index 0000000000..b72d04d3c9 --- /dev/null +++ b/src/app/submission/form/collection/submission-form-collection.component.scss @@ -0,0 +1,17 @@ +@import '../../../../styles/variables'; + +.scrollable-menu { + height: auto; + max-height: 200px; + overflow-x: hidden; +} + +.collection-item { + border-bottom: $dropdown-border-width solid $dropdown-border-color; +} + +#collectionControlsDropdownMenu { + outline: 0; + left: 0 !important; + box-shadow: $btn-focus-box-shadow; +} diff --git a/src/app/submission/form/collection/submission-form-collection.component.ts b/src/app/submission/form/collection/submission-form-collection.component.ts new file mode 100644 index 0000000000..efcebeebd8 --- /dev/null +++ b/src/app/submission/form/collection/submission-form-collection.component.ts @@ -0,0 +1,168 @@ +import { + ChangeDetectorRef, + Component, + EventEmitter, + HostListener, + Input, + OnChanges, + OnInit, + Output, + SimpleChanges +} from '@angular/core'; +import { FormControl } from '@angular/forms'; + +import { Store } from '@ngrx/store'; +import { Subscription } from 'rxjs/Subscription'; + +import { isNullOrUndefined } from 'util'; +import { Collection } from '../../../core/shared/collection.model'; +import { CommunityDataService } from '../../../core/data/community-data.service'; +import { Community } from '../../../core/shared/community.model'; +import { hasValue, isNotEmpty } from '../../../shared/empty.util'; +import { RemoteData } from '../../../core/data/remote-data'; +import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; +import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; +import { Workspaceitem } from '../../../core/submission/models/workspaceitem.model'; +import { PaginatedList } from '../../../core/data/paginated-list'; +import { JsonPatchOperationsService } from '../../../core/json-patch/json-patch-operations.service'; +import { SubmitDataResponseDefinitionObject } from '../../../core/shared/submit-data-response-definition.model'; +import { SubmissionService } from '../../submission.service'; +import { SubmissionState } from '../../submission.reducers'; +import { ChangeSubmissionCollectionAction } from '../../objects/submission-objects.actions'; +import { SubmissionObject } from '../../../core/submission/models/submission-object.model'; + +@Component({ + selector: 'ds-submission-form-collection', + styleUrls: ['./submission-form-collection.component.scss'], + templateUrl: './submission-form-collection.component.html' +}) +export class SubmissionFormCollectionComponent implements OnChanges, OnInit { + @Input() currentCollectionId: string; + @Input() currentDefinition: string; + @Input() submissionId; + + /** + * An event fired when a different collection is selected. + * Event's payload equals to new collection uuid. + */ + @Output() collectionChange: EventEmitter = new EventEmitter(); + + public disabled = true; + public listCollection = []; + public model: any; + public searchField: FormControl; + public searchListCollection = []; + public selectedCollectionId: string; + public selectedCollectionName: string; + + protected pathCombiner: JsonPatchOperationPathCombiner; + private scrollableBottom = false; + private scrollableTop = false; + private subs: Subscription[] = []; + + formatter = (x: { collection: string }) => x.collection; + + constructor(protected cdr: ChangeDetectorRef, + private communityDataService: CommunityDataService, + private operationsBuilder: JsonPatchOperationsBuilder, + private operationsService: JsonPatchOperationsService, + private store: Store, + private submissionService: SubmissionService) { + } + + @HostListener('mousewheel', ['$event']) onMousewheel(event) { + if (event.wheelDelta > 0 && this.scrollableTop) { + event.preventDefault(); + } + if (event.wheelDelta < 0 && this.scrollableBottom) { + event.preventDefault(); + } + } + + onScroll(event) { + this.scrollableBottom = (event.target.scrollTop + event.target.clientHeight === event.target.scrollHeight); + this.scrollableTop = (event.target.scrollTop === 0); + } + + ngOnChanges(changes: SimpleChanges) { + if (hasValue(changes.currentCollectionId) + && hasValue(changes.currentCollectionId.currentValue) + && !isNotEmpty(this.listCollection)) { + this.selectedCollectionId = this.currentCollectionId; + // @TODO replace with search/top browse endpoint + // @TODO implement community/subcommunity hierarchy + this.subs.push(this.communityDataService.findAll() + .filter((communities: RemoteData>) => isNotEmpty(communities.payload)) + .first() + .switchMap((communities: RemoteData>) => communities.payload.page) + .subscribe((communityData: Community) => { + + this.subs.push(communityData.collections + .filter((collections: RemoteData) => !collections.isResponsePending && collections.hasSucceeded) + .first() + .switchMap((collections: RemoteData) => collections.payload) + .filter((collectionData: Collection) => isNotEmpty(collectionData)) + .subscribe((collectionData: Collection) => { + if (collectionData.id === this.selectedCollectionId) { + this.selectedCollectionName = collectionData.name; + } + const collectionEntry = { + communities: [{id: communityData.id, name: communityData.name}], + collection: {id: collectionData.id, name: collectionData.name} + }; + this.listCollection.push(collectionEntry); + this.searchListCollection.push(collectionEntry); + this.disabled = false; + this.cdr.detectChanges(); + })) + })); + } + } + + ngOnInit() { + this.pathCombiner = new JsonPatchOperationPathCombiner('sections', 'collection'); + this.searchField = new FormControl(); + this.searchField.valueChanges + .debounceTime(200) + .distinctUntilChanged() + .subscribe((term) => { + this.search(term); + }); + } + + ngOnDestroy(): void { + this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); + } + + search(text: string) { + if (text === '' || isNullOrUndefined(text)) { + this.searchListCollection = this.listCollection; + } else { + this.searchListCollection = this.listCollection.filter((v) => v.collection.name.toLowerCase().indexOf(text.toLowerCase()) > -1).slice(0, 5); + } + } + + onSelect(event) { + this.searchField.reset(); + this.searchListCollection = this.listCollection; + this.disabled = true; + this.operationsBuilder.replace(this.pathCombiner.getPath(), event.collection.id, true); + this.operationsService.jsonPatchByResourceID( + this.submissionService.getSubmissionObjectLinkName(), + this.submissionId, + 'sections', + 'collection') + .subscribe((submissionObject: SubmissionObject[]) => { + this.selectedCollectionId = event.collection.id; + this.selectedCollectionName = event.collection.name; + this.collectionChange.emit(submissionObject[0]); + this.store.dispatch(new ChangeSubmissionCollectionAction(this.submissionId, event.collection.id)); + this.disabled = false; + this.cdr.detectChanges(); + }) + } + + onClose(event) { + this.searchField.reset(); + } +} diff --git a/src/app/submission/form/footer/submission-form-footer.component.html b/src/app/submission/form/footer/submission-form-footer.component.html new file mode 100644 index 0000000000..58017b320b --- /dev/null +++ b/src/app/submission/form/footer/submission-form-footer.component.html @@ -0,0 +1,52 @@ +
+
+ +
+
+ +
+
+
+
Saving...
+
Depositing...
+
+
+
+ + + +
+
+ + + + + + diff --git a/src/app/submission/form/footer/submission-form-footer.component.scss b/src/app/submission/form/footer/submission-form-footer.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/submission/form/footer/submission-form-footer.component.ts b/src/app/submission/form/footer/submission-form-footer.component.ts new file mode 100644 index 0000000000..86d4a502c5 --- /dev/null +++ b/src/app/submission/form/footer/submission-form-footer.component.ts @@ -0,0 +1,70 @@ +import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { SubmissionRestService } from '../../submission-rest.service'; +import { SubmissionService } from '../../submission.service'; +import { SubmissionState } from '../../submission.reducers'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { + DepositSubmissionAction, DiscardSubmissionAction, + SaveAndDepositSubmissionAction, + SaveForLaterSubmissionFormAction, + SaveSubmissionFormAction +} from '../../objects/submission-objects.actions'; +import { Observable } from 'rxjs/Observable'; +import { SubmissionScopeType } from '../../../core/submission/submission-scope-type'; + +@Component({ + selector: 'ds-submission-form-footer', + styleUrls: ['./submission-form-footer.component.scss'], + templateUrl: './submission-form-footer.component.html' +}) +export class SubmissionFormFooterComponent implements OnChanges { + + @Input() submissionId; + + public processingDepositStatus: Observable; + public processingSaveStatus: Observable; + public showDepositAndDiscard: Observable; + private submissionIsInvalid = true; + + constructor(private modalService: NgbModal, + private restService: SubmissionRestService, + private submissionService: SubmissionService, + private store: Store) { + } + + ngOnChanges(changes: SimpleChanges) { + if (!!this.submissionId) { + this.submissionService.getSectionsState(this.submissionId) + .subscribe((isValid) => { + this.submissionIsInvalid = isValid === false; + }); + + this.processingSaveStatus = this.submissionService.getSubmissionSaveProcessingStatus(this.submissionId); + this.processingDepositStatus = this.submissionService.getSubmissionDepositProcessingStatus(this.submissionId); + this.showDepositAndDiscard = Observable.of(this.submissionService.getSubmissionScope() === SubmissionScopeType.WorkspaceItem); + } + } + + save(event) { + this.store.dispatch(new SaveSubmissionFormAction(this.submissionId)); + } + + saveLater(event) { + this.store.dispatch(new SaveForLaterSubmissionFormAction(this.submissionId)); + } + + public deposit(event) { + this.store.dispatch(new SaveAndDepositSubmissionAction(this.submissionId)); + } + + public confirmDiscard(content) { + this.modalService.open(content).result.then( + (result) => { + if (result === 'ok') { + this.store.dispatch(new DiscardSubmissionAction(this.submissionId)); + } + } + ); + } +} diff --git a/src/app/submission/form/section-add/submission-form-section-add.component.html b/src/app/submission/form/section-add/submission-form-section-add.component.html new file mode 100644 index 0000000000..4559abebba --- /dev/null +++ b/src/app/submission/form/section-add/submission-form-section-add.component.html @@ -0,0 +1,14 @@ +
+ +
+ + +
+
diff --git a/src/app/submission/form/section-add/submission-form-section-add.component.scss b/src/app/submission/form/section-add/submission-form-section-add.component.scss new file mode 100644 index 0000000000..628f0f5633 --- /dev/null +++ b/src/app/submission/form/section-add/submission-form-section-add.component.scss @@ -0,0 +1,9 @@ +@import '../../../../styles/variables'; + +.dropdown-toggle::after { + display:none +} + +.sections-dropdown-menu { + z-index: $submission-header-z-index; +} diff --git a/src/app/submission/form/section-add/submission-form-section-add.component.ts b/src/app/submission/form/section-add/submission-form-section-add.component.ts new file mode 100644 index 0000000000..105059dfc1 --- /dev/null +++ b/src/app/submission/form/section-add/submission-form-section-add.component.ts @@ -0,0 +1,33 @@ +import { Component, Input, OnInit, } from '@angular/core'; + +import { Observable } from 'rxjs/Observable'; + +import { SectionsService } from '../../sections/sections.service'; +import { HostWindowService } from '../../../shared/host-window.service'; +import { SubmissionService } from '../../submission.service'; +import { SectionDataObject } from '../../sections/models/section-data.model'; + +@Component({ + selector: 'ds-submission-form-section-add', + styleUrls: [ './submission-form-section-add.component.scss' ], + templateUrl: './submission-form-section-add.component.html' +}) +export class SubmissionFormSectionAddComponent implements OnInit { + @Input() collectionId: string; + @Input() submissionId: string; + + public sectionList: Observable; + + constructor(private sectionService: SectionsService, + private submissionService: SubmissionService, + public windowService: HostWindowService) { + } + + ngOnInit() { + this.sectionList = this.submissionService.getDisabledSectionsList(this.submissionId); + } + + addSection(sectionId) { + this.sectionService.addSection(this.submissionId, sectionId); + } +} diff --git a/src/app/submission/form/submission-form.component.html b/src/app/submission/form/submission-form.component.html new file mode 100644 index 0000000000..b17ca49bd5 --- /dev/null +++ b/src/app/submission/form/submission-form.component.html @@ -0,0 +1,36 @@ +
+
+ +
+
+
+
+ + +
+
+ + +
+
+ +
+ + + + +
+ +
diff --git a/src/app/submission/form/submission-form.component.scss b/src/app/submission/form/submission-form.component.scss new file mode 100644 index 0000000000..c9b6872146 --- /dev/null +++ b/src/app/submission/form/submission-form.component.scss @@ -0,0 +1,21 @@ +@import '../../../styles/variables'; + +.submission-form-header { + background-color: rgba($white, .97); + padding: ($spacer / 2) 0 ($spacer / 2) 0; + top: 0; + z-index: $submission-header-z-index; +} + +.submission-form-header-item { + flex-grow: 1; +} + +.submission-form-footer { + border-radius: $card-border-radius; + bottom: 0; + background-color: $gray-400; + padding: $spacer / 2; + z-index: $submission-footer-z-index; +} + diff --git a/src/app/submission/form/submission-form.component.ts b/src/app/submission/form/submission-form.component.ts new file mode 100644 index 0000000000..0bed9173c1 --- /dev/null +++ b/src/app/submission/form/submission-form.component.ts @@ -0,0 +1,139 @@ +import { ChangeDetectorRef, Component, Input, OnChanges, OnDestroy, SimpleChanges } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { + CancelSubmissionFormAction, + LoadSubmissionFormAction, + ResetSubmissionFormAction +} from '../objects/submission-objects.actions'; +import { hasValue, isNotEmpty, isNotUndefined } from '../../shared/empty.util'; +import { submissionObjectFromIdSelector } from '../selectors'; +import { SubmissionObjectEntry } from '../objects/submission-objects.reducer'; +import { WorkspaceitemSectionsObject } from '../../core/submission/models/workspaceitem-sections.model'; +import { SubmissionDefinitionsModel } from '../../core/shared/config/config-submission-definitions.model'; +import { SubmissionState } from '../submission.reducers'; +import { Workspaceitem } from '../../core/submission/models/workspaceitem.model'; +import { SubmissionService } from '../submission.service'; +import { Subscription } from 'rxjs/Subscription'; +import { AuthService } from '../../core/auth/auth.service'; +import { Observable } from 'rxjs/Observable'; +import { SectionDataObject } from '../sections/models/section-data.model'; +import { UploaderOptions } from '../../shared/uploader/uploader-options.model'; +import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; + +@Component({ + selector: 'ds-submission-submit-form', + styleUrls: ['./submission-form.component.scss'], + templateUrl: './submission-form.component.html', +}) +export class SubmissionFormComponent implements OnChanges, OnDestroy { + @Input() collectionId: string; + @Input() sections: WorkspaceitemSectionsObject; + @Input() selfUrl: string; + @Input() submissionDefinition: SubmissionDefinitionsModel; + @Input() submissionId: string; + + public definitionId: string; + public test = true; + public loading: Observable = Observable.of(true); + public submissionSections: Observable; + public uploadFilesOptions: UploaderOptions = { + url: '', + authToken: null, + disableMultipart: false, + itemAlias: null + }; + + protected isActive: boolean; + protected subs: Subscription[] = []; + + constructor( + private authService: AuthService, + private changeDetectorRef: ChangeDetectorRef, + private halService: HALEndpointService, + private store: Store, + private submissionService: SubmissionService) { + this.isActive = true; + } + + ngOnChanges(changes: SimpleChanges) { + if (this.collectionId && this.submissionId) { + this.isActive = true; + this.submissionSections = this.store.select(submissionObjectFromIdSelector(this.submissionId)) + .filter((submission: SubmissionObjectEntry) => isNotUndefined(submission) && this.isActive) + .map((submission: SubmissionObjectEntry) => submission.isLoading) + .map((isLoading: boolean) => isLoading) + .distinctUntilChanged() + .flatMap((isLoading: boolean) => { + if (!isLoading) { + return this.getSectionsList(); + } else { + return Observable.of([]) + } + }); + + this.loading = this.store.select(submissionObjectFromIdSelector(this.submissionId)) + .filter((submission: SubmissionObjectEntry) => isNotUndefined(submission) && this.isActive) + .map((submission: SubmissionObjectEntry) => submission.isLoading) + .map((isLoading: boolean) => isLoading) + .distinctUntilChanged(); + + this.subs.push( + this.halService.getEndpoint('workspaceitems') + .filter((href: string) => isNotEmpty(href)) + .distinctUntilChanged() + .subscribe((endpointURL) => { + this.uploadFilesOptions.authToken = this.authService.buildAuthHeader(); + this.uploadFilesOptions.url = endpointURL.concat(`/${this.submissionId}`); + this.definitionId = this.submissionDefinition.name; + this.store.dispatch(new LoadSubmissionFormAction(this.collectionId, this.submissionId, this.selfUrl, this.submissionDefinition, this.sections, null)); + this.changeDetectorRef.detectChanges(); + }), + + // this.store.select(submissionObjectFromIdSelector(this.submissionId)) + // .filter((submission: SubmissionObjectEntry) => isNotUndefined(submission) && this.isActive) + // .subscribe((submission: SubmissionObjectEntry) => { + // if (this.loading !== submission.isLoading) { + // this.loading = submission.isLoading; + // this.changeDetectorRef.detectChanges(); + // } + // }) + ); + this.submissionService.startAutoSave(this.submissionId); + } + } + + ngOnDestroy() { + this.isActive = false; + this.submissionService.stopAutoSave(); + this.store.dispatch(new CancelSubmissionFormAction()); + this.subs + .filter((subscription) => hasValue(subscription)) + .forEach((subscription) => subscription.unsubscribe()); + } + + onCollectionChange(workspaceItemObject: Workspaceitem) { + this.collectionId = workspaceItemObject.collection[0].id; + if (this.definitionId !== workspaceItemObject.submissionDefinition[0].name) { + this.sections = workspaceItemObject.sections; + this.submissionDefinition = workspaceItemObject.submissionDefinition[0]; + this.definitionId = this.submissionDefinition.name; + this.store.dispatch(new ResetSubmissionFormAction(this.collectionId, this.submissionId, workspaceItemObject.self, this.sections, this.submissionDefinition)); + // this.submissionSections = this.getSectionsList(); + } else { + this.changeDetectorRef.detectChanges(); + } + } + + isLoading(): Observable { + // return isUndefined(this.loading) || this.loading === true; + return this.loading; + } + + protected getSectionsList(): Observable { + return this.submissionService.getSubmissionSections(this.submissionId) + .filter((sections: SectionDataObject[]) => isNotEmpty(sections)) + .map((sections: SectionDataObject[]) => { + return sections; + }); + } +} diff --git a/src/app/submission/form/submission-upload-files/submission-upload-files.component.html b/src/app/submission/form/submission-upload-files/submission-upload-files.component.html new file mode 100644 index 0000000000..cbd7dd3c9e --- /dev/null +++ b/src/app/submission/form/submission-upload-files/submission-upload-files.component.html @@ -0,0 +1,7 @@ + diff --git a/src/app/submission/form/submission-upload-files/submission-upload-files.component.ts b/src/app/submission/form/submission-upload-files/submission-upload-files.component.ts new file mode 100644 index 0000000000..7944dc05f6 --- /dev/null +++ b/src/app/submission/form/submission-upload-files/submission-upload-files.component.ts @@ -0,0 +1,96 @@ +import { Component, Input, OnChanges } from '@angular/core'; + +import { TranslateService } from '@ngx-translate/core'; +import { Observable } from 'rxjs/Observable'; + +import { SectionsService } from '../../sections/sections.service'; +import { hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util'; +import { Workspaceitem } from '../../../core/submission/models/workspaceitem.model'; +import { normalizeSectionData } from '../../../core/submission/models/workspaceitem-sections.model'; +import { JsonPatchOperationsService } from '../../../core/json-patch/json-patch-operations.service'; +import { SubmitDataResponseDefinitionObject } from '../../../core/shared/submit-data-response-definition.model'; +import { SubmissionService } from '../../submission.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { UploaderOptions } from '../../../shared/uploader/uploader-options.model'; +import parseSectionErrors from '../../utils/parseSectionErrors'; + +@Component({ + selector: 'ds-submission-upload-files', + templateUrl: './submission-upload-files.component.html', +}) +export class SubmissionUploadFilesComponent implements OnChanges { + + @Input() collectionId; + @Input() submissionId; + @Input() sectionId; + @Input() uploadFilesOptions: UploaderOptions; + + public enableDragOverDocument = true; + public dropOverDocumentMsg = 'submission.sections.upload.drop-message'; + public dropMsg = 'submission.sections.upload.drop-message'; + + private subs = []; + private uploadEnabled: Observable = Observable.of(false); + + public onBeforeUpload = () => { + this.operationsService.jsonPatchByResourceType( + this.submissionService.getSubmissionObjectLinkName(), + this.submissionId, + 'sections') + .subscribe(); + }; + + constructor(private notificationsService: NotificationsService, + private operationsService: JsonPatchOperationsService, + private sectionService: SectionsService, + private submissionService: SubmissionService, + private translate: TranslateService) { + } + + ngOnChanges() { + this.uploadEnabled = this.sectionService.isSectionAvailable(this.submissionId, this.sectionId); + } + + public onCompleteItem(workspaceitem: Workspaceitem) { + // Checks if upload section is enabled so do upload + this.subs.push( + this.uploadEnabled + .first() + .subscribe((isUploadEnabled) => { + if (isUploadEnabled) { + + const {sections} = workspaceitem; + const {errors} = workspaceitem; + + const errorsList = parseSectionErrors(errors); + if (sections && isNotEmpty(sections)) { + Object.keys(sections) + .forEach((sectionId) => { + const sectionData = normalizeSectionData(sections[sectionId]); + const sectionErrors = errorsList[sectionId]; + if (sectionId === 'upload') { + // Look for errors on upload + if ((isEmpty(sectionErrors))) { + this.notificationsService.success(null, this.translate.get('submission.sections.upload.upload-successful')); + } else { + this.notificationsService.error(null, this.translate.get('submission.sections.upload.upload-failed')); + } + } + this.sectionService.updateSectionData(this.submissionId, sectionId, sectionData, sectionErrors) + }) + } + + } + }) + ); + } + + /** + * Method provided by Angular. Invoked when the instance is destroyed. + */ + ngOnDestroy() { + this.subs + .filter((subscription) => hasValue(subscription)) + .forEach((subscription) => subscription.unsubscribe()); + } +} diff --git a/src/app/submission/objects/submission-objects.actions.ts b/src/app/submission/objects/submission-objects.actions.ts new file mode 100644 index 0000000000..fba526dece --- /dev/null +++ b/src/app/submission/objects/submission-objects.actions.ts @@ -0,0 +1,1032 @@ +import { Action } from '@ngrx/store'; + +import { type } from '../../shared/ngrx/type'; +import { SectionVisibility, SubmissionSectionError } from './submission-objects.reducer'; +import { WorkspaceitemSectionUploadFileObject } from '../../core/submission/models/workspaceitem-section-upload-file.model'; +import { WorkspaceitemSectionFormObject } from '../../core/submission/models/workspaceitem-section-form.model'; +import { WorkspaceitemSectionLicenseObject } from '../../core/submission/models/workspaceitem-section-license.model'; +import { + WorkspaceitemSectionDataType, + WorkspaceitemSectionsObject +} from '../../core/submission/models/workspaceitem-sections.model'; +import { WorkspaceitemSectionUploadObject } from '../../core/submission/models/workspaceitem-section-upload.model'; +import { SubmissionObject } from '../../core/submission/models/submission-object.model'; +import { SubmissionDefinitionsModel } from '../../core/shared/config/config-submission-definitions.model'; +import { SectionsType } from '../sections/sections-type'; + +/** + * For each action type in an action group, make a simple + * enum object for all of this group's action types. + * + * The 'type' utility function coerces strings into string + * literal types and runs a simple check to guarantee all + * action types in the application are unique. + */ +export const SubmissionObjectActionTypes = { + // Section types + LOAD_SUBMISSION_FORM: type('dspace/submission/LOAD_SUBMISSION_FORM'), + RESET_SUBMISSION_FORM: type('dspace/submission/RESET_SUBMISSION_FORM'), + CANCEL_SUBMISSION_FORM: type('dspace/submission/CANCEL_SUBMISSION_FORM'), + INIT_SUBMISSION_FORM: type('dspace/submission/INIT_SUBMISSION_FORM'), + COMPLETE_INIT_SUBMISSION_FORM: type('dspace/submission/COMPLETE_INIT_SUBMISSION_FORM'), + SAVE_FOR_LATER_SUBMISSION_FORM: type('dspace/submission/SAVE_FOR_LATER_SUBMISSION_FORM'), + SAVE_FOR_LATER_SUBMISSION_FORM_SUCCESS: type('dspace/submission/SAVE_FOR_LATER_SUBMISSION_FORM_SUCCESS'), + SAVE_FOR_LATER_SUBMISSION_FORM_ERROR: type('dspace/submission/SAVE_FOR_LATER_SUBMISSION_FORM_ERROR'), + SAVE_SUBMISSION_FORM: type('dspace/submission/SAVE_SUBMISSION_FORM'), + SAVE_SUBMISSION_FORM_SUCCESS: type('dspace/submission/SAVE_SUBMISSION_FORM_SUCCESS'), + SAVE_SUBMISSION_FORM_ERROR: type('dspace/submission/SAVE_SUBMISSION_FORM_ERROR'), + SAVE_SUBMISSION_SECTION_FORM: type('dspace/submission/SAVE_SUBMISSION_SECTION_FORM'), + SAVE_SUBMISSION_SECTION_FORM_SUCCESS: type('dspace/submission/SAVE_SUBMISSION_SECTION_FORM_SUCCESS'), + SAVE_SUBMISSION_SECTION_FORM_ERROR: type('dspace/submission/SAVE_SUBMISSION_SECTION_FORM_ERROR'), + COMPLETE_SAVE_SUBMISSION_FORM: type('dspace/submission/COMPLETE_SAVE_SUBMISSION_FORM'), + CHANGE_SUBMISSION_COLLECTION: type('dspace/submission/CHANGE_SUBMISSION_COLLECTION'), + SET_ACTIVE_SECTION: type('dspace/submission/SET_ACTIVE_SECTION'), + INIT_SECTION: type('dspace/submission/INIT_SECTION'), + ENABLE_SECTION: type('dspace/submission/ENABLE_SECTION'), + DISABLE_SECTION: type('dspace/submission/DISABLE_SECTION'), + SECTION_STATUS_CHANGE: type('dspace/submission/SECTION_STATUS_CHANGE'), + SECTION_LOADING_STATUS_CHANGE: type('dspace/submission/SECTION_LOADING_STATUS_CHANGE'), + UPLOAD_SECTION_DATA: type('dspace/submission/UPLOAD_SECTION_DATA'), + REMOVE_SECTION_ERRORS: type('dspace/submission/REMOVE_SECTION_ERRORS'), + SAVE_AND_DEPOSIT_SUBMISSION: type('dspace/submission/SAVE_AND_DEPOSIT_SUBMISSION'), + DEPOSIT_SUBMISSION: type('dspace/submission/DEPOSIT_SUBMISSION'), + DEPOSIT_SUBMISSION_SUCCESS: type('dspace/submission/DEPOSIT_SUBMISSION_SUCCESS'), + DEPOSIT_SUBMISSION_ERROR: type('dspace/submission/DEPOSIT_SUBMISSION_ERROR'), + DISCARD_SUBMISSION: type('dspace/submission/DISCARD_SUBMISSION'), + DISCARD_SUBMISSION_SUCCESS: type('dspace/submission/DISCARD_SUBMISSION_SUCCESS'), + DISCARD_SUBMISSION_ERROR: type('dspace/submission/DISCARD_SUBMISSION_ERROR'), + SET_WORKSPACE_DUPLICATION: type('/sections/deduplication/SET_WORKSPACE_DUPLICATION'), + SET_WORKSPACE_DUPLICATION_SUCCESS: type('/sections/deduplication/SET_WORKSPACE_DUPLICATION_SUCCESS'), + SET_WORKSPACE_DUPLICATION_ERROR: type('/sections/deduplication/SET_WORKSPACE_DUPLICATION_ERROR'), + SET_WORKFLOW_DUPLICATION: type('/sections/deduplication/SET_WORKFLOW_DUPLICATION'), + SET_WORKFLOW_DUPLICATION_SUCCESS: type('/sections/deduplication/SET_WORKFLOW_DUPLICATION_SUCCESS'), + SET_WORKFLOW_DUPLICATION_ERROR: type('/sections/deduplication/SET_WORKFLOW_DUPLICATION_ERROR'), + + // Upload file types + NEW_FILE: type('dspace/submission/NEW_FILE'), + EDIT_FILE_DATA: type('dspace/submission/EDIT_FILE_DATA'), + DELETE_FILE: type('dspace/submission/DELETE_FILE'), + + // Errors + INSERT_ERRORS: type('dspace/submission/INSERT_ERRORS'), + DELETE_ERRORS: type('dspace/submission/DELETE_ERRORS'), + CLEAR_ERRORS: type('dspace/submission/CLEAR_ERRORS'), +}; + +/* tslint:disable:max-classes-per-file */ + +/** + * Insert a new error of type SubmissionSectionError into the given section + * @param {string} submissionId + * @param {string} sectionId + * @param {SubmissionSectionError} error + */ +export class InertSectionErrorsAction implements Action { + type: string = SubmissionObjectActionTypes.INSERT_ERRORS; + payload: { + submissionId: string; + sectionId: string; + error: SubmissionSectionError | SubmissionSectionError[]; + }; + + constructor(submissionId: string, sectionId: string, error: SubmissionSectionError | SubmissionSectionError[]) { + this.payload = { submissionId, sectionId, error }; + } +} + +/** + * Delete a SubmissionSectionError from the given section + * @param {string} submissionId + * @param {string} sectionId + * @param {string | SubmissionSectionError} error + */ +export class DeleteSectionErrorsAction implements Action { + type: string = SubmissionObjectActionTypes.DELETE_ERRORS; + payload: { + submissionId: string; + sectionId: string; + error: string | SubmissionSectionError | SubmissionSectionError[]; + }; + + constructor(submissionId: string, sectionId: string, error: string | SubmissionSectionError | SubmissionSectionError[]) { + this.payload = { submissionId, sectionId, error }; + } +} + +/** + * Clear all the errors from the given section + * @param {string} submissionId + * @param {string} sectionId + */ +export class ClearSectionErrorsAction implements Action { + type: string = SubmissionObjectActionTypes.CLEAR_ERRORS; + payload: { + submissionId: string; + sectionId: string; + }; + + constructor(submissionId: string, sectionId: string) { + this.payload = { submissionId, sectionId } + } +} + +// Section actions + +export class InitSectionAction implements Action { + type = SubmissionObjectActionTypes.INIT_SECTION; + payload: { + submissionId: string; + sectionId: string; + header: string; + config: string; + mandatory: boolean; + sectionType: SectionsType; + visibility: SectionVisibility; + enabled: boolean; + data: WorkspaceitemSectionDataType; + errors: SubmissionSectionError[]; + }; + + /** + * Create a new InitSectionAction + * + * @param submissionId + * the submission's ID to remove + * @param sectionId + * the section's ID to add + * @param header + * the section's header + * @param mandatory + * the section's mandatory + * @param sectionType + * the section's type + * @param visibility + * the section's visibility + * @param enabled + * the section's enabled state + * @param data + * the section's data + * @param errors + * the section's errors + */ + constructor(submissionId: string, + sectionId: string, + header: string, + config: string, + mandatory: boolean, + sectionType: SectionsType, + visibility: SectionVisibility, + enabled: boolean, + data: WorkspaceitemSectionDataType, + errors: SubmissionSectionError[]) { + this.payload = { submissionId, sectionId, header, config, mandatory, sectionType, visibility, enabled, data, errors }; + } +} + +export class EnableSectionAction implements Action { + type = SubmissionObjectActionTypes.ENABLE_SECTION; + payload: { + submissionId: string; + sectionId: string; + }; + + /** + * Create a new EnableSectionAction + * + * @param submissionId + * the submission's ID to remove + * @param sectionId + * the section's ID to add + */ + constructor(submissionId: string, + sectionId: string) { + this.payload = { submissionId, sectionId }; + } +} + +export class DisableSectionAction implements Action { + type = SubmissionObjectActionTypes.DISABLE_SECTION; + payload: { + submissionId: string; + sectionId: string; + }; + + /** + * Create a new DisableSectionAction + * + * @param submissionId + * the submission's ID to remove + * @param sectionId + * the section's ID to remove + */ + constructor(submissionId: string, sectionId: string) { + this.payload = { submissionId, sectionId }; + } +} + +export class UpdateSectionDataAction implements Action { + type = SubmissionObjectActionTypes.UPLOAD_SECTION_DATA; + payload: { + submissionId: string; + sectionId: string; + data: WorkspaceitemSectionDataType; + errors: SubmissionSectionError[]; + }; + + /** + * Create a new EnableSectionAction + * + * @param submissionId + * the submission's ID to remove + * @param sectionId + * the section's ID to add + * @param data + * the section's data + * @param errors + * the section's errors + */ + constructor(submissionId: string, + sectionId: string, + data: WorkspaceitemSectionDataType, + errors: SubmissionSectionError[]) { + this.payload = { submissionId, sectionId, data, errors }; + } +} + +export class RemoveSectionErrorsAction implements Action { + type = SubmissionObjectActionTypes.REMOVE_SECTION_ERRORS; + payload: { + submissionId: string; + sectionId: string; + }; + + /** + * Create a new RemoveSectionErrorsAction + * + * @param submissionId + * the submission's ID to remove + * @param sectionId + * the section's ID to add + */ + constructor(submissionId: string, sectionId: string) { + this.payload = { submissionId, sectionId }; + } +} + +export class InitSubmissionFormAction implements Action { + type = SubmissionObjectActionTypes.INIT_SUBMISSION_FORM; + payload: { + collectionId: string; + definitionId: string; + submissionId: string; + selfUrl: string; + sections: WorkspaceitemSectionsObject; + }; + + /** + * Create a new InitSubmissionFormAction + * + * @param collectionId + * the collection's Id where to deposit + * @param definitionId + * the definition's ID to use + * @param submissionId + * the submission's ID + * @param selfUrl + * the submission's self url + * @param sections + * the submission's sections + */ + constructor(collectionId: string, + definitionId: string, + submissionId: string, + selfUrl: string, + sections: WorkspaceitemSectionsObject) { + this.payload = { collectionId, definitionId, submissionId, selfUrl, sections }; + } +} + +// Submission actions + +export class CompleteInitSubmissionFormAction implements Action { + type = SubmissionObjectActionTypes.COMPLETE_INIT_SUBMISSION_FORM; + payload: { + submissionId: string; + }; + + /** + * Create a new CompleteInitSubmissionFormAction + * + * @param submissionId + * the submission's ID + */ + constructor(submissionId: string) { + this.payload = { submissionId }; + } +} + +export class LoadSubmissionFormAction implements Action { + type = SubmissionObjectActionTypes.LOAD_SUBMISSION_FORM; + payload: { + collectionId: string; + submissionId: string; + selfUrl: string; + submissionDefinition: SubmissionDefinitionsModel; + sections: WorkspaceitemSectionsObject; + errors: SubmissionSectionError[]; + }; + + /** + * Create a new LoadSubmissionFormAction + * + * @param collectionId + * the collection's Id where to deposit + * @param submissionId + * the submission's ID + * @param selfUrl + * the submission object url + * @param submissionDefinition + * the submission's sections definition + * @param sections + * the submission's sections + * @param errors + * the submission's sections errors + */ + constructor(collectionId: string, + submissionId: string, + selfUrl: string, + submissionDefinition: SubmissionDefinitionsModel, + sections: WorkspaceitemSectionsObject, + errors: SubmissionSectionError[]) { + this.payload = { collectionId, submissionId, selfUrl, submissionDefinition, sections, errors }; + } +} + +export class SaveForLaterSubmissionFormAction implements Action { + type = SubmissionObjectActionTypes.SAVE_FOR_LATER_SUBMISSION_FORM; + payload: { + submissionId: string; + }; + + /** + * Create a new SaveForLaterSubmissionFormAction + * + * @param submissionId + * the submission's ID + */ + constructor(submissionId: string) { + this.payload = { submissionId }; + } +} + +export class SaveForLaterSubmissionFormSuccessAction implements Action { + type = SubmissionObjectActionTypes.SAVE_FOR_LATER_SUBMISSION_FORM_SUCCESS; + payload: { + submissionId: string; + submissionObject: SubmissionObject[]; + }; + + /** + * Create a new SaveForLaterSubmissionFormSuccessAction + * + * @param submissionId + * the submission's ID + * @param submissionObjects + * the submission's Object + */ + constructor(submissionId: string, submissionObject: SubmissionObject[]) { + this.payload = { submissionId, submissionObject }; + } +} + +export class SaveForLaterSubmissionFormErrorAction implements Action { + type = SubmissionObjectActionTypes.SAVE_FOR_LATER_SUBMISSION_FORM_ERROR; + payload: { + submissionId: string; + }; + + /** + * Create a new SaveForLaterSubmissionFormErrorAction + * + * @param submissionId + * the submission's ID + */ + constructor(submissionId: string) { + this.payload = { submissionId }; + } +} + +export class SaveSubmissionFormAction implements Action { + type = SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM; + payload: { + submissionId: string; + }; + + /** + * Create a new SaveSubmissionFormAction + * + * @param submissionId + * the submission's ID + */ + constructor(submissionId: string) { + this.payload = { submissionId }; + } +} + +export class SaveSubmissionFormSuccessAction implements Action { + type = SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM_SUCCESS; + payload: { + submissionId: string; + submissionObject: SubmissionObject[]; + }; + + /** + * Create a new SaveSubmissionFormSuccessAction + * + * @param submissionId + * the submission's ID + * @param submissionObjects + * the submission's Object + */ + constructor(submissionId: string, submissionObject: SubmissionObject[]) { + this.payload = { submissionId, submissionObject }; + } +} + +export class SaveSubmissionFormErrorAction implements Action { + type = SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM_ERROR; + payload: { + submissionId: string; + }; + + /** + * Create a new SaveSubmissionFormErrorAction + * + * @param submissionId + * the submission's ID + */ + constructor(submissionId: string) { + this.payload = { submissionId }; + } +} + +export class SaveSubmissionSectionFormAction implements Action { + type = SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM; + payload: { + submissionId: string; + sectionId: string; + }; + + /** + * Create a new SaveSubmissionSectionFormAction + * + * @param submissionId + * the submission's ID + * @param sectionId + * the section's ID + */ + constructor(submissionId: string, sectionId: string) { + this.payload = { submissionId, sectionId }; + } +} + +export class SaveSubmissionSectionFormSuccessAction implements Action { + type = SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM_SUCCESS; + payload: { + submissionId: string; + submissionObject: SubmissionObject[]; + }; + + /** + * Create a new SaveSubmissionSectionFormSuccessAction + * + * @param submissionId + * the submission's ID + * @param submissionObjects + * the submission's Object + */ + constructor(submissionId: string, submissionObject: SubmissionObject[]) { + this.payload = { submissionId, submissionObject }; + } +} + +export class SaveSubmissionSectionFormErrorAction implements Action { + type = SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM_ERROR; + payload: { + submissionId: string; + }; + + /** + * Create a new SaveSubmissionFormErrorAction + * + * @param submissionId + * the submission's ID + */ + constructor(submissionId: string) { + this.payload = { submissionId }; + } +} + +export class CompleteSaveSubmissionFormAction implements Action { + type = SubmissionObjectActionTypes.COMPLETE_SAVE_SUBMISSION_FORM; + payload: { + submissionId: string; + }; + + /** + * Create a new CompleteSaveSubmissionFormAction + * + * @param submissionId + * the submission's ID + */ + constructor(submissionId: string) { + this.payload = { submissionId }; + } +} + +export class ResetSubmissionFormAction implements Action { + type = SubmissionObjectActionTypes.RESET_SUBMISSION_FORM; + payload: { + collectionId: string; + submissionId: string; + selfUrl: string; + sections: WorkspaceitemSectionsObject; + submissionDefinition: SubmissionDefinitionsModel; + }; + + /** + * Create a new LoadSubmissionFormAction + * + * @param collectionId + * the collection's Id where to deposit + * @param submissionId + * the submission's ID + * @param selfUrl + * the submission object url + * @param sections + * the submission's sections + * @param submissionDefinition + * the submission's form definition + */ + constructor(collectionId: string, submissionId: string, selfUrl: string, sections: WorkspaceitemSectionsObject, submissionDefinition: SubmissionDefinitionsModel) { + this.payload = { collectionId, submissionId, selfUrl, sections, submissionDefinition }; + } +} + +export class CancelSubmissionFormAction implements Action { + type = SubmissionObjectActionTypes.CANCEL_SUBMISSION_FORM; +} + +export class ChangeSubmissionCollectionAction implements Action { + type = SubmissionObjectActionTypes.CHANGE_SUBMISSION_COLLECTION; + payload: { + submissionId: string; + collectionId: string; + }; + + /** + * Create a new ChangeSubmissionCollectionAction + * + * @param collectionId + * the new collection's ID + */ + constructor(submissionId: string, collectionId: string) { + this.payload = { submissionId, collectionId }; + } +} + +export class SaveAndDepositSubmissionAction implements Action { + type = SubmissionObjectActionTypes.SAVE_AND_DEPOSIT_SUBMISSION; + payload: { + submissionId: string; + }; + + /** + * Create a new SaveAndDepositSubmissionAction + * + * @param submissionId + * the submission's ID to deposit + */ + constructor(submissionId: string) { + this.payload = { submissionId }; + } +} + +export class DepositSubmissionAction implements Action { + type = SubmissionObjectActionTypes.DEPOSIT_SUBMISSION; + payload: { + submissionId: string; + }; + + /** + * Create a new DepositSubmissionAction + * + * @param submissionId + * the submission's ID to deposit + */ + constructor(submissionId: string) { + this.payload = { submissionId }; + } +} + +export class DepositSubmissionSuccessAction implements Action { + type = SubmissionObjectActionTypes.DEPOSIT_SUBMISSION_SUCCESS; + payload: { + submissionId: string; + }; + + /** + * Create a new DepositSubmissionSuccessAction + * + * @param submissionId + * the submission's ID to deposit + */ + constructor(submissionId: string) { + this.payload = { submissionId }; + } +} + +export class DepositSubmissionErrorAction implements Action { + type = SubmissionObjectActionTypes.DEPOSIT_SUBMISSION_ERROR; + payload: { + submissionId: string; + }; + + /** + * Create a new DepositSubmissionErrorAction + * + * @param submissionId + * the submission's ID to deposit + */ + constructor(submissionId: string) { + this.payload = { submissionId }; + } +} + +export class DiscardSubmissionAction implements Action { + type = SubmissionObjectActionTypes.DISCARD_SUBMISSION; + payload: { + submissionId: string; + }; + + /** + * Create a new DiscardSubmissionAction + * + * @param submissionId + * the submission's ID to discard + */ + constructor(submissionId: string) { + this.payload = { submissionId }; + } +} + +export class DiscardSubmissionSuccessAction implements Action { + type = SubmissionObjectActionTypes.DISCARD_SUBMISSION_SUCCESS; + payload: { + submissionId: string; + }; + + /** + * Create a new DiscardSubmissionSuccessAction + * + * @param submissionId + * the submission's ID to discard + */ + constructor(submissionId: string) { + this.payload = { submissionId }; + } +} + +export class DiscardSubmissionErrorAction implements Action { + type = SubmissionObjectActionTypes.DISCARD_SUBMISSION_ERROR; + payload: { + submissionId: string; + }; + + /** + * Create a new DiscardSubmissionErrorAction + * + * @param submissionId + * the submission's ID to discard + */ + constructor(submissionId: string) { + this.payload = { submissionId }; + } +} + +export class SectionStatusChangeAction implements Action { + type = SubmissionObjectActionTypes.SECTION_STATUS_CHANGE; + payload: { + submissionId: string; + sectionId: string; + status: boolean + }; + + /** + * Change the section validity status + * + * @param submissionId + * the submission's ID + * @param sectionId + * the section's ID to change + * @param status + * the section validity status (true if is valid) + */ + constructor(submissionId: string, sectionId: string, status: boolean) { + this.payload = { submissionId, sectionId, status }; + } +} + +export class SectionLoadingStatusChangeAction implements Action { + type = SubmissionObjectActionTypes.SECTION_LOADING_STATUS_CHANGE; + payload: { + submissionId: string; + sectionId: string; + loading: boolean + }; + + /** + * Change the section loading status + * + * @param submissionId + * the submission's ID + * @param sectionId + * the section's ID to change + * @param loading + * the section loading status (true if is loading) + */ + constructor(submissionId: string, sectionId: string, loading: boolean) { + this.payload = { submissionId, sectionId, loading }; + } +} + +export class SetActiveSectionAction implements Action { + type = SubmissionObjectActionTypes.SET_ACTIVE_SECTION; + payload: { + submissionId: string; + sectionId: string; + }; + + /** + * Create a new SetActiveSectionAction + * + * @param submissionId + * the submission's ID + * @param sectionId + * the section's ID to active + */ + constructor(submissionId: string, sectionId: string) { + this.payload = { submissionId, sectionId }; + } +} +// Upload file actions + +export class NewUploadedFileAction implements Action { + type = SubmissionObjectActionTypes.NEW_FILE; + payload: { + submissionId: string; + sectionId: string; + fileId: string; + data: WorkspaceitemSectionUploadFileObject; + }; + + /** + * Add a new uploaded file + * + * @param submissionId + * the submission's ID + * @param sectionId + * the section's ID + * @param fileId + * the file's ID + * @param data + * the metadata of the new bitstream + */ + constructor(submissionId: string, sectionId: string, fileId: string, data: WorkspaceitemSectionUploadFileObject) { + this.payload = { submissionId, sectionId, fileId: fileId, data }; + } +} + +export class EditFileDataAction implements Action { + type = SubmissionObjectActionTypes.EDIT_FILE_DATA; + payload: { + submissionId: string; + sectionId: string; + fileId: string; + data: WorkspaceitemSectionUploadFileObject; + }; + + /** + * Edit a file data + * + * @param submissionId + * the submission's ID + * @param sectionId + * the section's ID + * @param fileId + * the file's ID + * @param data + * the metadata of the new bitstream + */ + constructor(submissionId: string, sectionId: string, fileId: string, data: WorkspaceitemSectionUploadFileObject) { + this.payload = { submissionId, sectionId, fileId: fileId, data }; + } +} + +export class DeleteUploadedFileAction implements Action { + type = SubmissionObjectActionTypes.DELETE_FILE; + payload: { + submissionId: string; + sectionId: string; + fileId: string; + }; + + /** + * Delete a uploaded file + * + * @param submissionId + * the submission's ID + * @param sectionId + * the section's ID + * @param fileId + * the file's ID + */ + constructor(submissionId: string, sectionId: string, fileId: string) { + this.payload = { submissionId, sectionId, fileId }; + } +} + +export class SetWorkspaceDuplicatedAction implements Action { + type = SubmissionObjectActionTypes.SET_WORKSPACE_DUPLICATION; + payload: { + index: number; + decision: string; + note?: string + }; + + /** + * Create a new SetWorkspaceDuplicatedAction + * + * @param index + * the index in matches array + * @param decision + * the submitter's decision ('verify'|'reject'|null) + * @param note + * the submitter's note, for 'verify' decision only + */ + constructor(payload: any) { + this.payload = payload; + } +} + +export class SetWorkspaceDuplicatedSuccessAction implements Action { + type = SubmissionObjectActionTypes.SET_WORKSPACE_DUPLICATION_SUCCESS; + payload: { + index: number; + decision: string; + note?: string + }; + + /** + * Create a new SetWorkspaceDuplicatedSuccessAction + * + * @param index + * the index in matches array + * @param decision + * the submitter's decision ('verify'|'reject'|null) + * @param note + * the submitter's note, for 'verify' decision only + */ + constructor(payload: any) { + this.payload = payload; + } +} + +export class SetWorkspaceDuplicatedErrorAction implements Action { + type = SubmissionObjectActionTypes.SET_WORKSPACE_DUPLICATION_ERROR; + payload: { + index: number; + }; + + /** + * Create a new SetWorkspaceDuplicatedErrorAction + * + * @param index + * the index in matches array + */ + constructor(index: number) { + this.payload = { index }; + } +} + +export class SetWorkflowDuplicatedAction implements Action { + type = SubmissionObjectActionTypes.SET_WORKFLOW_DUPLICATION; + payload: { + index: number; + decision: string; + note?: string + }; + + /** + * Create a new SetWorkflowDuplicatedAction + * + * @param index + * the index in matches array + * @param decision + * the controller's decision ('verify'|'reject'|null) + * @param note + * the controller's note, for 'verify' decision only + */ + constructor(payload: any) { + this.payload = payload; + } +} + +export class SetWorkflowDuplicatedSuccessAction implements Action { + type = SubmissionObjectActionTypes.SET_WORKFLOW_DUPLICATION_SUCCESS; + payload: { + index: number; + decision: string; + note?: string + }; + + /** + * Create a new SetWorkflowDuplicatedSuccessAction + * + * @param index + * the index in matches array + * @param decision + * the controller's decision ('verify'|'reject'|null) + * @param note + * the controller's note, for 'verify' decision only + */ + constructor(payload: any) { + this.payload = payload; + } +} + +export class SetWorkflowDuplicatedErrorAction implements Action { + type = SubmissionObjectActionTypes.SET_WORKFLOW_DUPLICATION_ERROR; + payload: { + index: number; + }; + + /** + * Create a new SetWorkflowDuplicatedErrorAction + * + * @param index + * the index in matches array + */ + constructor(index: number) { + this.payload = { index }; + } +} + +/* tslint:enable:max-classes-per-file */ + +/** + * Export a type alias of all actions in this action group + * so that reducers can easily compose action types + */ +export type SubmissionObjectAction = DisableSectionAction + | InitSectionAction + | EnableSectionAction + | LoadSubmissionFormAction + | ResetSubmissionFormAction + | CancelSubmissionFormAction + | InitSubmissionFormAction + | CompleteInitSubmissionFormAction + | ChangeSubmissionCollectionAction + | SaveAndDepositSubmissionAction + | DepositSubmissionAction + | DepositSubmissionSuccessAction + | DepositSubmissionErrorAction + | DiscardSubmissionAction + | DiscardSubmissionSuccessAction + | DiscardSubmissionErrorAction + | SectionStatusChangeAction + | NewUploadedFileAction + | EditFileDataAction + | DeleteUploadedFileAction + | InertSectionErrorsAction + | DeleteSectionErrorsAction + | ClearSectionErrorsAction + | UpdateSectionDataAction + | RemoveSectionErrorsAction + | SaveForLaterSubmissionFormAction + | SaveForLaterSubmissionFormSuccessAction + | SaveForLaterSubmissionFormErrorAction + | SaveSubmissionFormAction + | SaveSubmissionFormSuccessAction + | SaveSubmissionFormErrorAction + | SaveSubmissionSectionFormAction + | SaveSubmissionSectionFormSuccessAction + | SaveSubmissionSectionFormErrorAction + | CompleteSaveSubmissionFormAction + | SetActiveSectionAction + | SetWorkspaceDuplicatedAction + | SetWorkspaceDuplicatedSuccessAction + | SetWorkspaceDuplicatedErrorAction + | SetWorkflowDuplicatedAction + | SetWorkflowDuplicatedSuccessAction + | SetWorkflowDuplicatedErrorAction; diff --git a/src/app/submission/objects/submission-objects.effects.ts b/src/app/submission/objects/submission-objects.effects.ts new file mode 100644 index 0000000000..7d0b0b2399 --- /dev/null +++ b/src/app/submission/objects/submission-objects.effects.ts @@ -0,0 +1,338 @@ +import { Injectable } from '@angular/core'; +import { Actions, Effect } from '@ngrx/effects'; + +import { union } from 'lodash'; + +import { + CompleteInitSubmissionFormAction, + DepositSubmissionAction, + DepositSubmissionErrorAction, + DepositSubmissionSuccessAction, + DiscardSubmissionErrorAction, + DiscardSubmissionSuccessAction, + InitSectionAction, + LoadSubmissionFormAction, + ResetSubmissionFormAction, + SaveAndDepositSubmissionAction, + SaveForLaterSubmissionFormAction, + SaveForLaterSubmissionFormSuccessAction, + SaveSubmissionFormAction, + SaveSubmissionFormErrorAction, + SaveSubmissionFormSuccessAction, + SaveSubmissionSectionFormAction, + SaveSubmissionSectionFormErrorAction, + SaveSubmissionSectionFormSuccessAction, + SetWorkflowDuplicatedAction, + SetWorkflowDuplicatedErrorAction, + SetWorkflowDuplicatedSuccessAction, + SetWorkspaceDuplicatedAction, + SetWorkspaceDuplicatedErrorAction, + SetWorkspaceDuplicatedSuccessAction, + SubmissionObjectActionTypes, + UpdateSectionDataAction +} from './submission-objects.actions'; +import { SectionsService } from '../sections/sections.service'; +import { isEmpty, isNotEmpty, isNotUndefined } from '../../shared/empty.util'; +import { Workspaceitem } from '../../core/submission/models/workspaceitem.model'; +import { Observable } from 'rxjs/Observable'; +import { JsonPatchOperationsService } from '../../core/json-patch/json-patch-operations.service'; +import { SubmitDataResponseDefinitionObject } from '../../core/shared/submit-data-response-definition.model'; +import { SubmissionService } from '../submission.service'; +import { Action, Store } from '@ngrx/store'; +import { Workflowitem } from '../../core/submission/models/workflowitem.model'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { SubmissionObject } from '../../core/submission/models/submission-object.model'; +import { TranslateService } from '@ngx-translate/core'; +import { DeduplicationService } from '../sections/deduplication/deduplication.service'; +import { SubmissionState } from '../submission.reducers'; +import { SubmissionObjectEntry } from './submission-objects.reducer'; +import { SubmissionSectionModel } from '../../core/shared/config/config-submission-section.model'; +import parseSectionErrors from '../utils/parseSectionErrors'; + +@Injectable() +export class SubmissionObjectEffects { + + @Effect() loadForm$ = this.actions$ + .ofType(SubmissionObjectActionTypes.LOAD_SUBMISSION_FORM) + .map((action: LoadSubmissionFormAction) => { + const definition = action.payload.submissionDefinition; + const mappedActions = []; + definition.sections.forEach((sectionDefinition: SubmissionSectionModel, index: number) => { + const sectionId = sectionDefinition._links.self.substr(sectionDefinition._links.self.lastIndexOf('/') + 1); + const config = sectionDefinition._links.config || ''; + const enabled = sectionDefinition.mandatory || (isNotEmpty(action.payload.sections) && action.payload.sections.hasOwnProperty(sectionId)); + const sectionData = (isNotUndefined(action.payload.sections) && isNotUndefined(action.payload.sections[sectionId])) ? action.payload.sections[sectionId] : Object.create(null); + const sectionErrors = null; + mappedActions.push( + new InitSectionAction( + action.payload.submissionId, + sectionId, + sectionDefinition.header, + config, + sectionDefinition.mandatory, + sectionDefinition.sectionType, + sectionDefinition.visibility, + enabled, + sectionData, + sectionErrors + ) + ) + }); + return {action: action, definition: definition, mappedActions: mappedActions}; + }) + .mergeMap((result) => { + return Observable.from( + result.mappedActions.concat( + new CompleteInitSubmissionFormAction(result.action.payload.submissionId) + )); + }); + + @Effect() resetForm$ = this.actions$ + .ofType(SubmissionObjectActionTypes.RESET_SUBMISSION_FORM) + .map((action: ResetSubmissionFormAction) => + new LoadSubmissionFormAction( + action.payload.collectionId, + action.payload.submissionId, + action.payload.selfUrl, + action.payload.submissionDefinition, + action.payload.sections, + null + )); + + @Effect() saveSubmission$ = this.actions$ + .ofType(SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM) + .switchMap((action: SaveSubmissionFormAction) => { + return this.operationsService.jsonPatchByResourceType( + this.submissionService.getSubmissionObjectLinkName(), + action.payload.submissionId, + 'sections') + .map((response: SubmissionObject[]) => new SaveSubmissionFormSuccessAction(action.payload.submissionId, response)) + .catch(() => Observable.of(new SaveSubmissionFormErrorAction(action.payload.submissionId))); + }); + + @Effect() saveForLaterSubmission$ = this.actions$ + .ofType(SubmissionObjectActionTypes.SAVE_FOR_LATER_SUBMISSION_FORM) + .switchMap((action: SaveForLaterSubmissionFormAction) => { + return this.operationsService.jsonPatchByResourceType( + this.submissionService.getSubmissionObjectLinkName(), + action.payload.submissionId, + 'sections') + .map((response: SubmissionObject[]) => new SaveForLaterSubmissionFormSuccessAction(action.payload.submissionId, response)) + .catch(() => Observable.of(new SaveSubmissionFormErrorAction(action.payload.submissionId))); + }); + + @Effect() saveSubmissionSuccess$ = this.actions$ + .ofType(SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM_SUCCESS, SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM_SUCCESS) + .withLatestFrom(this.store$) + .map(([action, currentState]: [SaveSubmissionFormSuccessAction | SaveSubmissionSectionFormSuccessAction, any]) => { + return this.parseSaveResponse((currentState.submission as SubmissionState).objects[action.payload.submissionId], action.payload.submissionObject, action.payload.submissionId); + }) + .mergeMap((actions) => { + return Observable.from(actions); + }); + + @Effect() saveSection$ = this.actions$ + .ofType(SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM) + .switchMap((action: SaveSubmissionSectionFormAction) => { + return this.operationsService.jsonPatchByResourceID( + this.submissionService.getSubmissionObjectLinkName(), + action.payload.submissionId, + 'sections', + action.payload.sectionId) + .map((response: SubmissionObject[]) => new SaveSubmissionSectionFormSuccessAction(action.payload.submissionId, response)) + .catch(() => Observable.of(new SaveSubmissionSectionFormErrorAction(action.payload.submissionId))); + }); + + @Effect() saveAndDepositSection$ = this.actions$ + .ofType(SubmissionObjectActionTypes.SAVE_AND_DEPOSIT_SUBMISSION) + .withLatestFrom(this.store$) + .switchMap(([action, currentState]: [SaveAndDepositSubmissionAction, any]) => { + return this.operationsService.jsonPatchByResourceType( + this.submissionService.getSubmissionObjectLinkName(), + action.payload.submissionId, + 'sections') + .map((response: SubmissionObject[]) => { + if (this.canDeposit(response)) { + return new DepositSubmissionAction(action.payload.submissionId); + } else { + this.notificationsService.warning(null, this.translate.get('submission.sections.general.sections_not_valid')); + return this.parseSaveResponse((currentState.submission as SubmissionState).objects[action.payload.submissionId], response, action.payload.submissionId); + } + }) + .catch(() => Observable.of(new SaveSubmissionSectionFormErrorAction(action.payload.submissionId))); + }); + + @Effect() depositSubmission$ = this.actions$ + .ofType(SubmissionObjectActionTypes.DEPOSIT_SUBMISSION) + .withLatestFrom(this.store$) + .switchMap(([action, state]: [DepositSubmissionAction, any]) => { + return this.submissionService.depositSubmission(state.submission.objects[action.payload.submissionId].selfUrl) + .map(() => new DepositSubmissionSuccessAction(action.payload.submissionId)) + .catch((e) => Observable.of(new DepositSubmissionErrorAction(action.payload.submissionId))); + }); + + @Effect({dispatch: false}) SaveForLaterSubmissionSuccess$ = this.actions$ + .ofType(SubmissionObjectActionTypes.SAVE_FOR_LATER_SUBMISSION_FORM_SUCCESS) + .do(() => this.notificationsService.success(null, this.translate.get('submission.sections.general.save_success_notice'))) + .do(() => this.submissionService.redirectToMyDSpace()); + + @Effect({dispatch: false}) depositSubmissionSuccess$ = this.actions$ + .ofType(SubmissionObjectActionTypes.DEPOSIT_SUBMISSION_SUCCESS) + .do(() => this.notificationsService.success(null, this.translate.get('submission.sections.general.deposit_success_notice'))) + .do(() => this.submissionService.redirectToMyDSpace()); + + @Effect({dispatch: false}) depositSubmissionError$ = this.actions$ + .ofType(SubmissionObjectActionTypes.DEPOSIT_SUBMISSION_ERROR) + .do(() => this.notificationsService.error(null, this.translate.get('submission.sections.general.deposit_error_notice'))); + + @Effect() discardSubmission$ = this.actions$ + .ofType(SubmissionObjectActionTypes.DISCARD_SUBMISSION) + .switchMap((action: DepositSubmissionAction) => { + return this.submissionService.discardSubmission(action.payload.submissionId) + .map(() => new DiscardSubmissionSuccessAction(action.payload.submissionId)) + .catch((e) => Observable.of(new DiscardSubmissionErrorAction(action.payload.submissionId))); + }); + + @Effect({dispatch: false}) discardSubmissionSuccess$ = this.actions$ + .ofType(SubmissionObjectActionTypes.DISCARD_SUBMISSION_SUCCESS) + .do(() => this.notificationsService.success(null, this.translate.get('submission.sections.general.discard_success_notice'))) + .do(() => this.submissionService.redirectToMyDSpace()); + + @Effect({dispatch: false}) discardSubmissionError$ = this.actions$ + .ofType(SubmissionObjectActionTypes.DISCARD_SUBMISSION_ERROR) + .do(() => this.notificationsService.error(null, this.translate.get('submission.sections.general.discard_error_notice'))); + + @Effect() + public wsDuplication: Observable = this.actions$ + .ofType(SubmissionObjectActionTypes.SET_WORKSPACE_DUPLICATION) + .map((action: SetWorkspaceDuplicatedAction) => { + // return this.deduplicationService.setWorkspaceDuplicated(action.payload) + // .first() + // .map((response) => { + console.log('Effect of SET_WORKSPACE_DUPLICATION'); + // TODO JSON PATCH + // const pathCombiner = new JsonPatchOperationPathCombiner('sections', 'deduplication'); + // const path = ''; // `metadata/${metadataKey}`; // TODO + // this.operationsBuilder.add(pathCombiner.getPath(path), action.payload, true); + return new SetWorkspaceDuplicatedSuccessAction(action.payload); + }) + .catch((error) => Observable.of(new SetWorkspaceDuplicatedErrorAction(error))); + + @Effect({dispatch: false}) + public wsDuplicationSuccess: Observable = this.actions$ + .ofType(SubmissionObjectActionTypes.SET_WORKSPACE_DUPLICATION_SUCCESS) + // TODO + .do((action: SetWorkspaceDuplicatedAction) => { + console.log('Effect of SET_WORKSPACE_DUPLICATION_SUCCESS'); + this.deduplicationService.setWorkspaceDuplicationSuccess(action.payload); + }); + + @Effect({dispatch: false}) + public wsDuplicationError: Observable = this.actions$ + .ofType(SubmissionObjectActionTypes.SET_WORKSPACE_DUPLICATION_ERROR) + .do((action: SetWorkspaceDuplicatedAction) => { + console.log('Effect of SET_WORKSPACE_DUPLICATION_ERROR'); + this.deduplicationService.setWorkspaceDuplicationError(action.payload); + }); + + @Effect() + public wfDuplication: Observable = this.actions$ + .ofType(SubmissionObjectActionTypes.SET_WORKFLOW_DUPLICATION) + .map((action: SetWorkflowDuplicatedAction) => { + // return this.deduplicationService.setWorkflowDuplicated(action.payload) + // .first() + // .map((response) => { + console.log('Effect of SET_WORKFLOW_DUPLICATION'); + // TODO JSON PATCH + // const pathCombiner = new JsonPatchOperationPathCombiner('sections', 'deduplication'); + // const path = ''; // `metadata/${metadataKey}`; // TODO + // this.operationsBuilder.add(pathCombiner.getPath(path), action.payload, true); + return new SetWorkflowDuplicatedSuccessAction(action.payload); + }) + .catch((error) => Observable.of(new SetWorkflowDuplicatedErrorAction(error))); + + @Effect({dispatch: false}) + public wfDuplicationSuccess: Observable = this.actions$ + .ofType(SubmissionObjectActionTypes.SET_WORKFLOW_DUPLICATION_SUCCESS) + // TODO + .do((action: SetWorkflowDuplicatedAction) => { + console.log('Effect of SET_WORKFLOW_DUPLICATION_SUCCESS'); + this.deduplicationService.setWorkflowDuplicationSuccess(action.payload); + }); + + @Effect({dispatch: false}) + public wfDuplicationError: Observable = this.actions$ + .ofType(SubmissionObjectActionTypes.SET_WORKFLOW_DUPLICATION_ERROR) + .do((action: SetWorkflowDuplicatedAction) => { + console.log('Effect of SET_WORKFLOW_DUPLICATION_ERROR'); + this.deduplicationService.setWorkflowDuplicationError(action.payload); + }); + + constructor(private actions$: Actions, + private notificationsService: NotificationsService, + private operationsService: JsonPatchOperationsService, + private sectionService: SectionsService, + private store$: Store, + private submissionService: SubmissionService, + private deduplicationService: DeduplicationService, + private translate: TranslateService) { + } + + protected canDeposit(response: SubmissionObject[]) { + let canDeposit = true; + + if (isNotEmpty(response)) { + response.forEach((item: Workspaceitem | Workflowitem) => { + const {errors} = item; + + if (errors && !isEmpty(errors)) { + canDeposit = false; + } + }); + } + return canDeposit; + } + + protected parseSaveResponse(currentState: SubmissionObjectEntry, response: SubmissionObject[], submissionId: string) { + const mappedActions = []; + + if (isNotEmpty(response)) { + this.notificationsService.success(null, this.translate.get('submission.sections.general.save_success_notice')); + + // to avoid dispatching an action for every error, create an array of errors per section + response.forEach((item: Workspaceitem | Workflowitem) => { + + let errorsList = Object.create({}); + const {errors} = item; + + if (errors && !isEmpty(errors)) { + errorsList = parseSectionErrors(errors); + this.notificationsService.warning(null, this.translate.get('submission.sections.general.sections_not_valid')); + } + + const sections = (item.sections && isNotEmpty(item.sections)) ? item.sections : {}; + + const sectionsKeys: string[] = union(Object.keys(sections), Object.keys(errorsList)); + + sectionsKeys + .forEach((sectionId) => { + const sectionErrors = errorsList[sectionId] || []; + const sectionData = sections[sectionId] || {}; + if (!currentState.sections[sectionId].enabled) { + this.translate.get('submission.sections.general.metadata-extracted-new-section', {sectionId}) + .take(1) + .subscribe((m) => { + this.notificationsService.info(null, m, null, true); + }); + } + mappedActions.push(new UpdateSectionDataAction(submissionId, sectionId, sectionData, sectionErrors)); + }); + + }); + + } + // mappedActions.push(new CompleteSaveSubmissionFormAction(submissionId)); + return mappedActions; + } + +} diff --git a/src/app/submission/objects/submission-objects.reducer.ts b/src/app/submission/objects/submission-objects.reducer.ts new file mode 100644 index 0000000000..1179591790 --- /dev/null +++ b/src/app/submission/objects/submission-objects.reducer.ts @@ -0,0 +1,844 @@ +import { hasValue, isNotEmpty, isNotNull, isNotUndefined } from '../../shared/empty.util'; +import { findKey, uniqWith, isEqual, differenceWith } from 'lodash'; + +import { + CompleteInitSubmissionFormAction, + DeleteUploadedFileAction, + DisableSectionAction, EditFileDataAction, + EnableSectionAction, NewUploadedFileAction, + LoadSubmissionFormAction, SectionStatusChangeAction, + SubmissionObjectAction, + SubmissionObjectActionTypes, ClearSectionErrorsAction, InertSectionErrorsAction, + DeleteSectionErrorsAction, ResetSubmissionFormAction, UpdateSectionDataAction, SaveSubmissionFormAction, + CompleteSaveSubmissionFormAction, SetActiveSectionAction, SaveSubmissionSectionFormAction, + DepositSubmissionAction, DepositSubmissionSuccessAction, DepositSubmissionErrorAction, + ChangeSubmissionCollectionAction, SaveSubmissionFormSuccessAction, SaveSubmissionFormErrorAction, + SaveSubmissionSectionFormSuccessAction, SaveSubmissionSectionFormErrorAction, SetWorkspaceDuplicatedAction, + SetWorkflowDuplicatedAction, InitSectionAction, RemoveSectionErrorsAction +} from './submission-objects.actions'; +import { deleteProperty } from '../../shared/object.util'; +import { WorkspaceitemSectionDataType } from '../../core/submission/models/workspaceitem-sections.model'; +import { WorkspaceitemSectionUploadObject } from '../../core/submission/models/workspaceitem-section-upload.model'; +import { SectionsType } from '../sections/sections-type'; + +export interface SectionVisibility { + main: any; + other: any; +} + +export interface SubmissionSectionObject { + header: string; + config: string; + mandatory: boolean; + sectionType: SectionsType; + visibility: SectionVisibility; + collapsed: boolean, + enabled: boolean; + data: WorkspaceitemSectionDataType; + errors: SubmissionSectionError[]; + isLoading: boolean; + isValid: boolean; +} + +export interface SubmissionSectionError { + path: string; + message: string; +} + +export interface SubmissionSectionEntry { + [sectionId: string]: SubmissionSectionObject; +} + +export interface SubmissionObjectEntry { + collection?: string, + definition?: string, + selfUrl?: string; + activeSection?: string; + sections?: SubmissionSectionEntry; + isLoading?: boolean; + savePending?: boolean; + depositPending?: boolean; +} + +/** + * The Submission State + * + * Consists of a map with submission's ID as key, + * and SubmissionObjectEntries as values + */ +export interface SubmissionObjectState { + [submissionId: string]: SubmissionObjectEntry; +} + +const initialState: SubmissionObjectState = Object.create({}); + +export function submissionObjectReducer(state = initialState, action: SubmissionObjectAction): SubmissionObjectState { + switch (action.type) { + + // submission form actions + case SubmissionObjectActionTypes.INIT_SUBMISSION_FORM: { + return state; + } + + case SubmissionObjectActionTypes.COMPLETE_INIT_SUBMISSION_FORM: { + return completeInit(state, action as CompleteInitSubmissionFormAction); + } + + case SubmissionObjectActionTypes.LOAD_SUBMISSION_FORM: { + return initSubmission(state, action as LoadSubmissionFormAction); + } + + case SubmissionObjectActionTypes.RESET_SUBMISSION_FORM: { + return resetSubmission(state, action as ResetSubmissionFormAction); + } + + case SubmissionObjectActionTypes.CANCEL_SUBMISSION_FORM: { + return initialState; + } + + case SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM: + case SubmissionObjectActionTypes.SAVE_FOR_LATER_SUBMISSION_FORM: + case SubmissionObjectActionTypes.SAVE_AND_DEPOSIT_SUBMISSION: { + return saveSubmission(state, action as SaveSubmissionFormAction); + } + + case SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM_SUCCESS: + case SubmissionObjectActionTypes.SAVE_FOR_LATER_SUBMISSION_FORM_SUCCESS: { + return completeSave(state, action as SaveSubmissionFormSuccessAction); + } + + case SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM_ERROR: + case SubmissionObjectActionTypes.SAVE_FOR_LATER_SUBMISSION_FORM_ERROR: { + return completeSave(state, action as SaveSubmissionFormErrorAction); + } + + case SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM: { + return saveSubmission(state, action as SaveSubmissionSectionFormAction); + } + + case SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM_SUCCESS: { + return completeSave(state, action as SaveSubmissionSectionFormSuccessAction); + } + + case SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM_ERROR: { + return completeSave(state, action as SaveSubmissionSectionFormErrorAction); + } + + case SubmissionObjectActionTypes.CHANGE_SUBMISSION_COLLECTION: { + return changeCollection(state, action as ChangeSubmissionCollectionAction); + } + + case SubmissionObjectActionTypes.COMPLETE_SAVE_SUBMISSION_FORM: { + return completeSave(state, action as CompleteSaveSubmissionFormAction); + } + + case SubmissionObjectActionTypes.DEPOSIT_SUBMISSION: { + return startDeposit(state, action as DepositSubmissionAction); + } + + case SubmissionObjectActionTypes.DEPOSIT_SUBMISSION_SUCCESS: { + return initialState; + } + + case SubmissionObjectActionTypes.DEPOSIT_SUBMISSION_ERROR: { + return endDeposit(state, action as DepositSubmissionAction); + } + + case SubmissionObjectActionTypes.DISCARD_SUBMISSION: { + return state; + } + + case SubmissionObjectActionTypes.DISCARD_SUBMISSION_SUCCESS: { + return initialState; + } + + case SubmissionObjectActionTypes.DISCARD_SUBMISSION_ERROR: { + return state; + } + + case SubmissionObjectActionTypes.SET_ACTIVE_SECTION: { + return setActiveSection(state, action as SetActiveSectionAction); + } + + // Section actions + + case SubmissionObjectActionTypes.INIT_SECTION: { + return initSection(state, action as InitSectionAction); + } + + case SubmissionObjectActionTypes.ENABLE_SECTION: { + return changeSectionState(state, action as EnableSectionAction, true); + } + + case SubmissionObjectActionTypes.UPLOAD_SECTION_DATA: { + return updateSectionData(state, action as UpdateSectionDataAction); + } + + case SubmissionObjectActionTypes.REMOVE_SECTION_ERRORS: { + return removeSectionErrors(state, action as RemoveSectionErrorsAction); + } + + case SubmissionObjectActionTypes.DISABLE_SECTION: { + return changeSectionState(state, action as DisableSectionAction, false); + } + + case SubmissionObjectActionTypes.SECTION_STATUS_CHANGE: { + return setIsValid(state, action as SectionStatusChangeAction); + } + + // Files actions + case SubmissionObjectActionTypes.NEW_FILE: { + return newFile(state, action as NewUploadedFileAction); + } + + case SubmissionObjectActionTypes.EDIT_FILE_DATA: { + return editFileData(state, action as EditFileDataAction); + } + + case SubmissionObjectActionTypes.DELETE_FILE: { + return deleteFile(state, action as DeleteUploadedFileAction); + } + + // deduplication + case SubmissionObjectActionTypes.SET_WORKSPACE_DUPLICATION: { + return updateDeduplication(state, action as SetWorkspaceDuplicatedAction); + } + + case SubmissionObjectActionTypes.SET_WORKFLOW_DUPLICATION: { + return updateDeduplication(state, action as SetWorkflowDuplicatedAction); + } + + // errors actions + case SubmissionObjectActionTypes.INSERT_ERRORS: { + return insertError(state, action as InertSectionErrorsAction); + } + + case SubmissionObjectActionTypes.DELETE_ERRORS: { + return removeError(state, action as DeleteSectionErrorsAction); + } + + case SubmissionObjectActionTypes.CLEAR_ERRORS: { + return clearErrorsFromSection(state, action as ClearSectionErrorsAction); + } + + default: { + return state; + } + } +} + +// ------ Submission error functions ------ // + +const removeError = (state: SubmissionObjectState, action: DeleteSectionErrorsAction): SubmissionObjectState => { + const { submissionId, sectionId, error } = action.payload; + + if (hasValue(state[ submissionId ].sections[ sectionId ])) { + let errors = state[ submissionId ].sections[ sectionId ].errors.filter((currentError) => { + return currentError.message !== error && !isEqual(currentError, error); + }); + + if (action.payload.error instanceof Array) { + errors = differenceWith(errors, action.payload.error, isEqual); + } + + return Object.assign({}, state, { + [ submissionId ]: Object.assign({}, state[ submissionId ], { + sections: Object.assign({}, state[ submissionId ].sections, { + [ sectionId ]: Object.assign({}, state[ action.payload.submissionId ].sections [ action.payload.sectionId ], { + errors + }) + }), + }) + }); + } else { + return state; + } +}; + +const insertError = (state: SubmissionObjectState, action: InertSectionErrorsAction): SubmissionObjectState => { + const { submissionId, sectionId, error } = action.payload; + + if (hasValue(state[ submissionId ].sections[ sectionId ])) { + const errors = uniqWith(state[ submissionId ].sections[ sectionId ].errors.concat(error), isEqual); + + return Object.assign({}, state, { + [ submissionId ]: Object.assign({}, state[ submissionId ], { + activeSection: state[ action.payload.submissionId ].activeSection, sections: Object.assign({}, state[ submissionId ].sections, { + [ sectionId ]: Object.assign({}, state[ action.payload.submissionId ].sections [ action.payload.sectionId ], { + errors + }) + }), + }) + }); + } else { + return state; + } +}; + +const clearErrorsFromSection = (state: SubmissionObjectState, action: ClearSectionErrorsAction): SubmissionObjectState => { + const { submissionId, sectionId } = action.payload; + + if (hasValue(state[ submissionId ].sections[ sectionId ])) { + const errors = []; // clear the errors + + return Object.assign({}, state, { + [ submissionId ]: Object.assign({}, state[ submissionId ], { + sections: Object.assign({}, state[ submissionId ].sections, { + [ sectionId ]: Object.assign({}, state[ action.payload.submissionId ].sections [ action.payload.sectionId ], { + errors + }) + }), + }) + }); + } else { + return state; + } +}; + +// ------ Submission functions ------ // + +/** + * Init a SubmissionObjectState. + * + * @param state + * the current state + * @param action + * an LoadSubmissionFormAction + * @return SubmissionObjectState + * the new state, with the section removed. + */ +function initSubmission(state: SubmissionObjectState, action: LoadSubmissionFormAction | ResetSubmissionFormAction): SubmissionObjectState { + + const newState = Object.assign({}, state); + newState[ action.payload.submissionId ] = { + collection: action.payload.collectionId, + definition: action.payload.submissionDefinition.name, + selfUrl: action.payload.selfUrl, + activeSection: null, + sections: Object.create(null), + isLoading: true, + savePending: false, + depositPending: false, + }; + return newState; +} + +/** + * Reset submission. + * + * @param state + * the current state + * @param action + * an ResetSubmissionFormAction + * @return SubmissionObjectState + * the new state, with the section removed. + */ +function resetSubmission(state: SubmissionObjectState, action: ResetSubmissionFormAction): SubmissionObjectState { + if (hasValue(state[ action.payload.submissionId ])) { + return Object.assign({}, state, { + [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { + sections: Object.create(null), + isLoading: true + }) + }); + } else { + return state; + } +} + +/** + * Set submission loading to false. + * + * @param state + * the current state + * @param action + * an CompleteInitSubmissionFormAction + * @return SubmissionObjectState + * the new state, with the section removed. + */ +function completeInit(state: SubmissionObjectState, action: CompleteInitSubmissionFormAction): SubmissionObjectState { + if (hasValue(state[ action.payload.submissionId ])) { + return Object.assign({}, state, { + [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { + isLoading: false + }) + }); + } else { + return state; + } +} + +/** + * Set submission save flag to true + * + * @param state + * the current state + * @param action + * an SaveSubmissionFormAction + * @return SubmissionObjectState + * the new state, with the flag set to true. + */ +function saveSubmission(state: SubmissionObjectState, action: SaveSubmissionFormAction): SubmissionObjectState { + if (hasValue(state[ action.payload.submissionId ])) { + return Object.assign({}, state, { + [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { + activeSection: state[ action.payload.submissionId ].activeSection, + sections: state[ action.payload.submissionId ].sections, + isLoading: state[ action.payload.submissionId ].isLoading, + savePending: true, + }) + }); + } else { + return state; + } +} + +/** + * Set submission save flag to false. + * + * @param state + * the current state + * @param action + * an CompleteSaveSubmissionFormAction | SaveSubmissionFormSuccessAction | SaveSubmissionFormErrorAction + * @return SubmissionObjectState + * the new state, with the flag set to false. + */ +function completeSave(state: SubmissionObjectState, + action: CompleteSaveSubmissionFormAction + | SaveSubmissionFormSuccessAction + | SaveSubmissionFormErrorAction + | SaveSubmissionSectionFormSuccessAction + | SaveSubmissionSectionFormErrorAction): SubmissionObjectState { + if (hasValue(state[ action.payload.submissionId ])) { + return Object.assign({}, state, { + [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { + savePending: false, + }) + }); + } else { + return state; + } +} + +/** + * Set deposit flag to true + * + * @param state + * the current state + * @param action + * an DepositSubmissionAction + * @return SubmissionObjectState + * the new state, with the deposit flag changed. + */ +function startDeposit(state: SubmissionObjectState, action: DepositSubmissionAction): SubmissionObjectState { + if (hasValue(state[ action.payload.submissionId ])) { + return Object.assign({}, state, { + [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { + savePending: false, + depositPending: true, + }) + }); + } else { + return state; + } +} + +/** + * Set deposit flag to false + * + * @param state + * the current state + * @param action + * an DepositSubmissionSuccessAction or DepositSubmissionErrorAction + * @return SubmissionObjectState + * the new state, with the deposit flag changed. + */ +function endDeposit(state: SubmissionObjectState, action: DepositSubmissionSuccessAction | DepositSubmissionErrorAction): SubmissionObjectState { + if (hasValue(state[ action.payload.submissionId ])) { + return Object.assign({}, state, { + [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { + depositPending: false, + }) + }); + } else { + return state; + } +} + +/** + * Init a SubmissionObjectState. + * + * @param state + * the current state + * @param action + * an LoadSubmissionFormAction + * @return SubmissionObjectState + * the new state, with the section removed. + */ +function changeCollection(state: SubmissionObjectState, action: ChangeSubmissionCollectionAction): SubmissionObjectState { + return Object.assign({}, state, { + [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { + collection: action.payload.collectionId + }) + }); +} + +// ------ Section functions ------ // + +/** + * Set submission active section. + * + * @param state + * the current state + * @param action + * an SetActiveSectionAction + * @return SubmissionObjectState + * the new state, with the active section. + */ +function setActiveSection(state: SubmissionObjectState, action: SetActiveSectionAction): SubmissionObjectState { + if (hasValue(state[ action.payload.submissionId ])) { + return Object.assign({}, state, { + [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { + activeSection: action.payload.sectionId, + sections: state[ action.payload.submissionId ].sections, + isLoading: state[ action.payload.submissionId ].isLoading, + savePending: state[ action.payload.submissionId ].savePending, + }) + }); + } else { + return state; + } +} + +/** + * Set a section enabled. + * + * @param state + * the current state + * @param action + * an InitSectionAction + * @return SubmissionObjectState + * the new state, with the section removed. + */ +function initSection(state: SubmissionObjectState, action: InitSectionAction): SubmissionObjectState { + if (hasValue(state[ action.payload.submissionId ])) { + return Object.assign({}, state, { + [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { + sections: Object.assign({}, state[ action.payload.submissionId ].sections, { + [ action.payload.sectionId ]: { + header: action.payload.header, + config: action.payload.config, + mandatory: action.payload.mandatory, + sectionType: action.payload.sectionType, + visibility: action.payload.visibility, + collapsed: false, + enabled: action.payload.enabled, + data: action.payload.data, + errors: action.payload.errors || [], + isLoading: false, + isValid: false + } + }) + }) + }); + } else { + return state; + } +} + +/** + * Update section's data. + * + * @param state + * the current state + * @param action + * an UpdateSectionDataAction + * @return SubmissionObjectState + * the new state, with the section's data updated. + */ +function updateSectionData(state: SubmissionObjectState, action: UpdateSectionDataAction): SubmissionObjectState { + if (isNotEmpty(state[ action.payload.submissionId ]) + && isNotEmpty(state[ action.payload.submissionId ].sections[ action.payload.sectionId])) { + return Object.assign({}, state, { + [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { + sections: Object.assign({}, state[ action.payload.submissionId ].sections, { + [ action.payload.sectionId ]: Object.assign({}, state[ action.payload.submissionId ].sections [ action.payload.sectionId ], { + enabled: true, + data: action.payload.data, + errors: action.payload.errors + }) + }) + }) + }); + } else { + return state; + } +} + +/** + * Remove section's errors. + * + * @param state + * the current state + * @param action + * an RemoveSectionErrorsAction + * @return SubmissionObjectState + * the new state, with the section's errors updated. + */ +function removeSectionErrors(state: SubmissionObjectState, action: RemoveSectionErrorsAction): SubmissionObjectState { + if (isNotEmpty(state[ action.payload.submissionId ]) + && isNotEmpty(state[ action.payload.submissionId ].sections[ action.payload.sectionId])) { + return Object.assign({}, state, { + [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { + sections: Object.assign({}, state[ action.payload.submissionId ].sections, { + [ action.payload.sectionId ]: Object.assign({}, state[ action.payload.submissionId ].sections [ action.payload.sectionId ], { + errors: [] + }) + }) + }) + }); + } else { + return state; + } +} + +/** + * Set a section state. + * + * @param state + * the current state + * @param action + * an DisableSectionAction + * @param enabled + * enabled or disabled section. + * @return SubmissionObjectState + * the new state, with the section removed. + */ +function changeSectionState(state: SubmissionObjectState, action: EnableSectionAction | DisableSectionAction, enabled: boolean): SubmissionObjectState { + if (hasValue(state[ action.payload.submissionId ].sections[ action.payload.sectionId ])) { + return Object.assign({}, state, { + [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { + // sections: deleteProperty(state[ action.payload.submissionId ].sections, action.payload.sectionId), + sections: Object.assign({}, state[ action.payload.submissionId ].sections, { + [ action.payload.sectionId ]: Object.assign({}, state[ action.payload.submissionId ].sections [ action.payload.sectionId ], { + enabled + }) + }) + }) + }); + } else { + return state; + } +} + +/** + * Set the section validity. + * + * @param state + * the current state + * @param action + * an LoadSubmissionFormAction + * @return SubmissionObjectState + * the new state, with the section new validity status. + */ +function setIsValid(state: SubmissionObjectState, action: SectionStatusChangeAction): SubmissionObjectState { + if (hasValue(state[ action.payload.submissionId ].sections[ action.payload.sectionId ])) { + return Object.assign({}, state, { + [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { + sections: Object.assign({}, state[ action.payload.submissionId ].sections, + Object.assign({}, { + [ action.payload.sectionId ]: Object.assign({}, state[ action.payload.submissionId ].sections [ action.payload.sectionId ], { + isValid: action.payload.status + }) + }) + ) + }) + }); + } else { + return state; + } +} + +// ------ Upload file functions ------ // + +/** + * Set a new bitstream. + * + * @param state + * the current state + * @param action + * a NewUploadedFileAction action + * @return SubmissionObjectState + * the new state, with the new bitstream. + */ +function newFile(state: SubmissionObjectState, action: NewUploadedFileAction): SubmissionObjectState { + const filesData = state[ action.payload.submissionId ].sections[ action.payload.sectionId ].data as WorkspaceitemSectionUploadObject; + if (isNotUndefined(filesData.files) + && !hasValue(filesData.files[ action.payload.fileId ])) { + const newData = []; + newData[ action.payload.fileId ] = action.payload.data; + return Object.assign({}, state, { + [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { + activeSection: state[ action.payload.submissionId ].activeSection, + sections: Object.assign({}, state[ action.payload.submissionId ].sections, + Object.assign({}, { + [ action.payload.sectionId ]: { + data: Object.assign({}, state[ action.payload.submissionId ].sections[ action.payload.sectionId ].data, { + files: Object.assign({}, + filesData.files, + newData) + }), + isValid: state[ action.payload.submissionId ].sections[ action.payload.sectionId ].isValid, + errors: state[ action.payload.submissionId ].sections[ action.payload.sectionId ].errors + } + } + ) + ), + isLoading: state[ action.payload.submissionId ].isLoading, + savePending: state[ action.payload.submissionId ].savePending, + }) + }); + } else { + const newData = []; + newData[ action.payload.fileId ] = action.payload.data; + return Object.assign({}, state, { + [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { + activeSection: state[ action.payload.submissionId ].activeSection, + sections: Object.assign({}, state[ action.payload.submissionId ].sections, + Object.assign({}, { + [ action.payload.sectionId ]: { + data: Object.assign({}, state[ action.payload.submissionId ].sections[ action.payload.sectionId ].data, { + files: newData + }), + isValid: state[ action.payload.submissionId ].sections[ action.payload.sectionId ].isValid, + errors: state[ action.payload.submissionId ].sections[ action.payload.sectionId ].errors + } + }) + ), + isLoading: state[ action.payload.submissionId ].isLoading, + savePending: state[ action.payload.submissionId ].savePending, + }) + }); + } +} + +/** + * Edit a bitstream. + * + * @param state + * the current state + * @param action + * a EditFileDataAction action + * @return SubmissionObjectState + * the new state, with the edited bitstream. + */ +function editFileData(state: SubmissionObjectState, action: EditFileDataAction): SubmissionObjectState { + const filesData = state[ action.payload.submissionId ].sections[ action.payload.sectionId ].data as WorkspaceitemSectionUploadObject; + if (hasValue(filesData.files)) { + const fileIndex = findKey( + filesData.files, + { uuid: action.payload.fileId }); + if (isNotNull(fileIndex)) { + return Object.assign({}, state, { + [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { + activeSection: state[ action.payload.submissionId ].activeSection, + sections: Object.assign({}, state[ action.payload.submissionId ].sections, + Object.assign({}, { + [ action.payload.sectionId ]: Object.assign({}, state[ action.payload.submissionId ].sections [ action.payload.sectionId ], { + data: Object.assign({}, state[ action.payload.submissionId ].sections[ action.payload.sectionId ].data, { + files: Object.assign({}, + filesData.files, { + [ fileIndex ]: action.payload.data + }) + }) + }) + }) + // Object.assign({}, state[action.payload.submissionId].sections[action.payload.sectionId],{ + // [ action.payload.sectionId ]: { + // data: Object.assign({}, state[ action.payload.submissionId ].sections[ action.payload.sectionId ].data, { + // files: Object.assign({}, + // filesData.files, { + // [ fileIndex ]: action.payload.data + // }) + // }) + // } + // } + // ) + ), + isLoading: state[ action.payload.submissionId ].isLoading, + savePending: state[ action.payload.submissionId ].savePending, + }) + }); + } + } + return state; +} + +/** + * Delete a bitstream. + * + * @param state + * the current state + * @param action + * a DeleteUploadedFileAction action + * @return SubmissionObjectState + * the new state, with the bitstream removed. + */ +function deleteFile(state: SubmissionObjectState, action: DeleteUploadedFileAction): SubmissionObjectState { + const filesData = state[ action.payload.submissionId ].sections[ action.payload.sectionId ].data as WorkspaceitemSectionUploadObject; + if (hasValue(filesData.files)) { + const fileIndex = findKey( + filesData.files, + {uuid: action.payload.fileId}); + if (isNotNull(fileIndex)) { + return Object.assign({}, state, { + [ action.payload.submissionId ]: Object.assign({}, state[action.payload.submissionId], { + activeSection: state[ action.payload.submissionId ].activeSection, + sections: Object.assign({}, state[action.payload.submissionId].sections, + Object.assign({}, { + [ action.payload.sectionId ]: Object.assign({}, state[ action.payload.submissionId ].sections [ action.payload.sectionId ], { + data: Object.assign({}, state[ action.payload.submissionId ].sections[ action.payload.sectionId ].data, { + files: deleteProperty(filesData.files, fileIndex) + }) + }) + }) + // Object.assign({}, state[action.payload.submissionId].sections[action.payload.sectionId], { + // [ action.payload.sectionId ]: { + // data: Object.assign({}, state[action.payload.submissionId].sections[action.payload.sectionId].data, { + // files: deleteProperty(filesData.files, fileIndex) + // }) + // } + // } + // ) + ), + isLoading: state[action.payload.submissionId].isLoading, + savePending: state[action.payload.submissionId].savePending, + }) + }); + } + } + return state; +} + +/** + * Update a Workspace deduplication match. + * + * @param state + * the current state + * @param action + * a SetWorkspaceDuplicatedAction or SetWorkflowDuplicatedAction + * @return SubmissionObjectState + * the new state, with the match parameter changed. + */ +function updateDeduplication(state: SubmissionObjectState, action: SetWorkspaceDuplicatedAction|SetWorkflowDuplicatedAction): SubmissionObjectState { + const matches = Object.assign([], (state[(action.payload as any).submissionId].sections.deduplication.data as any).matches); + const newMatch = (action.payload as any).data; + matches.forEach( (match, i) => { + if (i === action.payload.index) { + matches.splice(i, 1, Object.assign({}, match, newMatch)); + return; + } + }); + // const updatedMatches = Object.assign({}, matches, newMatch); + return Object.assign({}, state, {[(action.payload as any).submissionId]: {sections: {deduplication: {data: {matches}}}}}); +} diff --git a/src/app/submission/sections/container/section-container.component.html b/src/app/submission/sections/container/section-container.component.html new file mode 100644 index 0000000000..898a4e5596 --- /dev/null +++ b/src/app/submission/sections/container/section-container.component.html @@ -0,0 +1,47 @@ +
+ + + + {{ 'submission.sections.'+sectionData.header | translate }} +
+ + + + + + + + + +
+
+ +
+ +
+
+ +
+
+
+
+
diff --git a/src/app/submission/sections/container/section-container.component.scss b/src/app/submission/sections/container/section-container.component.scss new file mode 100644 index 0000000000..f71765be88 --- /dev/null +++ b/src/app/submission/sections/container/section-container.component.scss @@ -0,0 +1,11 @@ +@import '../../../../styles/variables'; + +:host /deep/ .card { + margin-bottom: 0.5rem; +} + +.section-focus { + // box-shadow: $btn-focus-box-shadow; + border-radius: 0.25rem; + box-shadow: $btn-focus-box-shadow; +} diff --git a/src/app/submission/sections/container/section-container.component.ts b/src/app/submission/sections/container/section-container.component.ts new file mode 100644 index 0000000000..e97d348781 --- /dev/null +++ b/src/app/submission/sections/container/section-container.component.ts @@ -0,0 +1,52 @@ +import { Component, Injector, Input, OnInit, ViewChild } from '@angular/core'; + +import { Store } from '@ngrx/store'; + +import { SectionsDirective } from '../sections.directive'; +import { SectionDataObject } from '../models/section-data.model'; +import { SubmissionState } from '../../submission.reducers'; +import { rendersSectionType } from '../sections-decorator'; +import { SectionsType } from '../sections-type'; +import { AlertType } from '../../../shared/alerts/aletrs-type'; + +@Component({ + selector: 'ds-submission-form-section-container', + templateUrl: './section-container.component.html', + styleUrls: ['./section-container.component.scss'], +}) +export class SectionContainerComponent implements OnInit { + @Input() collectionId: string; + @Input() sectionData: SectionDataObject; + @Input() submissionId: string; + + public AlertTypeEnum = AlertType; + public active = true; + public objectInjector: Injector; + public sectionComponentType: SectionsType; + + @ViewChild('sectionRef') sectionRef: SectionsDirective; + + constructor(private injector: Injector, private store: Store) { + } + + ngOnInit() { + this.objectInjector = Injector.create({ + providers: [ + {provide: 'collectionIdProvider', useFactory: () => (this.collectionId), deps: []}, + {provide: 'sectionDataProvider', useFactory: () => (this.sectionData), deps: []}, + {provide: 'submissionIdProvider', useFactory: () => (this.submissionId), deps: []}, + ], + parent: this.injector + }); + } + + public removeSection(event) { + event.preventDefault(); + event.stopPropagation(); + this.sectionRef.removeSection(this.submissionId, this.sectionData.id); + } + + getSectionContent(): string { + return rendersSectionType(this.sectionData.sectionType); + } +} diff --git a/src/app/submission/sections/deduplication/deduplication.service.ts b/src/app/submission/sections/deduplication/deduplication.service.ts new file mode 100644 index 0000000000..5b0a029f2a --- /dev/null +++ b/src/app/submission/sections/deduplication/deduplication.service.ts @@ -0,0 +1,51 @@ +import { Store } from '@ngrx/store'; +import { SubmissionState } from '../../submission.reducers'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs/Observable'; +import { HttpHeaders } from '@angular/common/http'; +import { HttpOptions } from '../../../core/dspace-rest-v2/dspace-rest-v2.service'; + +@Injectable() +export class DeduplicationService { + + constructor(private store: Store) { + } + + // setWorkspaceDuplicated(payload: any): Observable { + // const options: HttpOptions = Object.create({}); + // let headers = new HttpHeaders(); + // headers = headers.append('Content-Type', 'application/json'); + // options.headers = headers; + // // TODO REST CALL + // // return this.restService.postToEndpoint('workspace/deduplication', payload, null, options); + // return Observable.of(payload); + // } + + setWorkspaceDuplicationSuccess(payload: any): void { + // TODO + + } + + setWorkspaceDuplicationError(payload: any): void { + // TODO + } + + // setWorkflowDuplicated(payload: any): Observable { + // const options: HttpOptions = Object.create({}); + // let headers = new HttpHeaders(); + // headers = headers.append('Content-Type', 'application/json'); + // options.headers = headers; + // // TODO REST CALL + // // return this.restService.postToEndpoint('workflow/deduplication', payload, null, options); + // return Observable.of(payload); + // } + + setWorkflowDuplicationSuccess(payload: any): void { + // TODO Update the redux store + + } + + setWorkflowDuplicationError(payload: any): void { + // TODO Update the redux store + } +} diff --git a/src/app/submission/sections/deduplication/match/deduplication-match.component.html b/src/app/submission/sections/deduplication/match/deduplication-match.component.html new file mode 100644 index 0000000000..2ec4385ddf --- /dev/null +++ b/src/app/submission/sections/deduplication/match/deduplication-match.component.html @@ -0,0 +1,89 @@ + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+ + + +
+ +
+ +
+ + + + + + + diff --git a/src/app/submission/sections/deduplication/match/deduplication-match.component.ts b/src/app/submission/sections/deduplication/match/deduplication-match.component.ts new file mode 100644 index 0000000000..a85239ef5c --- /dev/null +++ b/src/app/submission/sections/deduplication/match/deduplication-match.component.ts @@ -0,0 +1,169 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { Item } from '../../../../core/shared/item.model'; +import { DeduplicationSchema } from '../../../../core/submission/models/workspaceitem-section-deduplication.model'; +import { SubmissionService } from '../../../submission.service'; +import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { SubmissionState } from '../../../submission.reducers'; +import { DeduplicationService } from '../deduplication.service'; +import { JsonPatchOperationsBuilder } from '../../../../core/json-patch/builder/json-patch-operations-builder'; +import { JsonPatchOperationPathCombiner } from '../../../../core/json-patch/builder/json-patch-operation-path-combiner'; +import { TranslateService } from '@ngx-translate/core'; +import { Observable } from 'rxjs/Observable'; +import { SubmissionScopeType } from '../../../../core/submission/submission-scope-type'; + +@Component({ + selector: 'ds-deduplication-match', + templateUrl: 'deduplication-match.component.html', +}) + +export class DeduplicationMatchComponent implements OnInit { + @Input() + sectionId: string; + @Input() + match: DeduplicationSchema; + @Input() + submissionId: string; + @Input() + index: string; + + object = {hitHighlights: []}; + item: Item; + isWorkFlow = false; + showSubmitterDecision = false; + submitterDecisionTxt: string; + + decidedYet: boolean; + + closeResult: string; // for modal + rejectForm: FormGroup; + modalRef: NgbModalRef; + pathCombiner: JsonPatchOperationPathCombiner; + + duplicatedBtnLabel: Observable; + submitterDecisionLabel: Observable; + + constructor(private deduplicationService: DeduplicationService, + private submissionService: SubmissionService, + private modalService: NgbModal, + private formBuilder: FormBuilder, + private store: Store, + protected operationsBuilder: JsonPatchOperationsBuilder, + private translate: TranslateService) { + } + + ngOnInit(): void { + if ((this.match.matchObject as any).item) { + // WSI & WFI + this.item = Object.assign(new Item(), (this.match.matchObject as any).item); + } else { + // Item + this.item = Object.assign(new Item(), this.match.matchObject); + } + + this.isWorkFlow = this.submissionService.getSubmissionScope() === SubmissionScopeType.WorkflowItem; + + this.rejectForm = this.formBuilder.group({ + reason: ['', Validators.required] + }); + + this.decidedYet = this.isWorkFlow ? + this.match.workflowDecision !== null ? true : false + : this.match.submitterDecision !== null ? true : false; + + if (this.match.submitterDecision) { + if (this.match.submitterDecision === 'verify') { + this.submitterDecisionTxt = 'It\'s a duplicate'; + } else { + this.submitterDecisionTxt = 'It\'s not a duplicate'; + } + } else { + this.submitterDecisionTxt = 'Not decided'; + } + + this.pathCombiner = new JsonPatchOperationPathCombiner('sections', this.sectionId, 'matches', this.index); + + this.duplicatedBtnLabel = this.isWorkFlow ? + this.translate.get('submission.sections.deduplication.duplicated_ctrl') + : this.translate.get('submission.sections.deduplication.duplicated'); + + this.submitterDecisionLabel = this.isWorkFlow ? + this.translate.get('submission.sections.deduplication.submitter_decision') + : this.translate.get('submission.sections.deduplication.your_decision'); + + } + + setAsDuplicated() { + console.log('Setting item #' + this.item.uuid + ' as duplicated...'); + this.dispatchAction(true); + this.modalRef.dismiss(); + } + + setAsNotDuplicated() { + console.log('Setting item #' + this.item.uuid + ' as not duplicated...'); + this.dispatchAction(false); + } + + clearDecision() { + console.log('Clearing item #' + this.item.uuid + ' from previous decision...'); + + } + + private dispatchAction(duplicated: boolean, clear?: boolean): void { + const payload = { + submissionId: this.submissionId, + index: this.index, + data: {} as DeduplicationSchema + }; + + // Call workflow action + const decision = clear ? null : duplicated ? 'verify' : 'reject'; + const pathDecision = this.isWorkFlow ? 'workflowDecision' : 'submitterDecision'; + this.operationsBuilder.add(this.pathCombiner.getPath(pathDecision), decision, false, true); + + if (!clear && duplicated) { + const note = this.rejectForm.get('reason').value; + const pathNote = this.isWorkFlow ? 'workflowNote' : 'submitterNote'; + this.operationsBuilder.add(this.pathCombiner.getPath(pathNote), note, false, true); + } + + // const now = new Date(); + // const time = now.getUTCFullYear() + '/' + now.getUTCMonth() + 1 + '/' + now.getDay(); + + // if (this.isWorkFlow) { + // // Call workflow action + // payload.data.workflowDecision = clear ? null : duplicated ? 'verify' : 'reject'; + // // payload.data.workflowTime = time; + // if (!clear && duplicated) { + // const note = this.rejectForm.get('reason').value; + // payload.data.workflowNote = note; + // } + // // Dispatch WorkFLOW action + // // this.store.dispatch(new SetWorkflowDuplicatedAction(payload)); + // const path = 'workflowDecision' + // this.operationsBuilder.add(this.pathCombiner.getPath(path), payload.data.workflowDecision, false, true); + // + // } else { + // // Call workspace action + // payload.data.submitterDecision = clear ? null : duplicated ? 'verify' : 'reject'; + // // payload.data.submitterTime = time; + // if (!clear && duplicated) { + // const note = this.rejectForm.get('reason').value; + // payload.data.submitterNote = note; + // } + // // Dispatch workSPACE action + // this.store.dispatch(new SetWorkspaceDuplicatedAction(payload)); + // } + } + + toggleSubmitterDecision() { + this.showSubmitterDecision = !this.showSubmitterDecision; + } + + openModal(modal) { + this.rejectForm.reset(); + this.modalRef = this.modalService.open(modal); + } + +} diff --git a/src/app/submission/sections/deduplication/section-deduplication.component.html b/src/app/submission/sections/deduplication/section-deduplication.component.html new file mode 100644 index 0000000000..942d8a75a6 --- /dev/null +++ b/src/app/submission/sections/deduplication/section-deduplication.component.html @@ -0,0 +1,39 @@ + + + +
+
+

No duplicated yet.

+
+
+
+ + + + + + +
    +
  • + + +
  • +
+
+
diff --git a/src/app/submission/sections/deduplication/section-deduplication.component.ts b/src/app/submission/sections/deduplication/section-deduplication.component.ts new file mode 100644 index 0000000000..3d08ccc5c3 --- /dev/null +++ b/src/app/submission/sections/deduplication/section-deduplication.component.ts @@ -0,0 +1,73 @@ +import { SectionsType } from '../sections-type'; +import { ChangeDetectionStrategy, Component, Inject, OnInit } from '@angular/core'; +import { SectionModelComponent } from '../models/section.model'; +import { renderSectionFor } from '../sections-decorator'; +import { SectionDataObject } from '../models/section-data.model'; +import { SubmissionState } from '../../submission.reducers'; +import { Store } from '@ngrx/store'; +import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; +import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +import { submissionSectionDataFromIdSelector } from '../../selectors'; +import { Observable } from 'rxjs/Observable'; +import { isNotEmpty } from '../../../shared/empty.util'; +import { TranslateService } from '@ngx-translate/core'; +import { SubmissionService } from '../../submission.service'; +import { SubmissionScopeType } from '../../../core/submission/submission-scope-type'; + +@Component({ + selector: 'ds-deduplication-section', + // styleUrls: ['./section-deduplication.component.scss'], + templateUrl: './section-deduplication.component.html', + changeDetection: ChangeDetectionStrategy.Default +}) + +@renderSectionFor(SectionsType.Deduplication) +export class DeduplicationSectionComponent extends SectionModelComponent implements OnInit { + public isLoading = true; + public sectionDataObs: Observable; + public matches = []; + + config: PaginationComponentOptions; + sortConfig: SortOptions; + + isWorkFlow = false; + disclaimer: Observable; + + constructor(protected store: Store, + private translate: TranslateService, + private submissionService: SubmissionService, + @Inject('collectionIdProvider') public injectedCollectionId: string, + @Inject('sectionDataProvider') public injectedSectionData: SectionDataObject, + @Inject('submissionIdProvider') public injectedSubmissionId: string) { + super(injectedCollectionId, injectedSectionData, injectedSubmissionId); + } + + ngOnInit() { + this.config = new PaginationComponentOptions(); + this.config.id = 'duplicated_items'; + this.config.pageSize = 2; + this.sortConfig = new SortOptions('dc.title', SortDirection.ASC); + + this.sectionDataObs = this.store.select(submissionSectionDataFromIdSelector(this.submissionId, this.sectionData.id)) + .filter((sd) => isNotEmpty(sd)) + .startWith({matches: []}) + .distinctUntilChanged() + .map((sd) => { + return sd; + }); + + this.isWorkFlow = this.submissionService.getSubmissionScope() === SubmissionScopeType.WorkflowItem; + + this.disclaimer = this.isWorkFlow ? + this.translate.get('submission.sections.deduplication.disclaimer_ctrl') + : this.translate.get('submission.sections.deduplication.disclaimer'); + + this.isLoading = false; + } + + setPage(page) { + console.log('Select page #', page); + this.config.currentPage = page; + } + +} diff --git a/src/app/submission/sections/default/section-default.component.html b/src/app/submission/sections/default/section-default.component.html new file mode 100644 index 0000000000..62f5d179c7 --- /dev/null +++ b/src/app/submission/sections/default/section-default.component.html @@ -0,0 +1,6 @@ +Lorem ipsum dolor sit amet, consectetur adipisci elit, sed eiusmod tempor incidunt ut labore et dolore + magna aliqua. Ut enim ad minim veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut + aliquid ex ea commodi consequatur. Quis aute iure reprehenderit in voluptate velit esse cillum dolore eu fugiat + nulla pariatur. Excepteur sint obcaecat cupiditat non proident, sunt in culpa qui officia deserunt + mollit anim id est laborum. + diff --git a/src/app/submission/sections/default/section-default.component.scss b/src/app/submission/sections/default/section-default.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/submission/sections/default/section-default.component.ts b/src/app/submission/sections/default/section-default.component.ts new file mode 100644 index 0000000000..2a40ecb9a0 --- /dev/null +++ b/src/app/submission/sections/default/section-default.component.ts @@ -0,0 +1,21 @@ +import { Component, Inject } from '@angular/core'; +import { SectionModelComponent } from '../models/section.model'; +import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; +import { SectionDataObject } from '../models/section-data.model'; + +@Component({ + selector: 'ds-submission-section-default', + styleUrls: ['./section-default.component.scss'], + templateUrl: './section-default.component.html', +}) +export class DefaultSectionComponent extends SectionModelComponent { + + protected operationsBuilder: JsonPatchOperationsBuilder; + + constructor(@Inject('collectionIdProvider') public injectedCollectionId: string, + @Inject('sectionDataProvider') public injectedSectionData: SectionDataObject, + @Inject('submissionIdProvider') public injectedSubmissionId: string) { + super(injectedCollectionId, injectedSectionData, injectedSubmissionId); + } + +} diff --git a/src/app/submission/sections/form/form-operations.service.ts b/src/app/submission/sections/form/form-operations.service.ts new file mode 100644 index 0000000000..2ba57e8657 --- /dev/null +++ b/src/app/submission/sections/form/form-operations.service.ts @@ -0,0 +1,242 @@ +import { Injectable } from '@angular/core'; + +import { isEqual, isObject } from 'lodash'; +import { + DYNAMIC_FORM_CONTROL_TYPE_ARRAY, + DYNAMIC_FORM_CONTROL_TYPE_GROUP, + DynamicFormArrayGroupModel, + DynamicFormControlEvent, + DynamicFormControlModel +} from '@ng-dynamic-forms/core'; + +import { isNotEmpty, isNotNull, isNotUndefined, isNull, isUndefined } from '../../../shared/empty.util'; +import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; +import { FormFieldPreviousValueObject } from '../../../shared/form/builder/models/form-field-previous-value-object'; +import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; +import { FormFieldLanguageValueObject } from '../../../shared/form/builder/models/form-field-language-value.model'; +import { DsDynamicInputModel } from '../../../shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model'; +import { AuthorityValueModel } from '../../../core/integration/models/authority-value.model'; +import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; +import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model'; +import { DynamicQualdropModel } from '../../../shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-qualdrop.model'; +import { DynamicGroupModel } from '../../../shared/form/builder/ds-dynamic-form-ui/models/dynamic-group/dynamic-group.model'; + +@Injectable() +export class FormOperationsService { + + constructor(private formBuilder: FormBuilderService, private operationsBuilder: JsonPatchOperationsBuilder) { + } + + dispatchOperationsFromEvent(pathCombiner: JsonPatchOperationPathCombiner, + event: DynamicFormControlEvent, + previousValue: FormFieldPreviousValueObject, + hasStoredValue: boolean) { + switch (event.type) { + case 'remove': + this.dispatchOperationsFromRemoveEvent(pathCombiner, event, previousValue); + break; + case 'change': + this.dispatchOperationsFromChangeEvent(pathCombiner, event, previousValue, hasStoredValue); + break; + default: + break; + } + } + + getArrayIndexFromEvent(event: DynamicFormControlEvent) { + let fieldIndex: number; + if (isNotEmpty(event)) { + if (isNull(event.context)) { + if (isNotNull(event.model.parent)) { + if ((event.model.parent as any).type === DYNAMIC_FORM_CONTROL_TYPE_GROUP) { + if ((event.model.parent as any).parent) { + if ((event.model.parent as any).parent.context) { + if ((event.model.parent as any).parent.context.type === DYNAMIC_FORM_CONTROL_TYPE_ARRAY) { + fieldIndex = (event.model.parent as any).parent.index; + } + } + } + } + } + } else { + fieldIndex = event.context.index; + } + } + return isNotUndefined(fieldIndex) ? fieldIndex : 0; + } + + public getComboboxMap(event): Map { + const metadataValueMap = new Map(); + + (event.model.parent.parent as DynamicFormArrayGroupModel).context.groups.forEach((arrayModel: DynamicFormArrayGroupModel) => { + const groupModel = arrayModel.group[0] as DynamicQualdropModel; + const metadataValueList = metadataValueMap.get(groupModel.qualdropId) ? metadataValueMap.get(groupModel.qualdropId) : []; + if (groupModel.value) { + metadataValueList.push(groupModel.value); + metadataValueMap.set(groupModel.qualdropId, metadataValueList); + } + }); + + return metadataValueMap; + } + + public getFieldPathFromChangeEvent(event: DynamicFormControlEvent) { + const fieldIndex = this.getArrayIndexFromEvent(event); + const fieldId = this.getFieldPathSegmentedFromChangeEvent(event); + return (isNotUndefined(fieldIndex)) ? fieldId + '/' + fieldIndex : fieldId; + } + + public getFieldPathSegmentedFromChangeEvent(event: DynamicFormControlEvent) { + let fieldId; + if (this.formBuilder.isQualdropGroup(event.model.parent as DynamicFormControlModel)) { + fieldId = (event.model.parent as any).qualdropId; + } else { + fieldId = this.formBuilder.getId(event.model); + } + return fieldId; + } + + public getFieldValueFromChangeEvent(event: DynamicFormControlEvent) { + let fieldValue; + const value = (event.model as any).value; + + if (this.formBuilder.isModelInCustomGroup(event.model)) { + fieldValue = (event.model.parent as any).value; + } else if (this.formBuilder.isRelationGroup(event.model)) { + fieldValue = (event.model as DynamicGroupModel).getGroupValue(); + } else if ((event.model as any).hasLanguages) { + const language = (event.model as any).language; + if ((event.model as DsDynamicInputModel).hasAuthority) { + if (Array.isArray(value)) { + value.forEach((authority, index) => { + authority = Object.assign(new AuthorityValueModel(), authority, {language}); + value[index] = authority; + }); + fieldValue = value; + } else { + fieldValue = Object.assign(new AuthorityValueModel(), value, {language}); + } + } else { + // Language without Authority (input, textArea) + fieldValue = new FormFieldMetadataValueObject(value, language); + } + } else if (value instanceof FormFieldLanguageValueObject || value instanceof AuthorityValueModel || isObject(value)) { + fieldValue = value; + } else { + fieldValue = new FormFieldMetadataValueObject(value); + } + + return fieldValue; + } + + public getValueMap(items: any[]): Map { + const metadataValueMap = new Map(); + + items.forEach((item) => { + Object.keys(item) + .forEach((key) => { + const metadataValueList = metadataValueMap.get(key) ? metadataValueMap.get(key) : []; + metadataValueList.push(item[key]); + metadataValueMap.set(key, metadataValueList); + }); + + }); + return metadataValueMap; + } + + protected dispatchOperationsFromRemoveEvent(pathCombiner: JsonPatchOperationPathCombiner, + event: DynamicFormControlEvent, + previousValue: FormFieldPreviousValueObject) { + const path = this.getFieldPathFromChangeEvent(event); + const value = this.getFieldValueFromChangeEvent(event); + if (this.formBuilder.isQualdropGroup(event.model.parent as DynamicFormControlModel)) { + this.dispatchOperationsFromMap(this.getComboboxMap(event), pathCombiner, event, previousValue); + } else if (isNotEmpty(value)) { + this.operationsBuilder.remove(pathCombiner.getPath(path)); + } + } + + protected dispatchOperationsFromChangeEvent(pathCombiner: JsonPatchOperationPathCombiner, + event: DynamicFormControlEvent, + previousValue: FormFieldPreviousValueObject, + hasStoredValue: boolean) { + const path = this.getFieldPathFromChangeEvent(event); + const segmentedPath = this.getFieldPathSegmentedFromChangeEvent(event); + const value = this.getFieldValueFromChangeEvent(event); + // Detect which operation must be dispatched + if (this.formBuilder.isQualdropGroup(event.model.parent as DynamicFormControlModel)) { + // It's a qualdrup model + this.dispatchOperationsFromMap(this.getComboboxMap(event), pathCombiner, event, previousValue); + } else if (this.formBuilder.isRelationGroup(event.model)) { + // It's a relation model + this.dispatchOperationsFromMap(this.getValueMap(value), pathCombiner, event, previousValue); + } else if (this.formBuilder.hasArrayGroupValue(event.model)) { + // Model has as value an array, so dispatch an add operation with entire block of values + this.operationsBuilder.add( + pathCombiner.getPath(segmentedPath), + value, true); + } else if (previousValue.isPathEqual(this.formBuilder.getPath(event.model)) || hasStoredValue) { + // Here model has a previous value changed or stored in the server + if (!value.hasValue()) { + // New value is empty, so dispatch a remove operation + if (this.getArrayIndexFromEvent(event) === 0) { + this.operationsBuilder.remove(pathCombiner.getPath(segmentedPath)); + } else { + this.operationsBuilder.remove(pathCombiner.getPath(path)); + } + } else { + // New value is not equal from the previous one, so dispatch a replace operation + this.operationsBuilder.replace( + pathCombiner.getPath(path), + value); + } + 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) { + // 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( + pathCombiner.getPath(segmentedPath), + value, true); + } else { + // Model is part of an array model but is not the first item, + // so dispatch an add operation that add a value to an existent metadata + this.operationsBuilder.add( + pathCombiner.getPath(path), + value); + } + } + } + + protected dispatchOperationsFromMap(valueMap: Map, + pathCombiner: JsonPatchOperationPathCombiner, + event: DynamicFormControlEvent, + previousValue: FormFieldPreviousValueObject) { + const currentValueMap = valueMap; + if (previousValue.isPathEqual(this.formBuilder.getPath(event.model))) { + previousValue.value.forEach((entry, index) => { + const currentValue = currentValueMap.get(index); + if (currentValue) { + if (!isEqual(entry, currentValue)) { + this.operationsBuilder.add(pathCombiner.getPath(index), currentValue, true); + } + currentValueMap.delete(index); + } else if (!currentValue) { + this.operationsBuilder.remove(pathCombiner.getPath(index)); + } + }); + } + currentValueMap.forEach((entry: any[], index) => { + if (entry.length === 1 && isNull(entry[0])) { + // The last item of the group has been deleted so make a remove op + this.operationsBuilder.remove(pathCombiner.getPath(index)); + } else { + this.operationsBuilder.add(pathCombiner.getPath(index), entry, true); + } + }); + + previousValue.delete(); + } +} diff --git a/src/app/submission/sections/form/section-form.component.html b/src/app/submission/sections/form/section-form.component.html new file mode 100644 index 0000000000..166e52675b --- /dev/null +++ b/src/app/submission/sections/form/section-form.component.html @@ -0,0 +1,9 @@ + + diff --git a/src/app/submission/sections/form/section-form.component.scss b/src/app/submission/sections/form/section-form.component.scss new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/src/app/submission/sections/form/section-form.component.scss @@ -0,0 +1 @@ + diff --git a/src/app/submission/sections/form/section-form.component.ts b/src/app/submission/sections/form/section-form.component.ts new file mode 100644 index 0000000000..b5dc2c3dee --- /dev/null +++ b/src/app/submission/sections/form/section-form.component.ts @@ -0,0 +1,248 @@ +import { ChangeDetectorRef, Component, Inject, OnDestroy, ViewChild } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { DynamicFormControlEvent, DynamicFormControlModel } from '@ng-dynamic-forms/core'; + +import { isEqual } from 'lodash'; + +import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; +import { FormComponent } from '../../../shared/form/form.component'; +import { FormService } from '../../../shared/form/form.service'; +import { SaveSubmissionFormAction, SectionStatusChangeAction, } from '../../objects/submission-objects.actions'; +import { SectionModelComponent } from '../models/section.model'; +import { SubmissionState } from '../../submission.reducers'; +import { SubmissionFormsConfigService } from '../../../core/config/submission-forms-config.service'; +import { hasValue, isNotEmpty, isNotUndefined, isUndefined } from '../../../shared/empty.util'; +import { ConfigData } from '../../../core/config/config-data'; +import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; +import { submissionSectionDataFromIdSelector, submissionSectionFromIdSelector } from '../../selectors'; +import { SubmissionFormsModel } from '../../../core/shared/config/config-submission-forms.model'; +import { SubmissionSectionError, SubmissionSectionObject } from '../../objects/submission-objects.reducer'; +import { FormFieldPreviousValueObject } from '../../../shared/form/builder/models/form-field-previous-value-object'; +import { WorkspaceitemSectionDataType } from '../../../core/submission/models/workspaceitem-sections.model'; +import { Subscription } from 'rxjs/Subscription'; +import { GLOBAL_CONFIG } from '../../../../config'; +import { GlobalConfig } from '../../../../config/global-config.interface'; +import { SectionDataObject } from '../models/section-data.model'; +import { renderSectionFor } from '../sections-decorator'; +import { SectionsType } from '../sections-type'; +import { SubmissionService } from '../../submission.service'; +import { FormOperationsService } from './form-operations.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { SectionsService } from '../sections.service'; +import { difference } from '../../../shared/object.util'; + +@Component({ + selector: 'ds-submission-section-form', + styleUrls: ['./section-form.component.scss'], + templateUrl: './section-form.component.html', +}) +@renderSectionFor(SectionsType.SubmissionForm) +export class FormSectionComponent extends SectionModelComponent implements OnDestroy { + + public formId; + public formModel: DynamicFormControlModel[]; + public isUpdating = false; + public isLoading = true; + + protected formConfig: SubmissionFormsModel; + protected formData: any = Object.create({}); + protected pathCombiner: JsonPatchOperationPathCombiner; + protected previousValue: FormFieldPreviousValueObject = new FormFieldPreviousValueObject(); + protected subs: Subscription[] = []; + + @ViewChild('formRef') private formRef: FormComponent; + + constructor(protected cdr: ChangeDetectorRef, + protected formBuilderService: FormBuilderService, + protected formOperationsService: FormOperationsService, + protected formService: FormService, + protected formConfigService: SubmissionFormsConfigService, + protected notificationsService: NotificationsService, + protected store: Store, + protected sectionService: SectionsService, + protected submissionService: SubmissionService, + protected translate: TranslateService, + @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + @Inject('collectionIdProvider') public injectedCollectionId: string, + @Inject('sectionDataProvider') public injectedSectionData: SectionDataObject, + @Inject('submissionIdProvider') public injectedSubmissionId: string) { + super(injectedCollectionId, injectedSectionData, injectedSubmissionId); + } + + ngOnInit() { + this.pathCombiner = new JsonPatchOperationPathCombiner('sections', this.sectionData.id); + this.formConfigService.getConfigByHref(this.sectionData.config) + .flatMap((config: ConfigData) => config.payload) + .subscribe((config: SubmissionFormsModel) => { + this.formConfig = config; + this.formId = this.formService.getUniqueId(this.sectionData.id); + this.store.select(submissionSectionDataFromIdSelector(this.submissionId, this.sectionData.id)) + .take(1) + .subscribe((sectionData: WorkspaceitemSectionDataType) => { + if (isUndefined(this.formModel)) { + this.sectionData.errors = []; + // Is the first loading so init form + this.initForm(sectionData); + this.sectionData.data = sectionData; + this.subscriptions(); + this.isLoading = false; + this.cdr.detectChanges(); + } + }) + }); + } + + ngOnDestroy() { + this.subs + .filter((subscription) => hasValue(subscription)) + .forEach((subscription) => subscription.unsubscribe()); + } + + hasMetadataEnrichment(sectionData): boolean { + const diffResult = []; + + // compare current form data state with section data retrieved from store + const diffObj = difference(sectionData, this.formData); + + // iterate over differences to check whether they are actually different + Object.keys(diffObj) + .forEach((key) => { + diffObj[key].forEach((value) => { + if (value.hasOwnProperty('value')) { + diffResult.push(value); + } + }); + }); + return isNotEmpty(diffResult); + } + + initForm(sectionData: WorkspaceitemSectionDataType) { + try { + this.formModel = this.formBuilderService.modelFromConfiguration( + this.formConfig, + this.collectionId, + sectionData, + this.submissionService.getSubmissionScope()); + } catch (e) { + this.translate.get('error.submission.sections.init-form-error') + .subscribe((msg) => { + const sectionError: SubmissionSectionError = { + message: msg + e.toString(), + path: '/sections/' + this.sectionData.id + }; + this.sectionService.setSectionError(this.submissionId, this.sectionData.id, [sectionError]) + }) + + } + } + + updateForm(sectionData: WorkspaceitemSectionDataType, errors: SubmissionSectionError[]) { + + if (isNotEmpty(sectionData) && !isEqual(sectionData, this.sectionData.data) && this.hasMetadataEnrichment(sectionData)) { + this.translate.get('submission.sections.general.metadata-extracted', {sectionId: this.sectionData.id}) + .take(1) + .subscribe((m) => { + this.notificationsService.info(null, m, null, true); + }); + this.isUpdating = true; + this.formModel = null; + this.cdr.detectChanges(); + this.initForm(sectionData); + this.checksForErrors(errors); + this.sectionData.data = sectionData; + this.isUpdating = false; + this.cdr.detectChanges(); + } else if (isNotEmpty(errors) || isNotEmpty(this.sectionData.errors)) { + this.checksForErrors(errors); + } + + } + + checksForErrors(errors: SubmissionSectionError[]) { + this.formService.isFormInitialized(this.formId) + .filter((status: boolean) => status === true && !this.isUpdating) + .take(1) + .subscribe(() => { + this.sectionService.checkSectionErrors(this.submissionId, this.sectionData.id, this.formId, errors, this.sectionData.errors); + this.sectionData.errors = errors; + this.cdr.detectChanges(); + }); + } + + subscriptions() { + this.subs.push( + /** + * Subscribe to form status + */ + this.formService.isValid(this.formId) + .filter((formValid) => isNotUndefined(formValid)) + .filter((formValid) => formValid !== this.valid) + .subscribe((formState) => { + this.valid = formState; + this.store.dispatch(new SectionStatusChangeAction(this.submissionId, this.sectionData.id, this.valid)); + }), + /** + * Subscribe to form's data + */ + this.formService.getFormData(this.formId) + .distinctUntilChanged() + .subscribe((formData) => { + this.formData = formData; + }), + /** + * Subscribe to section state + */ + this.store.select(submissionSectionFromIdSelector(this.submissionId, this.sectionData.id)) + .filter((sectionState: SubmissionSectionObject) => { + return isNotEmpty(sectionState) && (isNotEmpty(sectionState.data) || isNotEmpty(sectionState.errors)) + }) + .distinctUntilChanged() + .subscribe((sectionState: SubmissionSectionObject) => { + this.updateForm(sectionState.data, sectionState.errors); + }) + ) + } + + onChange(event: DynamicFormControlEvent) { + this.formOperationsService.dispatchOperationsFromEvent( + this.pathCombiner, + event, + this.previousValue, + this.hasStoredValue(this.formBuilderService.getId(event.model), this.formOperationsService.getArrayIndexFromEvent(event))); + const metadata = this.formOperationsService.getFieldPathSegmentedFromChangeEvent(event); + const value = this.formOperationsService.getFieldValueFromChangeEvent(event); + + if (this.EnvConfig.submission.autosave.metadata.indexOf(metadata) !== -1 && isNotEmpty(value)) { + this.store.dispatch(new SaveSubmissionFormAction(this.submissionId)); + } + } + + onFocus(event: DynamicFormControlEvent) { + const value = this.formOperationsService.getFieldValueFromChangeEvent(event); + const path = this.formBuilderService.getPath(event.model); + if (this.formBuilderService.hasMappedGroupValue(event.model)) { + this.previousValue.path = path; + this.previousValue.value = this.formOperationsService.getComboboxMap(event); + } else if (isNotEmpty(value) && ((typeof value === 'object' && isNotEmpty(value.value)) || (typeof value === 'string'))) { + this.previousValue.path = path; + this.previousValue.value = value; + } + } + + onRemove(event: DynamicFormControlEvent) { + this.formOperationsService.dispatchOperationsFromEvent( + this.pathCombiner, + event, + this.previousValue, + this.hasStoredValue(this.formBuilderService.getId(event.model), this.formOperationsService.getArrayIndexFromEvent(event))); + } + + hasStoredValue(fieldId, index) { + if (isNotEmpty(this.sectionData.data) && isNotEmpty(this.sectionData.data[index])) { + return this.sectionData.data.hasOwnProperty(fieldId); + } else { + return false; + } + } +} diff --git a/src/app/submission/sections/license/section-license.component.html b/src/app/submission/sections/license/section-license.component.html new file mode 100644 index 0000000000..5388534d04 --- /dev/null +++ b/src/app/submission/sections/license/section-license.component.html @@ -0,0 +1,7 @@ +{{ licenseText }} +

+ diff --git a/src/app/submission/sections/license/section-license.component.scss b/src/app/submission/sections/license/section-license.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/submission/sections/license/section-license.component.ts b/src/app/submission/sections/license/section-license.component.ts new file mode 100644 index 0000000000..65cb99faf6 --- /dev/null +++ b/src/app/submission/sections/license/section-license.component.ts @@ -0,0 +1,139 @@ +import { ChangeDetectorRef, Component, Inject, OnDestroy, OnInit } from '@angular/core'; +import { SectionModelComponent } from '../models/section.model'; +import { Store } from '@ngrx/store'; +import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; +import { CollectionDataService } from '../../../core/data/collection-data.service'; +import { Subscription } from 'rxjs/Subscription'; +import { hasValue, isNotEmpty, isNotNull, isNotUndefined } from '../../../shared/empty.util'; +import { License } from '../../../core/shared/license.model'; +import { RemoteData } from '../../../core/data/remote-data'; +import { Collection } from '../../../core/shared/collection.model'; +import { DynamicCheckboxModel, DynamicFormControlEvent, DynamicFormControlModel } from '@ng-dynamic-forms/core'; +import { SECTION_LICENSE_FORM_MODEL } from './section-license.model'; +import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; +import { RemoveSectionErrorsAction, SectionStatusChangeAction } from '../../objects/submission-objects.actions'; +import { FormService } from '../../../shared/form/form.service'; +import { SubmissionState } from '../../submission.reducers'; +import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; +import { SectionsType } from '../sections-type'; +import { renderSectionFor } from '../sections-decorator'; +import { SectionDataObject } from '../models/section-data.model'; +import { WorkspaceitemSectionLicenseObject } from '../../../core/submission/models/workspaceitem-section-license.model'; +import { SubmissionService } from '../../submission.service'; +import { SectionsService } from '../sections.service'; +import { FormOperationsService } from '../form/form-operations.service'; +import { submissionSectionErrorsFromIdSelector } from '../../selectors'; + +@Component({ + selector: 'ds-submission-section-license', + styleUrls: ['./section-license.component.scss'], + templateUrl: './section-license.component.html', +}) +@renderSectionFor(SectionsType.License) +export class LicenseSectionComponent extends SectionModelComponent implements OnDestroy, OnInit { + + public formId; + public formModel: DynamicFormControlModel[]; + public displaySubmit = false; + public licenseText: string; + + protected pathCombiner: JsonPatchOperationPathCombiner; + protected subs: Subscription[] = []; + + constructor(protected changeDetectorRef: ChangeDetectorRef, + protected collectionDataService: CollectionDataService, + protected formBuilderService: FormBuilderService, + protected formOperationsService: FormOperationsService, + protected formService: FormService, + protected operationsBuilder: JsonPatchOperationsBuilder, + protected store: Store, + protected sectionService: SectionsService, + protected submissionService: SubmissionService, + @Inject('collectionIdProvider') public injectedCollectionId: string, + @Inject('sectionDataProvider') public injectedSectionData: SectionDataObject, + @Inject('submissionIdProvider') public injectedSubmissionId: string) { + super(injectedCollectionId, injectedSectionData, injectedSubmissionId); + } + + ngOnInit() { + this.pathCombiner = new JsonPatchOperationPathCombiner('sections', this.sectionData.id); + + this.subs.push( + this.collectionDataService.findById(this.collectionId) + .filter((collectionData: RemoteData) => isNotUndefined((collectionData.payload))) + .flatMap((collectionData: RemoteData) => collectionData.payload.license) + .filter((licenseData: RemoteData) => isNotUndefined((licenseData.payload))) + .take(1) + .subscribe((licenseData: RemoteData) => { + this.licenseText = licenseData.payload.text; + this.formId = this.formService.getUniqueId(this.sectionData.id); + this.formModel = this.formBuilderService.fromJSON(SECTION_LICENSE_FORM_MODEL); + const model = this.formBuilderService.findById('granted', this.formModel); + // Retrieve license accepted status + if ((this.sectionData.data as WorkspaceitemSectionLicenseObject).granted) { + (model as DynamicCheckboxModel).checked = true; + this.store.dispatch(new SectionStatusChangeAction(this.submissionId, this.sectionData.id, true)); + } + + // Disable checkbox whether it's in workflow or item scope + this.sectionService.isSectionReadOnly(this.submissionId, this.sectionData.id, this.submissionService.getSubmissionScope()) + .take(1) + .filter((isReadOnly) => isReadOnly) + .subscribe(() => { + model.disabled = true; + }); + this.changeDetectorRef.detectChanges(); + }), + this.store.select(submissionSectionErrorsFromIdSelector(this.submissionId, this.sectionData.id)) + .filter((errors) => isNotEmpty(errors)) + .distinctUntilChanged() + .subscribe((errors) => { + // parse errors + const newErrors = errors.map((error) => { + // When the error path is only on the section, + // replace it with the path to the form field to display error also on the form + if (error.path === '/sections/license') { + const model = this.formBuilderService.findById('granted', this.formModel); + // check whether license is not accepted + if (!(model as DynamicCheckboxModel).checked) { + return Object.assign({}, error, {path: '/sections/license/granted'}); + } else { + return null; + } + } else { + return error; + } + }).filter((error) => isNotNull(error)); + + if (isNotEmpty(newErrors)) { + this.sectionService.checkSectionErrors(this.submissionId, this.sectionData.id, this.formId, newErrors); + this.sectionData.errors = errors; + } else { + // Remove any section's errors + this.store.dispatch(new RemoveSectionErrorsAction(this.submissionId, this.sectionData.id)); + } + this.changeDetectorRef.detectChanges(); + }) + ); + } + + onChange(event: DynamicFormControlEvent) { + const path = this.formOperationsService.getFieldPathSegmentedFromChangeEvent(event); + const value = this.formOperationsService.getFieldValueFromChangeEvent(event); + this.store.dispatch(new SectionStatusChangeAction(this.submissionId, this.sectionData.id, value.value)); + if (value) { + this.operationsBuilder.add(this.pathCombiner.getPath(path), value.value.toString(), false, true); + // Remove any section's errors + this.store.dispatch(new RemoveSectionErrorsAction(this.submissionId, this.sectionData.id)); + } else { + this.operationsBuilder.remove(this.pathCombiner.getPath(path)); + } + } + + ngOnDestroy() { + this.subs + .filter((subscription) => hasValue(subscription)) + .forEach((subscription) => subscription.unsubscribe()); + } + +} diff --git a/src/app/submission/sections/license/section-license.model.ts b/src/app/submission/sections/license/section-license.model.ts new file mode 100644 index 0000000000..22f6c544c4 --- /dev/null +++ b/src/app/submission/sections/license/section-license.model.ts @@ -0,0 +1,17 @@ + +export const SECTION_LICENSE_FORM_MODEL = [ + { + id: 'granted', + label: 'I confirm the license above', + required: true, + value: false, + validators: { + required: null + }, + errorMessages: { + required: 'You must accept the license', + notgranted: 'You must accept the license' + }, + type: 'CHECKBOX', + } +]; diff --git a/src/app/submission/sections/models/section-data.model.ts b/src/app/submission/sections/models/section-data.model.ts new file mode 100644 index 0000000000..230b36eb94 --- /dev/null +++ b/src/app/submission/sections/models/section-data.model.ts @@ -0,0 +1,15 @@ +import { SubmissionSectionError } from '../../objects/submission-objects.reducer'; +import { WorkspaceitemSectionDataType } from '../../../core/submission/models/workspaceitem-sections.model'; +import { SectionsType } from '../sections-type'; + +export interface SectionDataObject { + config: string; + data: WorkspaceitemSectionDataType; + errors: SubmissionSectionError[]; + header: string; + id: string; + mandatory: boolean; + sectionType: SectionsType; + + [propName: string]: any; +} diff --git a/src/app/submission/sections/models/section.model.ts b/src/app/submission/sections/models/section.model.ts new file mode 100644 index 0000000000..7925cc54ec --- /dev/null +++ b/src/app/submission/sections/models/section.model.ts @@ -0,0 +1,24 @@ +import { Inject } from '@angular/core'; +import { SectionDataObject } from './section-data.model'; + +export interface SectionDataModel { + sectionData: SectionDataObject +} + +/** + * An abstract model class for a submission edit form section. + */ +export abstract class SectionModelComponent implements SectionDataModel { + collectionId: string; + sectionData: SectionDataObject; + submissionId: string; + protected valid: boolean; + + public constructor(@Inject('collectionIdProvider') public injectedCollectionId: string, + @Inject('sectionDataProvider') public injectedSectionData: SectionDataObject, + @Inject('submissionIdProvider') public injectedSubmissionId: string) { + this.collectionId = injectedCollectionId; + this.sectionData = injectedSectionData; + this.submissionId = injectedSubmissionId; + } +} diff --git a/src/app/submission/sections/recycle/section-recycle.component.html b/src/app/submission/sections/recycle/section-recycle.component.html new file mode 100644 index 0000000000..a3bc8d52bb --- /dev/null +++ b/src/app/submission/sections/recycle/section-recycle.component.html @@ -0,0 +1,39 @@ + + + +
+
+

No recycled elements yet.

+
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/src/app/submission/sections/recycle/section-recycle.component.scss b/src/app/submission/sections/recycle/section-recycle.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/submission/sections/recycle/section-recycle.component.ts b/src/app/submission/sections/recycle/section-recycle.component.ts new file mode 100644 index 0000000000..9890a59d69 --- /dev/null +++ b/src/app/submission/sections/recycle/section-recycle.component.ts @@ -0,0 +1,55 @@ +import { SectionsType } from '../sections-type'; +import { Component, Inject } from '@angular/core'; +import { SectionModelComponent } from '../models/section.model'; +import { renderSectionFor } from '../sections-decorator'; +import { SectionDataObject } from '../models/section-data.model'; +import { SubmissionState } from '../../submission.reducers'; +import { Store } from '@ngrx/store'; +import { WorkspaceitemSectionRecycleObject } from '../../../core/submission/models/workspaceitem-section-recycle.model'; +import { submissionSectionDataFromIdSelector } from '../../selectors'; +import { isNotEmpty } from '../../../shared/empty.util'; +import { Observable } from 'rxjs/Observable'; + +@Component({ + selector: 'ds-recycle-section', + styleUrls: ['./section-recycle.component.scss'], + templateUrl: './section-recycle.component.html', +}) + +@renderSectionFor(SectionsType.Recycle) +export class RecycleSectionComponent extends SectionModelComponent { + public sectionDataObs: Observable; + public isLoading = true; + + public unexpected: any[]; // FormFieldChangedObject[]; + public metadata: any[]; // FormFieldMetadataValueObject[]; + public files: any[]; // WorkspaceitemSectionUploadFileObject[]; + + constructor(protected store: Store, + @Inject('collectionIdProvider') public injectedCollectionId: string, + @Inject('sectionDataProvider') public injectedSectionData: SectionDataObject, + @Inject('submissionIdProvider') public injectedSubmissionId: string) { + super(injectedCollectionId, injectedSectionData, injectedSubmissionId); + } + + ngOnInit() { + + this.sectionDataObs = this.store.select(submissionSectionDataFromIdSelector(this.submissionId, this.sectionData.id)) + .filter((sd) => isNotEmpty(sd)) + // .startWith( {metadata:[]}) + .distinctUntilChanged() + .map( (sd) => { + console.log('sectionData for recycle...'); + console.log(sd); + console.log('sectionData for recycle end'); + return sd; + }); + + this.unexpected = this.sectionData.unexpected; + this.metadata = this.sectionData.metadata; + this.files = this.sectionData.files; + + this.isLoading = false; + } + +} diff --git a/src/app/submission/sections/sections-decorator.ts b/src/app/submission/sections/sections-decorator.ts new file mode 100644 index 0000000000..7e7840adfd --- /dev/null +++ b/src/app/submission/sections/sections-decorator.ts @@ -0,0 +1,16 @@ + +import { SectionsType } from './sections-type'; + +const submissionSectionsMap = new Map(); +export function renderSectionFor(sectionType: SectionsType) { + return function decorator(objectElement: any) { + if (!objectElement) { + return; + } + submissionSectionsMap.set(sectionType, objectElement); + }; +} + +export function rendersSectionType(sectionType: SectionsType) { + return submissionSectionsMap.get(sectionType); +} diff --git a/src/app/submission/sections/sections-type.ts b/src/app/submission/sections/sections-type.ts new file mode 100644 index 0000000000..a21bdf6cd4 --- /dev/null +++ b/src/app/submission/sections/sections-type.ts @@ -0,0 +1,9 @@ +export enum SectionsType { + SubmissionForm = 'submission-form', + Upload = 'upload', + License = 'license', + CcLicense = 'cclicense', + collection = 'collection', + Recycle = 'recycle', + Deduplication = 'deduplication' +} diff --git a/src/app/submission/sections/sections.directive.ts b/src/app/submission/sections/sections.directive.ts new file mode 100644 index 0000000000..aa61ca5e88 --- /dev/null +++ b/src/app/submission/sections/sections.directive.ts @@ -0,0 +1,155 @@ +import { ChangeDetectorRef, Directive, Input, OnDestroy, OnInit } from '@angular/core'; + +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs/Observable'; +import { Subscription } from 'rxjs/Subscription'; +import { isEmpty, uniq } from 'lodash'; + +import { SectionsService } from './sections.service'; +import { hasValue, isNotEmpty, isNotNull, isNotUndefined } from '../../shared/empty.util'; +import { submissionSectionFromIdSelector } from '../selectors'; +import { SubmissionState } from '../submission.reducers'; +import { SubmissionSectionError, SubmissionSectionObject } from '../objects/submission-objects.reducer'; +import parseSectionErrorPaths, { SectionErrorPath } from '../utils/parseSectionErrorPaths'; +import { + DeleteSectionErrorsAction, + SaveSubmissionSectionFormAction, + SetActiveSectionAction +} from '../objects/submission-objects.actions'; +import { SubmissionService } from '../submission.service'; + +@Directive({ + selector: '[dsSection]', + exportAs: 'sectionRef' +}) +export class SectionsDirective implements OnDestroy, OnInit { + @Input() mandatory = true; + @Input() sectionId; + @Input() submissionId; + public sectionErrors: string[] = []; + private active = true; + private animation = !this.mandatory; + private enabled: Observable; + private sectionState = this.mandatory; + private subs: Subscription[] = []; + private valid: Observable; + + constructor(private changeDetectorRef: ChangeDetectorRef, + private store: Store, + private submissionService: SubmissionService, + private sectionService: SectionsService) { + } + + ngOnInit() { + this.valid = this.sectionService.isSectionValid(this.submissionId, this.sectionId) + .map((valid: boolean) => { + if (valid) { + this.resetErrors(); + } + return valid; + }); + + this.subs.push( + this.store.select(submissionSectionFromIdSelector(this.submissionId, this.sectionId)) + .filter((state: SubmissionSectionObject) => isNotUndefined(state)) + .map((state: SubmissionSectionObject) => state.errors) + // .filter((errors: SubmissionSectionError[]) => isNotEmpty(errors)) + .subscribe((errors: SubmissionSectionError[]) => { + if (isNotEmpty(errors)) { + errors.forEach((errorItem: SubmissionSectionError) => { + const parsedErrors: SectionErrorPath[] = parseSectionErrorPaths(errorItem.path); + + parsedErrors.forEach((error: SectionErrorPath) => { + if (!error.fieldId) { + this.sectionErrors = uniq(this.sectionErrors.concat(errorItem.message)); + } + }); + }); + } else { + this.resetErrors(); + } + }), + this.submissionService.getActiveSectionId(this.submissionId) + .subscribe((activeSectionId) => { + const previousActive = this.active; + this.active = (activeSectionId === this.sectionId); + if (previousActive !== this.active) { + this.changeDetectorRef.detectChanges(); + // If section is no longer active dispatch save action + if (!this.active && isNotNull(activeSectionId)) { + this.store.dispatch(new SaveSubmissionSectionFormAction(this.submissionId, this.sectionId)); + } + } + }) + ); + + this.enabled = this.sectionService.isSectionEnabled(this.submissionId, this.sectionId); + } + + ngOnDestroy() { + this.subs + .filter((subscription) => hasValue(subscription)) + .forEach((subscription) => subscription.unsubscribe()); + } + + public sectionChange(event) { + this.sectionState = event.nextState; + } + + public isOpen() { + return (this.sectionState) ? true : false; + } + + public isMandatory() { + return this.mandatory; + } + + public isAnimationsActive() { + return this.animation; + } + + public isSectionActive(): boolean { + return this.active; + } + + public isEnabled(): Observable { + return this.enabled; + } + + public isValid(): Observable { + return this.valid; + } + + public removeSection(submissionId, sectionId) { + this.sectionService.removeSection(submissionId, sectionId) + } + + public hasErrors() { + return this.sectionErrors && this.sectionErrors.length > 0 + } + + public getErrors() { + return this.sectionErrors; + } + + public setFocus(event) { + if (!this.active) { + this.store.dispatch(new SetActiveSectionAction(this.submissionId, this.sectionId)); + } + } + + public removeError(index) { + this.sectionErrors.splice(index); + } + + public resetErrors() { + this.sectionErrors + .forEach((errorItem) => { + // because it has been deleted, remove the error from the state + const removeAction = new DeleteSectionErrorsAction(this.submissionId, this.sectionId, errorItem); + this.store.dispatch(removeAction); + }) + this.sectionErrors = []; + + } +} diff --git a/src/app/submission/sections/sections.service.ts b/src/app/submission/sections/sections.service.ts new file mode 100644 index 0000000000..b01f58751e --- /dev/null +++ b/src/app/submission/sections/sections.service.ts @@ -0,0 +1,154 @@ +import { Injectable } from '@angular/core'; + +import { Observable } from 'rxjs/Observable'; +import { Store } from '@ngrx/store'; +import { SubmissionState } from '../submission.reducers'; +import { isEqual } from 'lodash'; + +import { hasValue, isEmpty, isNotEmpty, isNotUndefined } from '../../shared/empty.util'; +import { + DeleteSectionErrorsAction, + DisableSectionAction, + EnableSectionAction, + InertSectionErrorsAction, + UpdateSectionDataAction +} from '../objects/submission-objects.actions'; +import { + SubmissionObjectEntry, + SubmissionSectionError, + SubmissionSectionObject +} from '../objects/submission-objects.reducer'; +import { submissionObjectFromIdSelector, submissionSectionFromIdSelector } from '../selectors'; +import { ScrollToConfigOptions, ScrollToService } from '@nicky-lenaers/ngx-scroll-to'; +import { SubmissionScopeType } from '../../core/submission/submission-scope-type'; +import parseSectionErrorPaths, { SectionErrorPath } from '../utils/parseSectionErrorPaths'; +import { FormAddError, FormClearErrorsAction, FormRemoveErrorAction } from '../../shared/form/form.actions'; +import { TranslateService } from '@ngx-translate/core'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; + +@Injectable() +export class SectionsService { + + constructor(private notificationsService: NotificationsService, + private scrollToService: ScrollToService, + private store: Store, + private translate: TranslateService) { + } + + public checkSectionErrors(submissionId, sectionId, formId, currentErrors, prevErrors = []) { + if (isEmpty(currentErrors)) { + this.store.dispatch(new DeleteSectionErrorsAction(submissionId, sectionId, currentErrors)); + this.store.dispatch(new FormClearErrorsAction(formId)); + } else if (!isEqual(currentErrors, prevErrors)) { + const dispatchedErrors = []; + currentErrors.forEach((error: SubmissionSectionError) => { + const errorPaths: SectionErrorPath[] = parseSectionErrorPaths(error.path); + + errorPaths.forEach((path: SectionErrorPath) => { + if (path.fieldId) { + const fieldId = path.fieldId.replace(/\./g, '_'); + + // Dispatch action to the form state; + const formAddErrorAction = new FormAddError(formId, fieldId, path.fieldIndex, error.message); + this.store.dispatch(formAddErrorAction); + dispatchedErrors.push(fieldId); + } + }); + }); + + prevErrors.forEach((error: SubmissionSectionError) => { + const errorPaths: SectionErrorPath[] = parseSectionErrorPaths(error.path); + + errorPaths.forEach((path: SectionErrorPath) => { + if (path.fieldId) { + const fieldId = path.fieldId.replace(/\./g, '_'); + + if (!dispatchedErrors.includes(fieldId)) { + const formRemoveErrorAction = new FormRemoveErrorAction(formId, fieldId, path.fieldIndex); + this.store.dispatch(formRemoveErrorAction); + } + } + }); + }); + } + } + + public getSectionState(submissionId, sectionId): Observable { + return this.store.select(submissionSectionFromIdSelector(submissionId, sectionId)) + .filter((sectionObj) => hasValue(sectionObj)) + .map((sectionObj: SubmissionSectionObject) => sectionObj) + .distinctUntilChanged(); + } + + public isSectionValid(submissionId, sectionId): Observable { + return this.store.select(submissionSectionFromIdSelector(submissionId, sectionId)) + .filter((sectionObj) => hasValue(sectionObj)) + .map((sectionObj: SubmissionSectionObject) => sectionObj.isValid) + .distinctUntilChanged(); + } + + public isSectionEnabled(submissionId, sectionId): Observable { + return this.store.select(submissionSectionFromIdSelector(submissionId, sectionId)) + .filter((sectionObj) => hasValue(sectionObj)) + .map((sectionObj: SubmissionSectionObject) => sectionObj.enabled) + .distinctUntilChanged(); + } + + public isSectionReadOnly(submissionId: string, sectionId: string, submissionScope: SubmissionScopeType): Observable { + return this.store.select(submissionSectionFromIdSelector(submissionId, sectionId)) + .filter((sectionObj) => hasValue(sectionObj)) + .map((sectionObj: SubmissionSectionObject) => { + return sectionObj.visibility.other === 'READONLY' && submissionScope !== SubmissionScopeType.WorkspaceItem + }) + .distinctUntilChanged(); + } + + public isSectionAvailable(submissionId, sectionId): Observable { + return this.store.select(submissionObjectFromIdSelector(submissionId)) + .filter((submissionState: SubmissionObjectEntry) => isNotUndefined(submissionState)) + .map((submissionState: SubmissionObjectEntry) => { + return isNotUndefined(submissionState.sections) && isNotUndefined(submissionState.sections[sectionId]); + }) + .distinctUntilChanged(); + } + + public addSection(submissionId: string, + sectionId: string) { + this.store.dispatch(new EnableSectionAction(submissionId, sectionId)); + const config: ScrollToConfigOptions = { + target: sectionId, + offset: -70 + }; + + this.scrollToService.scrollTo(config); + } + + public removeSection(submissionId, sectionId) { + this.store.dispatch(new DisableSectionAction(submissionId, sectionId)) + } + + public updateSectionData(submissionId, sectionId, data, errors = []) { + if (isNotEmpty(data)) { + const isAvailable$ = this.isSectionAvailable(submissionId, sectionId); + const isEnabled$ = this.isSectionEnabled(submissionId, sectionId); + + Observable.combineLatest(isAvailable$, isEnabled$) + .take(1) + .filter(([available, enabled]: [boolean, boolean]) => available) + .subscribe(([available, enabled]: [boolean, boolean]) => { + if (!enabled) { + this.translate.get('submission.sections.general.metadata-extracted-new-section', {sectionId}) + .take(1) + .subscribe((m) => { + this.notificationsService.info(null, m, null, true); + }); + } + this.store.dispatch(new UpdateSectionDataAction(submissionId, sectionId, data, errors)); + }); + } + } + + public setSectionError(submissionId: string, sectionId: string, errors: SubmissionSectionError[]) { + this.store.dispatch(new InertSectionErrorsAction(submissionId, sectionId, errors)); + } +} diff --git a/src/app/submission/sections/upload/accessConditions/accessConditions.component.html b/src/app/submission/sections/upload/accessConditions/accessConditions.component.html new file mode 100644 index 0000000000..ae4c74e2eb --- /dev/null +++ b/src/app/submission/sections/upload/accessConditions/accessConditions.component.html @@ -0,0 +1,9 @@ + + + {{accessCondition.name}} {{accessCondition.startDate}} {{accessCondition.endDate}} + + {{accessCondition.name}} + {{accessCondition.name}} from {{accessCondition.endDate}} + {{accessCondition.name}} until {{accessCondition.startDate}} +
+
diff --git a/src/app/submission/sections/upload/accessConditions/accessConditions.component.ts b/src/app/submission/sections/upload/accessConditions/accessConditions.component.ts new file mode 100644 index 0000000000..c89886d69d --- /dev/null +++ b/src/app/submission/sections/upload/accessConditions/accessConditions.component.ts @@ -0,0 +1,35 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { GroupEpersonService } from '../../../../core/eperson/group-eperson.service'; +import { ResourcePolicy } from '../../../../core/shared/resource-policy.model'; +import { isEmpty } from '../../../../shared/empty.util'; +import { EpersonData } from '../../../../core/eperson/eperson-data'; +import { Group } from '../../../../core/eperson/models/group.model'; + +@Component({ + selector: 'ds-access-conditions', + templateUrl: './accessConditions.component.html', +}) +export class AccessConditionsComponent implements OnInit { + + @Input() accessConditions: ResourcePolicy[]; + + public accessConditionsList = []; + + constructor(private groupService: GroupEpersonService) {} + + ngOnInit() { + this.accessConditions.forEach((accessCondition: ResourcePolicy) => { + if (isEmpty(accessCondition.name)) { + this.groupService.getDataByUuid(accessCondition.groupUUID) + .subscribe((data: EpersonData) => { + const group = data.payload[0] as any; + const accessConditionEntry = Object.assign({}, accessCondition); + accessConditionEntry.name = group.name; + this.accessConditionsList.push(accessConditionEntry); + }) + } else { + this.accessConditionsList.push(accessCondition); + } + }) + } +} diff --git a/src/app/submission/sections/upload/file/edit/file-edit.component.html b/src/app/submission/sections/upload/file/edit/file-edit.component.html new file mode 100644 index 0000000000..bfb322052c --- /dev/null +++ b/src/app/submission/sections/upload/file/edit/file-edit.component.html @@ -0,0 +1,8 @@ +
+ +
diff --git a/src/app/submission/sections/upload/file/edit/file-edit.component.ts b/src/app/submission/sections/upload/file/edit/file-edit.component.ts new file mode 100644 index 0000000000..fcc323047f --- /dev/null +++ b/src/app/submission/sections/upload/file/edit/file-edit.component.ts @@ -0,0 +1,259 @@ +import { ChangeDetectorRef, Component, Input, OnChanges } from '@angular/core'; + +import { WorkspaceitemSectionUploadFileObject } from '../../../../../core/submission/models/workspaceitem-section-upload-file.model'; +import { + DYNAMIC_FORM_CONTROL_TYPE_DATEPICKER, + DynamicDateControlModel, + DynamicDatePickerModel, + DynamicFormArrayGroupModel, + DynamicFormArrayModel, + DynamicFormControlEvent, + DynamicFormControlModel, + DynamicFormGroupModel, + DynamicSelectModel +} from '@ng-dynamic-forms/core'; +import { FormBuilderService } from '../../../../../shared/form/builder/form-builder.service'; +import { + BITSTREAM_ACCESS_CONDITIONS_FORM_ARRAY_LAYOUT, + BITSTREAM_ACCESS_CONDITIONS_FORM_ARRAY_CONFIG, + BITSTREAM_FORM_ACCESS_CONDITION_END_DATE_LAYOUT, + BITSTREAM_FORM_ACCESS_CONDITION_END_DATE_CONFIG, + BITSTREAM_FORM_ACCESS_CONDITION_GROUPS_LAYOUT, + BITSTREAM_FORM_ACCESS_CONDITION_GROUPS_CONFIG, + BITSTREAM_FORM_ACCESS_CONDITION_START_DATE_LAYOUT, + BITSTREAM_FORM_ACCESS_CONDITION_START_DATE_CONFIG, + BITSTREAM_FORM_ACCESS_CONDITION_TYPE_LAYOUT, + BITSTREAM_FORM_ACCESS_CONDITION_TYPE_CONFIG, + BITSTREAM_METADATA_FORM_GROUP_LAYOUT, + BITSTREAM_METADATA_FORM_GROUP_CONFIG +} from './files-edit.model'; +import { POLICY_DEFAULT_WITH_LIST } from '../../section-upload.component'; +import { isNotEmpty, isNotUndefined } from '../../../../../shared/empty.util'; +import { SubmissionFormsModel } from '../../../../../core/shared/config/config-submission-forms.model'; +import { FormFieldModel } from '../../../../../shared/form/builder/models/form-field.model'; +import { AccessConditionOption } from '../../../../../core/shared/config/config-access-condition-option.model'; +import { SubmissionService } from '../../../../submission.service'; + +@Component({ + selector: 'ds-submission-upload-section-file-edit', + templateUrl: './file-edit.component.html', +}) +export class UploadSectionFileEditComponent implements OnChanges { + + @Input() availableAccessConditionOptions: any[]; + @Input() availableAccessConditionGroups: Map; + @Input() collectionId; + @Input() collectionPolicyType; + @Input() configMetadataForm: SubmissionFormsModel; + @Input() fileData: WorkspaceitemSectionUploadFileObject; + @Input() fileId; + @Input() fileIndex; + @Input() formId; + @Input() sectionId; + @Input() submissionId; + + public formModel: DynamicFormControlModel[]; + + constructor(private cdr: ChangeDetectorRef, + private formBuilderService: FormBuilderService, + private submissionService: SubmissionService) { + } + + ngOnChanges() { + if (this.fileData && this.formId) { + this.formModel = this.buildFileEditForm(); + this.cdr.detectChanges(); + } + } + + protected buildFileEditForm() { + // TODO check in the rest server configuration whether dc.description may be repeatable + const configDescr: FormFieldModel = Object.assign({}, this.configMetadataForm.rows[ 0 ].fields[ 0 ]); + configDescr.repeatable = false; + const configForm = Object.assign({}, this.configMetadataForm, { + fields: Object.assign([], this.configMetadataForm.rows[ 0 ].fields[ 0 ], [ + this.configMetadataForm.rows[ 0 ].fields[ 0 ], + configDescr + ]) + }); + const formModel: DynamicFormControlModel[] = []; + const metadataGroupModelConfig = Object.assign({}, BITSTREAM_METADATA_FORM_GROUP_CONFIG); + metadataGroupModelConfig.group = this.formBuilderService.modelFromConfiguration( + configForm, + this.collectionId, + this.fileData.metadata, + this.submissionService.getSubmissionScope() + ); + formModel.push(new DynamicFormGroupModel(metadataGroupModelConfig, BITSTREAM_METADATA_FORM_GROUP_LAYOUT)); + const accessConditionTypeModelConfig = Object.assign({}, BITSTREAM_FORM_ACCESS_CONDITION_TYPE_CONFIG); + const accessConditionsArrayConfig = Object.assign({}, BITSTREAM_ACCESS_CONDITIONS_FORM_ARRAY_CONFIG); + const accessConditionTypeOptions = []; + + if (this.collectionPolicyType === POLICY_DEFAULT_WITH_LIST) { + for (const accessCondition of this.availableAccessConditionOptions) { + accessConditionTypeOptions.push( + { + label: accessCondition.name, + value: accessCondition.name + } + ); + } + accessConditionTypeModelConfig.options = accessConditionTypeOptions; + + // Dynamic assign of relation in config. For startdate, endDate, groups. + const hasStart = []; + const hasEnd = []; + const hasGroups = []; + this.availableAccessConditionOptions.forEach((condition) => { + const showStart: boolean = condition.hasStartDate === true; + const showEnd: boolean = condition.hasEndDate === true; + const showGroups: boolean = showStart || showEnd; + if (showStart) { + hasStart.push({ id: 'name', value: condition.name }); + } + if (showEnd) { + hasEnd.push({ id: 'name', value: condition.name }); + } + if (showGroups) { + hasGroups.push({ id: 'name', value: condition.name }); + } + }); + const confStart = { relation: [ { action: 'ENABLE', connective: 'OR', when: hasStart } ] }; + const confEnd = { relation: [ { action: 'ENABLE', connective: 'OR', when: hasEnd } ] }; + const confGroup = { relation: [ { action: 'ENABLE', connective: 'OR', when: hasGroups } ] }; + + accessConditionsArrayConfig.groupFactory = () => { + const type = new DynamicSelectModel(accessConditionTypeModelConfig, BITSTREAM_FORM_ACCESS_CONDITION_TYPE_LAYOUT); + const startDateConfig = Object.assign({}, BITSTREAM_FORM_ACCESS_CONDITION_START_DATE_CONFIG, confStart); + const endDateConfig = Object.assign({}, BITSTREAM_FORM_ACCESS_CONDITION_END_DATE_CONFIG, confEnd); + const groupsConfig = Object.assign({}, BITSTREAM_FORM_ACCESS_CONDITION_GROUPS_CONFIG, confGroup); + + const startDate = new DynamicDatePickerModel(startDateConfig, BITSTREAM_FORM_ACCESS_CONDITION_START_DATE_LAYOUT); + const endDate = new DynamicDatePickerModel(endDateConfig, BITSTREAM_FORM_ACCESS_CONDITION_END_DATE_LAYOUT); + const groups = new DynamicSelectModel(groupsConfig, BITSTREAM_FORM_ACCESS_CONDITION_GROUPS_LAYOUT); + + return [ type, startDate, endDate, groups ]; + }; + + // Number of access conditions blocks in form + accessConditionsArrayConfig.initialCount = isNotEmpty(this.fileData.accessConditions) ? this.fileData.accessConditions.length : 1; + formModel.push( + new DynamicFormArrayModel(accessConditionsArrayConfig, BITSTREAM_ACCESS_CONDITIONS_FORM_ARRAY_LAYOUT) + ); + + } + this.initModelData(formModel); + return formModel; + } + + public initModelData(formModel: DynamicFormControlModel[]) { + this.fileData.accessConditions.forEach((accessCondition, index) => { + Array.of('name', 'groupUUID', 'startDate', 'endDate') + .filter((key) => accessCondition.hasOwnProperty(key)) + .forEach((key) => { + const metadataModel: any = this.formBuilderService.findById(key, formModel, index); + if (metadataModel) { + if (key === 'groupUUID') { + this.availableAccessConditionGroups.forEach((group) => { + metadataModel.options.push({ + label: group.name, + value: group.uuid + }) + }); + } + if (metadataModel.type === DYNAMIC_FORM_CONTROL_TYPE_DATEPICKER) { + const date = new Date(accessCondition[key]); + metadataModel.value = { + year: date.getFullYear(), + month: date.getMonth() + 1, + day: date.getDate() + } + } else { + metadataModel.value = accessCondition[key]; + } + } + }); + }); + } + + public onChange(event: DynamicFormControlEvent) { + if (event.model.id === 'name') { + this.setOptions(event.model, event.control); + } + } + + public setOptions(model, control) { + let accessCondition: AccessConditionOption = null; + this.availableAccessConditionOptions.filter((element) => element.name === control.value) + .forEach((element) => accessCondition = element); + if (isNotEmpty(accessCondition)) { + const showGroups: boolean = accessCondition.hasStartDate === true || accessCondition.hasEndDate === true; + + const groupControl = control.parent.get('groupUUID'); + const startDateControl = control.parent.get('startDate'); + const endDateControl = control.parent.get('endDate'); + + // Clear previous values + if (showGroups) { + groupControl.setValue(null); + } else { + groupControl.setValue(accessCondition.groupUUID); + } + startDateControl.setValue(null); + control.parent.markAsDirty(); + endDateControl.setValue(null); + + if (showGroups) { + if (isNotUndefined(accessCondition.groupUUID)) { + + const groupOptions = []; + if (isNotUndefined(this.availableAccessConditionGroups.get(accessCondition.groupUUID))) { + const groupModel = this.formBuilderService.findById( + 'groupUUID', + (model.parent as DynamicFormArrayGroupModel).group) as DynamicSelectModel; + + this.availableAccessConditionGroups.forEach((group) => { + groupOptions.push({ + label: group.name, + value: group.uuid + }) + }); + + // Due to a bug can't dynamically change the select options, so replace the model with a new one + const confGroup = { relation: groupModel.relation }; + const groupsConfig = Object.assign({}, BITSTREAM_FORM_ACCESS_CONDITION_GROUPS_CONFIG, confGroup); + groupsConfig.options = groupOptions; + model.parent.group.pop(); + model.parent.group.push(new DynamicSelectModel(groupsConfig, BITSTREAM_FORM_ACCESS_CONDITION_GROUPS_LAYOUT)); + } + + } + if (accessCondition.hasStartDate) { + const startDateModel = this.formBuilderService.findById( + 'startDate', + (model.parent as DynamicFormArrayGroupModel).group) as DynamicDateControlModel; + + const min = new Date(accessCondition.maxStartDate); + startDateModel.max = { + year: min.getFullYear(), + month: min.getMonth() + 1, + day: min.getDate() + }; + } + if (accessCondition.hasEndDate) { + const endDateModel = this.formBuilderService.findById( + 'endDate', + (model.parent as DynamicFormArrayGroupModel).group) as DynamicDateControlModel; + + const max = new Date(accessCondition.maxEndDate); + endDateModel.max = { + year: max.getFullYear(), + month: max.getMonth() + 1, + day: max.getDate() + }; + } + } + } + } + +} diff --git a/src/app/submission/sections/upload/file/edit/files-edit.model.ts b/src/app/submission/sections/upload/file/edit/files-edit.model.ts new file mode 100644 index 0000000000..fb36e8c54a --- /dev/null +++ b/src/app/submission/sections/upload/file/edit/files-edit.model.ts @@ -0,0 +1,115 @@ +import { + DynamicDatePickerModelConfig, + DynamicFormArrayModelConfig, + DynamicSelectModelConfig, + DynamicFormGroupModelConfig, DynamicFormControlLayout, +} from '@ng-dynamic-forms/core'; + +export const BITSTREAM_METADATA_FORM_GROUP_CONFIG: DynamicFormGroupModelConfig = { + id: 'metadata', + group: [] +}; +export const BITSTREAM_METADATA_FORM_GROUP_LAYOUT: DynamicFormControlLayout = { + element: { + container: 'form-group', + label: 'col-form-label' + }, + grid: { + label: 'col-sm-3' + } +}; + +export const BITSTREAM_ACCESS_CONDITIONS_FORM_ARRAY_CONFIG: DynamicFormArrayModelConfig = { + id: 'accessConditions', + groupFactory: null, +}; +export const BITSTREAM_ACCESS_CONDITIONS_FORM_ARRAY_LAYOUT: DynamicFormControlLayout = { + grid: { + group: 'form-row' + } +}; + +export const BITSTREAM_FORM_ACCESS_CONDITION_TYPE_CONFIG: DynamicSelectModelConfig = { + id: 'name', + label: 'submission.sections.upload.form.access-condition-label', + options: [] +}; +export const BITSTREAM_FORM_ACCESS_CONDITION_TYPE_LAYOUT: DynamicFormControlLayout = { + element: { + container: 'p-0', + label: 'col-form-label' + }, + grid: { + host: 'col-md-10' + } +}; + +export const BITSTREAM_FORM_ACCESS_CONDITION_START_DATE_CONFIG: DynamicDatePickerModelConfig = { + id: 'startDate', + label: 'submission.sections.upload.form.from-label', + placeholder: 'submission.sections.upload.form.from-placeholder', + inline: false, + toggleIcon: 'fa fa-calendar', + relation: [ + { + action: 'ENABLE', + connective: 'OR', + when: [] + } + ] +}; +export const BITSTREAM_FORM_ACCESS_CONDITION_START_DATE_LAYOUT: DynamicFormControlLayout = { + element: { + container: 'p-0', + label: 'col-form-label' + }, + grid: { + host: 'col-md-4' + } +}; + +export const BITSTREAM_FORM_ACCESS_CONDITION_END_DATE_CONFIG: DynamicDatePickerModelConfig = { + id: 'endDate', + label: 'submission.sections.upload.form.until-label', + placeholder: 'submission.sections.upload.form.until-placeholder', + inline: false, + toggleIcon: 'fa fa-calendar', + relation: [ + { + action: 'ENABLE', + connective: 'OR', + when: [] + } + ] +}; +export const BITSTREAM_FORM_ACCESS_CONDITION_END_DATE_LAYOUT: DynamicFormControlLayout = { + element: { + container: 'p-0', + label: 'col-form-label' + }, + grid: { + host: 'col-md-4' + } +}; + +export const BITSTREAM_FORM_ACCESS_CONDITION_GROUPS_CONFIG: DynamicSelectModelConfig = { + id: 'groupUUID', + label: 'submission.sections.upload.form.group-label', + options: [], + relation: [ + { + action: 'ENABLE', + connective: 'OR', + when: [] + } + ] +}; +export const BITSTREAM_FORM_ACCESS_CONDITION_GROUPS_LAYOUT: DynamicFormControlLayout = { + element: { + container: 'p-0', + label: 'col-form-label' + }, + grid: { + host: 'col-sm-10' + } +}; diff --git a/src/app/submission/sections/upload/file/file.component.html b/src/app/submission/sections/upload/file/file.component.html new file mode 100644 index 0000000000..ffbaefaa0e --- /dev/null +++ b/src/app/submission/sections/upload/file/file.component.html @@ -0,0 +1,56 @@ + +
+
+ + +
+
+
+

{{fileName}} ({{fileData?.sizeBytes | dsFileSize}})

+
+
+ + + + + + + + + + + +
+
+ + +
+
+
+ + + + + + diff --git a/src/app/submission/sections/upload/file/file.component.ts b/src/app/submission/sections/upload/file/file.component.ts new file mode 100644 index 0000000000..f522f6c3b7 --- /dev/null +++ b/src/app/submission/sections/upload/file/file.component.ts @@ -0,0 +1,181 @@ +import { ChangeDetectorRef, Component, Input, OnChanges, OnInit } from '@angular/core'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { SectionUploadService } from '../section-upload.service'; +import { isNotEmpty, isNotNull, isNotUndefined } from '../../../../shared/empty.util'; +import { DynamicFormControlModel, } from '@ng-dynamic-forms/core'; + +import { FormService } from '../../../../shared/form/form.service'; +import { JsonPatchOperationsBuilder } from '../../../../core/json-patch/builder/json-patch-operations-builder'; +import { JsonPatchOperationPathCombiner } from '../../../../core/json-patch/builder/json-patch-operation-path-combiner'; + +import { WorkspaceitemSectionUploadFileObject } from '../../../../core/submission/models/workspaceitem-section-upload-file.model'; +import { SubmissionFormsModel } from '../../../../core/shared/config/config-submission-forms.model'; +import { deleteProperty } from '../../../../shared/object.util'; +import { dateToGMTString } from '../../../../shared/date.util'; +import { JsonPatchOperationsService } from '../../../../core/json-patch/json-patch-operations.service'; +import { SubmitDataResponseDefinitionObject } from '../../../../core/shared/submit-data-response-definition.model'; +import { SubmissionService } from '../../../submission.service'; +import { FileService } from '../../../../core/shared/file.service'; +import { HALEndpointService } from '../../../../core/shared/hal-endpoint.service'; + +@Component({ + selector: 'ds-submission-upload-section-file', + templateUrl: './file.component.html', +}) +export class UploadSectionFileComponent implements OnChanges, OnInit { + + @Input() availableAccessConditionOptions: any[]; + @Input() availableAccessConditionGroups: Map; + @Input() collectionId; + @Input() collectionPolicyType; + @Input() configMetadataForm: SubmissionFormsModel; + @Input() fileId; + @Input() fileIndex; + @Input() fileName + @Input() sectionId; + @Input() submissionId; + + public fileData: WorkspaceitemSectionUploadFileObject; + public formId; + public formState; + public readMode; + public formModel: DynamicFormControlModel[]; + + protected pathCombiner: JsonPatchOperationPathCombiner; + protected subscriptions = []; + + constructor(private cdr: ChangeDetectorRef, + private fileService: FileService, + private formService: FormService, + private halService: HALEndpointService, + private modalService: NgbModal, + private operationsBuilder: JsonPatchOperationsBuilder, + private operationsService: JsonPatchOperationsService, + private submissionService: SubmissionService, + private uploadService: SectionUploadService) { + this.readMode = true; + } + + ngOnChanges() { + if (this.availableAccessConditionOptions && this.availableAccessConditionGroups) { + // Retrieve file state + this.subscriptions.push( + this.uploadService + .getFileData(this.submissionId, this.sectionId, this.fileId) + .filter((bitstream) => isNotUndefined(bitstream)) + .subscribe((bitstream) => { + this.fileData = bitstream; + } + ) + ); + } + } + + ngOnInit() { + this.formId = this.formService.getUniqueId(this.fileId); + this.pathCombiner = new JsonPatchOperationPathCombiner('sections', this.sectionId, 'files', this.fileIndex); + } + + protected deleteFile() { + this.uploadService.removeUploadedFile(this.submissionId, this.sectionId, this.fileId); + this.operationsBuilder.remove(this.pathCombiner.getPath()); + this.operationsService.jsonPatchByResourceID( + this.submissionService.getSubmissionObjectLinkName(), + this.submissionId, + this.pathCombiner.rootElement, + this.pathCombiner.subRootElement) + .subscribe(); + } + + public confirmDelete(content) { + this.modalService.open(content).result.then( + (result) => { + if (result === 'ok') { + this.deleteFile(); + } + } + ); + } + + public downloadBitstreamFile() { + this.halService.getEndpoint('bitstreams') + .first() + .subscribe((url) => { + const fileUrl = `${url}/${this.fileData.uuid}/content`; + this.fileService.downloadFile(fileUrl); + }); + } + + public saveBitstreamData(event) { + event.preventDefault(); + this.subscriptions.push( + this.formService.getFormData(this.formId) + .take(1) + .subscribe((formData: any) => { + Object.keys((formData.metadata)) + .filter((key) => isNotEmpty(formData.metadata[key])) + .forEach((key) => { + const metadataKey = key.replace(/_/g, '.'); + const path = `metadata/${metadataKey}`; + this.operationsBuilder.add(this.pathCombiner.getPath(path), formData.metadata[key], true); + }); + const accessConditionsToSave = []; + formData.accessConditions + .filter((accessCondition) => isNotEmpty(accessCondition)) + .forEach((accessCondition, index) => { + let accessConditionOpt; + + this.availableAccessConditionOptions + .filter((element) => isNotNull(accessCondition.name) && element.name === accessCondition.name[0].value) + .forEach((element) => accessConditionOpt = element); + + if (accessConditionOpt) { + const path = `accessConditions/${index}`; + if (accessConditionOpt.hasStartDate !== true && accessConditionOpt.hasEndDate !== true) { + accessConditionOpt = deleteProperty(accessConditionOpt, 'hasStartDate'); + + accessConditionOpt = deleteProperty(accessConditionOpt, 'hasEndDate'); + accessConditionsToSave.push(accessConditionOpt); + } else { + accessConditionOpt = Object.assign({}, accessCondition); + accessConditionOpt.name = Array.isArray(accessCondition.name) ? accessCondition.name[0].value : accessCondition.name.value; + accessConditionOpt.groupUUID = Array.isArray(accessCondition.groupUUID) ? accessCondition.groupUUID[0].value : accessCondition.groupUUID.value; + if (accessCondition.startDate) { + const startDate = Array.isArray(accessCondition.startDate) ? accessCondition.startDate[0].value : accessCondition.startDate.value; + accessConditionOpt.startDate = dateToGMTString(startDate); + accessConditionOpt = deleteProperty(accessConditionOpt, 'endDate'); + } + if (accessCondition.endDate) { + const endDate = Array.isArray(accessCondition.endDate) ? accessCondition.endDate[0].value : accessCondition.endDate.value; + accessConditionOpt.endDate = dateToGMTString(endDate); + accessConditionOpt = deleteProperty(accessConditionOpt, 'startDate'); + } + accessConditionsToSave.push(accessConditionOpt); + } + } + }); + this.operationsBuilder.add(this.pathCombiner.getPath('accessConditions'), accessConditionsToSave, true); + this.operationsService.jsonPatchByResourceID( + this.submissionService.getSubmissionObjectLinkName(), + this.submissionId, + this.pathCombiner.rootElement, + this.pathCombiner.subRootElement) + .subscribe((result) => { + Object.keys(result[0].sections.upload.files) + .filter((key) => result[0].sections.upload.files[key].uuid === this.fileId) + .forEach((key) => this.uploadService.updateFileData( + this.submissionId, + this.sectionId, + this.fileId, + result[0].sections.upload.files[key])); + this.switchMode(); + }); + }) + ); + } + + public switchMode() { + this.readMode = !this.readMode; + this.cdr.detectChanges(); + } +} diff --git a/src/app/submission/sections/upload/file/view/file-view.component.html b/src/app/submission/sections/upload/file/view/file-view.component.html new file mode 100644 index 0000000000..e8a2ea4469 --- /dev/null +++ b/src/app/submission/sections/upload/file/view/file-view.component.html @@ -0,0 +1,25 @@ +
+ + + +
+ {{entry.value}} +
+ + {{entry.value | dsTruncate:[150]}} + +
+ +
+ No {{entry.key}} +
+ + No {{entry.key}} + +
+
+
+ + + +
diff --git a/src/app/submission/sections/upload/file/view/file-view.component.ts b/src/app/submission/sections/upload/file/view/file-view.component.ts new file mode 100644 index 0000000000..0136b70c05 --- /dev/null +++ b/src/app/submission/sections/upload/file/view/file-view.component.ts @@ -0,0 +1,29 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { WorkspaceitemSectionUploadFileObject } from '../../../../../core/submission/models/workspaceitem-section-upload-file.model'; +import { Metadatum } from '../../../../../core/shared/metadatum.model'; +import { isNotEmpty } from '../../../../../shared/empty.util'; + +@Component({ + selector: 'ds-submission-upload-section-file-view', + templateUrl: './file-view.component.html', +}) +export class UploadSectionFileViewComponent implements OnInit { + @Input() fileData: WorkspaceitemSectionUploadFileObject; + + public metadata: Metadatum[] = []; + + ngOnInit() { + if (isNotEmpty(this.fileData.metadata)) { + this.metadata.push({ + key: 'Title', + language: (this.fileData.metadata.hasOwnProperty('dc.title') ? this.fileData.metadata['dc.title'][0].language : ''), + value: (this.fileData.metadata.hasOwnProperty('dc.title') ? this.fileData.metadata['dc.title'][0].value : '') + }); + this.metadata.push({ + key: 'Description', + language: (this.fileData.metadata.hasOwnProperty('dc.description') ? this.fileData.metadata['dc.description'][0].language : ''), + value: (this.fileData.metadata.hasOwnProperty('dc.description') ? this.fileData.metadata['dc.description'][0].value : '') + }); + } + } +} diff --git a/src/app/submission/sections/upload/section-upload.component.html b/src/app/submission/sections/upload/section-upload.component.html new file mode 100644 index 0000000000..3939688e2f --- /dev/null +++ b/src/app/submission/sections/upload/section-upload.component.html @@ -0,0 +1,50 @@ + + + +
+
+

No file uploaded yet.

+
+
+
+ + + +
+
+ + + + + {{ 'submission.sections.upload.header.policy.default.nolist' | translate:{ "collectionName": collectionName } }} + + + + {{ 'submission.sections.upload.header.policy.default.withlist' | translate:{ "collectionName": collectionName } }} + + + + +
+
+ + + +
+
+
+
+
+
+
diff --git a/src/app/submission/sections/upload/section-upload.component.scss b/src/app/submission/sections/upload/section-upload.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/submission/sections/upload/section-upload.component.ts b/src/app/submission/sections/upload/section-upload.component.ts new file mode 100644 index 0000000000..c392c52d8f --- /dev/null +++ b/src/app/submission/sections/upload/section-upload.component.ts @@ -0,0 +1,185 @@ +import { ChangeDetectorRef, Component, Inject, OnInit } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { SectionModelComponent } from '../models/section.model'; +import { hasValue, isNotEmpty, isNotUndefined, isUndefined } from '../../../shared/empty.util'; +import { SectionUploadService } from './section-upload.service'; +import { SectionStatusChangeAction } from '../../objects/submission-objects.actions'; +import { SubmissionState } from '../../submission.reducers'; +import { CollectionDataService } from '../../../core/data/collection-data.service'; +import { GroupEpersonService } from '../../../core/eperson/group-eperson.service'; +import { SubmissionUploadsConfigService } from '../../../core/config/submission-uploads-config.service'; +import { SubmissionUploadsModel } from '../../../core/shared/config/config-submission-uploads.model'; +import { Observable } from 'rxjs/Observable'; +import { EpersonData } from '../../../core/eperson/eperson-data'; +import { SubmissionFormsModel } from '../../../core/shared/config/config-submission-forms.model'; +import { SectionsType } from '../sections-type'; +import { renderSectionFor } from '../sections-decorator'; +import { SectionDataObject } from '../models/section-data.model'; +import { submissionObjectFromIdSelector } from '../../selectors'; +import { SubmissionObjectEntry } from '../../objects/submission-objects.reducer'; +import { AlertType } from '../../../shared/alerts/aletrs-type'; + +export const POLICY_DEFAULT_NO_LIST = 1; // Banner1 +export const POLICY_DEFAULT_WITH_LIST = 2; // Banner2 + +@Component({ + selector: 'ds-submission-section-upload', + styleUrls: ['./section-upload.component.scss'], + templateUrl: './section-upload.component.html', +}) +@renderSectionFor(SectionsType.Upload) +export class UploadSectionComponent extends SectionModelComponent implements OnInit { + + public AlertTypeEnum = AlertType; + public fileIndexes = []; + public fileList = []; + public fileNames = []; + + public collectionName: string; + + /* + * Default access conditions of this collection + */ + public collectionDefaultAccessConditions: any[] = []; + + /* + * The collection access conditions policy + */ + public collectionPolicyType; + + public configMetadataForm: SubmissionFormsModel; + + /* + * List of available access conditions that could be setted to files + */ + public availableAccessConditionOptions: any[]; // List of accessConditions that an user can select + + /* + * List of Groups available for every access condition + */ + protected availableGroups: Map; // Groups for any policy + + protected subs = []; + + constructor(private bitstreamService: SectionUploadService, + private changeDetectorRef: ChangeDetectorRef, + private collectionDataService: CollectionDataService, + private groupService: GroupEpersonService, + private store: Store, + private uploadsConfigService: SubmissionUploadsConfigService, + @Inject('sectionDataProvider') public injectedSectionData: SectionDataObject, + @Inject('submissionIdProvider') public injectedSubmissionId: string) { + super(undefined, injectedSectionData, injectedSubmissionId); + } + + ngOnInit() { + this.subs.push( + this.store.select(submissionObjectFromIdSelector(this.submissionId)) + .filter((submissionObject: SubmissionObjectEntry) => isNotUndefined(submissionObject) && !submissionObject.isLoading) + .filter((submissionObject: SubmissionObjectEntry) => isUndefined(this.collectionId) || this.collectionId !== submissionObject.collection) + .subscribe((submissionObject: SubmissionObjectEntry) => { + this.collectionId = submissionObject.collection; + this.collectionDataService.findById(this.collectionId) + .filter((collectionData) => isNotUndefined((collectionData.payload))) + .take(1) + .subscribe((collectionData) => { + this.collectionName = collectionData.payload.name; + + // Default Access Conditions + this.subs.push(collectionData.payload.defaultAccessConditions + .filter((accessConditions) => isNotUndefined((accessConditions.payload))) + .take(1) + .subscribe((defaultAccessConditions) => { + + if (isNotEmpty(defaultAccessConditions.payload)) { + this.collectionDefaultAccessConditions = Array.isArray(defaultAccessConditions.payload) + ? defaultAccessConditions.payload : [defaultAccessConditions.payload]; + } + + // Edit Form Configuration, access policy list + this.subs.push(this.uploadsConfigService.getConfigByHref(this.sectionData.config) + .flatMap((config) => config.payload) + .take(1) + .subscribe((config: SubmissionUploadsModel) => { + this.availableAccessConditionOptions = isNotEmpty(config.accessConditionOptions) ? config.accessConditionOptions : []; + + this.configMetadataForm = config.metadata[0]; + this.collectionPolicyType = this.availableAccessConditionOptions.length > 0 + ? POLICY_DEFAULT_WITH_LIST + : POLICY_DEFAULT_NO_LIST; + + this.availableGroups = new Map(); + const groupsObs = []; + // Retrieve Groups for accessConditionPolicies + this.availableAccessConditionOptions.forEach((accessCondition) => { + if (accessCondition.hasEndDate === true || accessCondition.hasStartDate === true) { + groupsObs.push(this.groupService.getDataByUuid(accessCondition.groupUUID) + ); + } + }); + let obsCounter = 1; + Observable.merge(groupsObs) + .flatMap((group) => group) + .take(groupsObs.length) + .subscribe((data: EpersonData) => { + const group = data.payload[0] as any; + if (isUndefined(this.availableGroups.get(group.uuid))) { + if (Array.isArray(group.groups)) { + const groupArrayData = []; + for (const groupData of group.groups) { + groupArrayData.push({name: groupData.name, uuid: groupData.uuid}); + } + this.availableGroups.set(group.uuid, groupArrayData); + } else { + this.availableGroups.set(group.uuid, {name: group.name, uuid: group.uuid}); + } + } + if (obsCounter++ === groupsObs.length) { + this.changeDetectorRef.detectChanges(); + } + }) + }) + ); + }) + ); + }) + }) + , + this.bitstreamService + .getUploadedFileList(this.submissionId, this.sectionData.id) + .filter((bitstreamList) => isNotUndefined(bitstreamList)) + .distinctUntilChanged() + .subscribe((fileList) => { + let sectionStatus = false; + this.fileList = []; + this.fileIndexes = []; + this.fileNames = []; + if (isNotUndefined(fileList) && Object.keys(fileList).length > 0) { + Object.keys(fileList) + .forEach((key) => { + this.fileList.push(fileList[key]); + this.fileIndexes.push(fileList[key].uuid); + const fileName = fileList[key].metadata['dc.title'][0].display || fileList[key].uuid; + this.fileNames.push(fileName); + }); + sectionStatus = true; + } + this.store.dispatch(new SectionStatusChangeAction(this.submissionId, + this.sectionData.id, + sectionStatus)); + this.changeDetectorRef.detectChanges(); + } + ) + ); + } + + /** + * Method provided by Angular. Invoked when the instance is destroyed. + */ + ngOnDestroy() { + this.subs + .filter((subscription) => hasValue(subscription)) + .forEach((subscription) => subscription.unsubscribe()); + } + +} diff --git a/src/app/submission/sections/upload/section-upload.service.ts b/src/app/submission/sections/upload/section-upload.service.ts new file mode 100644 index 0000000000..3e0a1735ef --- /dev/null +++ b/src/app/submission/sections/upload/section-upload.service.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs/Observable'; +import { Store } from '@ngrx/store'; +import { SubmissionState } from '../../submission.reducers'; +import { DeleteUploadedFileAction, EditFileDataAction, NewUploadedFileAction } from '../../objects/submission-objects.actions'; +import { + submissionUploadedFileFromUuidSelector, + submissionUploadedFilesFromIdSelector +} from '../../selectors'; +import { isUndefined } from '../../../shared/empty.util'; +import { WorkspaceitemSectionUploadFileObject } from '../../../core/submission/models/workspaceitem-section-upload-file.model'; + +@Injectable() +export class SectionUploadService { + + constructor(private store: Store) {} + + public getUploadedFileList(submissionId: string, sectionId: string): Observable { + return this.store.select(submissionUploadedFilesFromIdSelector(submissionId, sectionId)) + .map((state) => state) + .distinctUntilChanged(); + } + + public getFileData(submissionId: string, sectionId: string, fileUuid: string): Observable { + return this.store.select(submissionUploadedFilesFromIdSelector(submissionId, sectionId)) + .filter((state) => !isUndefined(state)) + .map((state) => { + let fileState; + Object.keys(state) + .filter((key) => state[key].uuid === fileUuid) + .forEach((key) => fileState = state[key]); + return fileState; + }) + .distinctUntilChanged(); + } + + public getDefaultPolicies(submissionId: string, sectionId: string, fileId: string): Observable { + return this.store.select(submissionUploadedFileFromUuidSelector(submissionId, sectionId, fileId)) + .map((state) => state) + .distinctUntilChanged(); + } + + public addUploadedFile(submissionId: string, sectionId: string, fileId: string, data: WorkspaceitemSectionUploadFileObject) { + this.store.dispatch( + new NewUploadedFileAction(submissionId, sectionId, fileId, data) + ); + } + + public updateFileData(submissionId: string, sectionId: string, fileId: string, data: WorkspaceitemSectionUploadFileObject) { + this.store.dispatch( + new EditFileDataAction(submissionId, sectionId, fileId, data) + ); + } + + public removeUploadedFile(submissionId: string, sectionId: string, fileId: string) { + this.store.dispatch( + new DeleteUploadedFileAction(submissionId, sectionId, fileId) + ); + } +} diff --git a/src/app/submission/selectors.ts b/src/app/submission/selectors.ts new file mode 100644 index 0000000000..b52c44b7b1 --- /dev/null +++ b/src/app/submission/selectors.ts @@ -0,0 +1,60 @@ +import { createSelector, MemoizedSelector, Selector } from '@ngrx/store'; + +import { hasValue } from '../shared/empty.util'; +import { submissionSelector, SubmissionState } from './submission.reducers'; +import { SubmissionObjectEntry, SubmissionSectionObject } from './objects/submission-objects.reducer'; + +// @TODO: Merge with keySelector function present in 'src/app/core/shared/selectors.ts' +export function keySelector(parentSelector: Selector, subState: string, key: string): MemoizedSelector { + return createSelector(parentSelector, (state: T) => { + if (hasValue(state) && hasValue(state[subState])) { + return state[subState][key]; + } else { + return undefined; + } + }); +} + +export function subStateSelector(parentSelector: Selector, subState: string): MemoizedSelector { + return createSelector(parentSelector, (state: T) => { + if (hasValue(state) && hasValue(state[subState])) { + return state[subState]; + } else { + return undefined; + } + }); +} + +export function submissionObjectFromIdSelector(submissionId: string): MemoizedSelector { + return keySelector(submissionSelector, 'objects', submissionId); +} + +export function submissionObjectSectionsFromIdSelector(submissionId: string): MemoizedSelector { + const submissionObjectSelector = submissionObjectFromIdSelector(submissionId); + return subStateSelector(submissionObjectSelector, 'sections'); +} + +export function submissionUploadedFilesFromIdSelector(submissionId: string, sectionId: string): MemoizedSelector { + const sectionDataSelector = submissionSectionDataFromIdSelector(submissionId, sectionId); + return subStateSelector(sectionDataSelector, 'files'); +} + +export function submissionUploadedFileFromUuidSelector(submissionId: string, sectionId: string, uuid: string): MemoizedSelector { + const filesSelector = submissionSectionDataFromIdSelector(submissionId, sectionId); + return keySelector(filesSelector, 'files', uuid); +} + +export function submissionSectionFromIdSelector(submissionId: string, sectionId: string): MemoizedSelector { + const submissionIdSelector = submissionObjectFromIdSelector(submissionId); + return keySelector(submissionIdSelector, 'sections', sectionId); +} + +export function submissionSectionDataFromIdSelector(submissionId: string, sectionId: string): MemoizedSelector { + const submissionIdSelector = submissionSectionFromIdSelector(submissionId, sectionId); + return subStateSelector(submissionIdSelector, 'data'); +} + +export function submissionSectionErrorsFromIdSelector(submissionId: string, sectionId: string): MemoizedSelector { + const submissionIdSelector = submissionSectionFromIdSelector(submissionId, sectionId); + return subStateSelector(submissionIdSelector, 'errors'); +} diff --git a/src/app/submission/server-submission.service.ts b/src/app/submission/server-submission.service.ts new file mode 100644 index 0000000000..cf3500fb94 --- /dev/null +++ b/src/app/submission/server-submission.service.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@angular/core'; +import { SubmissionService } from './submission.service'; +import { SubmissionObject } from '../core/submission/models/submission-object.model'; +import { Observable } from 'rxjs/Observable'; + +@Injectable() +export class ServerSubmissionService extends SubmissionService { + + createSubmission(): Observable { + return Observable.of(null); + } + + retrieveSubmission(submissionId): Observable { + return Observable.of(null); + } + + startAutoSave(submissionId) { + return; + } + + stopAutoSave() { + return; + } +} diff --git a/src/app/submission/submission-rest.service.ts b/src/app/submission/submission-rest.service.ts new file mode 100644 index 0000000000..019b377fdd --- /dev/null +++ b/src/app/submission/submission-rest.service.ts @@ -0,0 +1,124 @@ +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs/Observable'; +import { Store } from '@ngrx/store'; + +import { ResponseCacheService } from '../core/cache/response-cache.service'; +import { RequestService } from '../core/data/request.service'; +import { ResponseCacheEntry } from '../core/cache/response-cache.reducer'; +import { + ErrorResponse, + PostPatchSuccessResponse, + RestResponse, + SubmissionSuccessResponse +} from '../core/cache/response-cache.models'; +import { isNotEmpty } from '../shared/empty.util'; +import { + ConfigRequest, + DeleteRequest, + PostRequest, + RestRequest, + SubmissionDeleteRequest, + SubmissionPatchRequest, + SubmissionPostRequest, + SubmissionRequest +} from '../core/data/request.models'; +import { SubmitDataResponseDefinitionObject } from '../core/shared/submit-data-response-definition.model'; +import { CoreState } from '../core/core.reducers'; +import { HttpOptions } from '../core/dspace-rest-v2/dspace-rest-v2.service'; +import { HALEndpointService } from '../core/shared/hal-endpoint.service'; + +@Injectable() +export class SubmissionRestService { + protected linkPath = 'workspaceitems'; + protected overrideRequest = true; + + constructor( + protected responseCache: ResponseCacheService, + protected requestService: RequestService, + protected store: Store, + protected halService: HALEndpointService) { + } + + protected submitData(request: RestRequest): Observable { + const [successResponse, errorResponse] = this.responseCache.get(request.href) + .map((entry: ResponseCacheEntry) => entry.response) + .partition((response: RestResponse) => response.isSuccessful); + return Observable.merge( + errorResponse.flatMap((response: ErrorResponse) => + Observable.throw(new Error(`Couldn't send data to server`))), + successResponse + .filter((response: PostPatchSuccessResponse) => isNotEmpty(response)) + .map((response: PostPatchSuccessResponse) => response.dataDefinition) + .distinctUntilChanged()); + } + + protected fetchRequest(request: RestRequest): Observable { + const [successResponse, errorResponse] = this.responseCache.get(request.href) + .map((entry: ResponseCacheEntry) => entry.response) + .do(() => this.responseCache.remove(request.href)) + .partition((response: RestResponse) => response.isSuccessful); + return Observable.merge( + errorResponse.flatMap((response: ErrorResponse) => + Observable.throw(new Error(`Couldn't retrieve the data`))), + successResponse + .filter((response: SubmissionSuccessResponse) => isNotEmpty(response)) + .map((response: SubmissionSuccessResponse) => response.dataDefinition) + .distinctUntilChanged()); + } + + protected getEndpointByIDHref(endpoint, resourceID): string { + return isNotEmpty(resourceID) ? `${endpoint}/${resourceID}` : `${endpoint}`; + } + + public deleteById(scopeId: string, linkName?: string): Observable { + return this.halService.getEndpoint(linkName || this.linkPath) + .filter((href: string) => isNotEmpty(href)) + .distinctUntilChanged() + .map((endpointURL: string) => this.getEndpointByIDHref(endpointURL, scopeId)) + .map((endpointURL: string) => new SubmissionDeleteRequest(this.requestService.generateRequestId(), endpointURL)) + .do((request: DeleteRequest) => this.requestService.configure(request)) + .flatMap((request: DeleteRequest) => this.submitData(request)) + .distinctUntilChanged(); + } + + public getDataByHref(href: string, options?: HttpOptions): Observable { + const request = new ConfigRequest(this.requestService.generateRequestId(), href, options); + this.requestService.configure(request, true); + + return this.fetchRequest(request); + } + + public getDataById(linkName: string, id: string): Observable { + return this.halService.getEndpoint(linkName) + .map((endpointURL: string) => this.getEndpointByIDHref(endpointURL, id)) + .filter((href: string) => isNotEmpty(href)) + .distinctUntilChanged() + .map((endpointURL: string) => new SubmissionRequest(this.requestService.generateRequestId(), endpointURL)) + .do((request: RestRequest) => this.requestService.configure(request, true)) + .flatMap((request: RestRequest) => this.fetchRequest(request)) + .distinctUntilChanged(); + } + + public postToEndpoint(linkName: string, body: any, scopeId?: string, options?: HttpOptions): Observable { + return this.halService.getEndpoint(linkName) + .filter((href: string) => isNotEmpty(href)) + .map((endpointURL: string) => this.getEndpointByIDHref(endpointURL, scopeId)) + .distinctUntilChanged() + .map((endpointURL: string) => new SubmissionPostRequest(this.requestService.generateRequestId(), endpointURL, body, options)) + .do((request: PostRequest) => this.requestService.configure(request, true)) + .flatMap((request: PostRequest) => this.submitData(request)) + .distinctUntilChanged(); + } + + public patchToEndpoint(linkName: string, body: any, scopeId?: string): Observable { + return this.halService.getEndpoint(linkName) + .filter((href: string) => isNotEmpty(href)) + .map((endpointURL: string) => this.getEndpointByIDHref(endpointURL, scopeId)) + .distinctUntilChanged() + .map((endpointURL: string) => new SubmissionPatchRequest(this.requestService.generateRequestId(), endpointURL, body)) + .do((request: PostRequest) => this.requestService.configure(request, true)) + .flatMap((request: PostRequest) => this.submitData(request)) + .distinctUntilChanged(); + } + +} diff --git a/src/app/submission/submission.effects.ts b/src/app/submission/submission.effects.ts new file mode 100644 index 0000000000..30e01451d1 --- /dev/null +++ b/src/app/submission/submission.effects.ts @@ -0,0 +1,5 @@ +import { SubmissionObjectEffects } from './objects/submission-objects.effects'; + +export const submissionEffects = [ + SubmissionObjectEffects +]; diff --git a/src/app/submission/submission.module.ts b/src/app/submission/submission.module.ts new file mode 100644 index 0000000000..d1a802e0f8 --- /dev/null +++ b/src/app/submission/submission.module.ts @@ -0,0 +1,90 @@ +import { NgModule } from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreModule } from '../core/core.module'; +import { SharedModule } from '../shared/shared.module'; + +import { FormSectionComponent } from './sections/form/section-form.component'; +import { SectionsDirective } from './sections/sections.directive'; +import { SectionsService } from './sections/sections.service'; +import { DefaultSectionComponent } from './sections/default/section-default.component'; +import { SubmissionFormCollectionComponent } from './form/collection/submission-form-collection.component'; +import { SubmissionFormFooterComponent } from './form/footer/submission-form-footer.component'; +import { SubmissionFormComponent } from './form/submission-form.component'; +import { SubmissionFormSectionAddComponent } from './form/section-add/submission-form-section-add.component'; +import { SectionContainerComponent } from './sections/container/section-container.component'; +import { CommonModule } from '@angular/common'; +import { StoreModule } from '@ngrx/store'; +import { EffectsModule } from '@ngrx/effects'; +import { submissionReducers } from './submission.reducers'; +import { submissionEffects } from './submission.effects'; +import { UploadSectionComponent } from './sections/upload/section-upload.component'; +import { SectionUploadService } from './sections/upload/section-upload.service'; +import { SubmissionUploadFilesComponent } from './form/submission-upload-files/submission-upload-files.component'; +import { SubmissionRestService } from './submission-rest.service'; +import { LicenseSectionComponent } from './sections/license/section-license.component'; +import { SubmissionUploadsConfigService } from '../core/config/submission-uploads-config.service'; +import { SubmissionEditComponent } from './edit/submission-edit.component'; +import { UploadSectionFileComponent } from './sections/upload/file/file.component'; +import { UploadSectionFileEditComponent } from './sections/upload/file/edit/file-edit.component'; +import { UploadSectionFileViewComponent } from './sections/upload/file/view/file-view.component'; +import { AccessConditionsComponent } from './sections/upload/accessConditions/accessConditions.component'; +import { RecycleSectionComponent } from './sections/recycle/section-recycle.component'; +import { DeduplicationSectionComponent } from './sections/deduplication/section-deduplication.component'; +import { DeduplicationMatchComponent } from './sections/deduplication/match/deduplication-match.component'; +import { DeduplicationService } from './sections/deduplication/deduplication.service'; +import { SubmissionSubmitComponent } from './submit/submission-submit.component'; + +@NgModule({ + imports: [ + CommonModule, + CoreModule, + SharedModule, + StoreModule.forFeature('submission', submissionReducers, {}), + EffectsModule.forFeature(submissionEffects), + TranslateModule + ], + declarations: [ + AccessConditionsComponent, + DefaultSectionComponent, + UploadSectionComponent, + FormSectionComponent, + LicenseSectionComponent, + SectionsDirective, + SectionContainerComponent, + SubmissionEditComponent, + SubmissionFormSectionAddComponent, + SubmissionFormCollectionComponent, + SubmissionFormComponent, + SubmissionFormFooterComponent, + SubmissionSubmitComponent, + SubmissionUploadFilesComponent, + UploadSectionFileComponent, + UploadSectionFileEditComponent, + UploadSectionFileViewComponent, + RecycleSectionComponent, + DeduplicationSectionComponent, + DeduplicationMatchComponent, + ], + entryComponents: [ + DefaultSectionComponent, + UploadSectionComponent, + FormSectionComponent, + LicenseSectionComponent, + SectionContainerComponent, + RecycleSectionComponent, + DeduplicationSectionComponent], + exports: [ + SubmissionEditComponent, + SubmissionFormComponent, + SubmissionSubmitComponent + ], + providers: [ + SectionUploadService, + SectionsService, + SubmissionRestService, + SubmissionUploadsConfigService, + DeduplicationService + ] +}) +export class SubmissionModule { +} diff --git a/src/app/submission/submission.reducers.ts b/src/app/submission/submission.reducers.ts new file mode 100644 index 0000000000..39069b2917 --- /dev/null +++ b/src/app/submission/submission.reducers.ts @@ -0,0 +1,16 @@ +import { ActionReducerMap, createFeatureSelector } from '@ngrx/store'; + +import { + submissionObjectReducer, + SubmissionObjectState +} from './objects/submission-objects.reducer'; + +export interface SubmissionState { + 'objects': SubmissionObjectState +} + +export const submissionReducers: ActionReducerMap = { + objects: submissionObjectReducer, +}; + +export const submissionSelector = createFeatureSelector('submission'); diff --git a/src/app/submission/submission.service.ts b/src/app/submission/submission.service.ts new file mode 100644 index 0000000000..cef39c9a52 --- /dev/null +++ b/src/app/submission/submission.service.ts @@ -0,0 +1,223 @@ +import { Inject, Injectable } from '@angular/core'; +import { Observable } from 'rxjs/Observable'; +import { Subscription } from 'rxjs/Subscription'; +import { Store } from '@ngrx/store'; + +import { submissionSelector, SubmissionState } from './submission.reducers'; +import { hasValue, isEmpty, isNotUndefined } from '../shared/empty.util'; +import { SaveSubmissionFormAction } from './objects/submission-objects.actions'; +import { + SubmissionObjectEntry, + SubmissionSectionEntry, + SubmissionSectionObject +} from './objects/submission-objects.reducer'; +import { submissionObjectFromIdSelector } from './selectors'; +import { GlobalConfig } from '../../config/global-config.interface'; +import { GLOBAL_CONFIG } from '../../config'; +import { HttpHeaders } from '@angular/common/http'; +import { HttpOptions } from '../core/dspace-rest-v2/dspace-rest-v2.service'; +import { SubmissionRestService } from './submission-rest.service'; +import { Router } from '@angular/router'; +import { SectionDataObject } from './sections/models/section-data.model'; +import { SubmissionScopeType } from '../core/submission/submission-scope-type'; +import { SubmissionObject } from '../core/submission/models/submission-object.model'; + +@Injectable() +export class SubmissionService { + + protected autoSaveSub: Subscription; + protected timerObs: Observable; + + constructor(@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + protected restService: SubmissionRestService, + protected router: Router, + protected store: Store) { + } + + createSubmission(): Observable { + return this.restService.postToEndpoint('workspaceitems', {}) + .map((workspaceitems) => workspaceitems[0]) + .catch(() => Observable.of({})) + } + + depositSubmission(selfUrl: string): Observable { + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'text/uri-list'); + options.headers = headers; + return this.restService.postToEndpoint('workflowitems', selfUrl, null, options); + } + + discardSubmission(submissionId: string): Observable { + return this.restService.deleteById(submissionId); + } + + getActiveSectionId(submissionId: string): Observable { + return this.getSubmissionObject(submissionId) + .map((submission: SubmissionObjectEntry) => submission.activeSection); + } + + getSubmissionObject(submissionId: string): Observable { + return this.store.select(submissionObjectFromIdSelector(submissionId)) + .filter((submission: SubmissionObjectEntry) => isNotUndefined(submission)) + } + + getSubmissionSections(submissionId: string): Observable { + return this.getSubmissionObject(submissionId) + .filter((submission: SubmissionObjectEntry) => isNotUndefined(submission.sections) && !submission.isLoading) + .take(1) + .map((submission: SubmissionObjectEntry) => submission.sections) + .map((sections: SubmissionSectionEntry) => { + const availableSections: SectionDataObject[] = []; + Object.keys(sections) + .filter((sectionId) => !this.isSectionHidden(sections[sectionId] as SubmissionSectionObject)) + .forEach((sectionId) => { + const sectionObject: SectionDataObject = Object.create({}); + sectionObject.config = sections[sectionId].config; + sectionObject.mandatory = sections[sectionId].mandatory; + sectionObject.data = sections[sectionId].data; + sectionObject.errors = sections[sectionId].errors; + sectionObject.header = sections[sectionId].header; + sectionObject.id = sectionId; + sectionObject.sectionType = sections[sectionId].sectionType; + availableSections.push(sectionObject); + }); + return availableSections; + }) + .startWith([]) + .distinctUntilChanged(); + } + + getDisabledSectionsList(submissionId: string): Observable { + return this.getSubmissionObject(submissionId) + .filter((submission: SubmissionObjectEntry) => isNotUndefined(submission.sections) && !submission.isLoading) + .map((submission: SubmissionObjectEntry) => submission.sections) + .map((sections: SubmissionSectionEntry) => { + const disabledSections: SectionDataObject[] = []; + Object.keys(sections) + .filter((sectionId) => !this.isSectionHidden(sections[sectionId] as SubmissionSectionObject)) + .filter((sectionId) => !sections[sectionId].enabled) + .forEach((sectionId) => { + const sectionObject: SectionDataObject = Object.create({}); + sectionObject.header = sections[sectionId].header; + sectionObject.id = sectionId; + disabledSections.push(sectionObject); + }); + return disabledSections; + }) + .startWith([]) + .distinctUntilChanged(); + } + + isSectionHidden(sectionData: SubmissionSectionObject) { + return (isNotUndefined(sectionData.visibility) + && sectionData.visibility.main === 'HIDDEN' + && sectionData.visibility.other === 'HIDDEN'); + + } + + isSubmissionLoading(submissionId: string): Observable { + return this.getSubmissionObject(submissionId) + .map((submission: SubmissionObjectEntry) => submission.isLoading) + .distinctUntilChanged() + } + + getSubmissionObjectLinkName(): string { + const url = this.router.routerState.snapshot.url; + if (url.startsWith('/workspaceitems') || url.startsWith('/submit')) { + return 'workspaceitems'; + } else if (url.startsWith('/workflowitems')) { + return 'workflowitems'; + } else { + return 'edititems'; + } + } + + getSubmissionScope(): SubmissionScopeType { + let scope: SubmissionScopeType; + switch (this.getSubmissionObjectLinkName()) { + case 'workspaceitems': + scope = SubmissionScopeType.WorkspaceItem; + break; + case 'workflowitems': + scope = SubmissionScopeType.WorkflowItem; + break; + case 'edititems': + scope = SubmissionScopeType.EditItem; + break; + } + return scope; + } + + getSectionsState(submissionId: string): Observable { + return this.store.select(submissionSelector) + .map((submissions: SubmissionState) => submissions.objects[submissionId]) + .filter((item) => isNotUndefined(item) && isNotUndefined(item.sections)) + .map((item) => item.sections) + .map((sections) => { + const states = []; + + if (isNotUndefined(sections)) { + Object.keys(sections) + .filter((sectionId) => sections.hasOwnProperty(sectionId)) + .filter((sectionId) => !this.isSectionHidden(sections[sectionId] as SubmissionSectionObject)) + .filter((sectionId) => sections[sectionId].enabled) + .filter((sectionId) => sections[sectionId].isValid === false) + .forEach((sectionId) => { + states.push(sections[sectionId].isValid); + }); + } + + return !isEmpty(sections) && isEmpty(states); + }) + .distinctUntilChanged() + .startWith(false); + } + + getSubmissionSaveProcessingStatus(submissionId: string): Observable { + return this.store.select(submissionObjectFromIdSelector(submissionId)) + .filter((state: SubmissionObjectEntry) => isNotUndefined(state)) + .map((state: SubmissionObjectEntry) => state.savePending) + .distinctUntilChanged() + .startWith(false); + } + + getSubmissionDepositProcessingStatus(submissionId: string): Observable { + return this.store.select(submissionObjectFromIdSelector(submissionId)) + .filter((state: SubmissionObjectEntry) => isNotUndefined(state)) + .map((state: SubmissionObjectEntry) => state.depositPending) + .distinctUntilChanged() + .startWith(false); + } + + redirectToMyDSpace() { + this.router.navigate(['/mydspace']); + } + + retrieveSubmission(submissionId): Observable { + return this.restService.getDataById(this.getSubmissionObjectLinkName(), submissionId) + .filter((submissionObjects: SubmissionObject[]) => isNotUndefined(submissionObjects)) + .take(1) + .map((submissionObjects: SubmissionObject[]) => submissionObjects[0]); + } + + startAutoSave(submissionId) { + this.stopAutoSave(); + console.log('AUTOSAVE ON!!!'); + // AUTOSAVE submission + // Retrieve interval from config and convert to milliseconds + const duration = this.EnvConfig.submission.autosave.timer * (1000 * 60); + // Dispatch save action after given duration + this.timerObs = Observable.timer(duration, duration); + this.autoSaveSub = this.timerObs + .subscribe(() => this.store.dispatch(new SaveSubmissionFormAction(submissionId))); + } + + stopAutoSave() { + if (hasValue(this.autoSaveSub)) { + console.log('AUTOSAVE OFFF!!!'); + this.autoSaveSub.unsubscribe(); + this.autoSaveSub = null; + } + } +} diff --git a/src/app/submission/submit/submission-submit.component.html b/src/app/submission/submit/submission-submit.component.html new file mode 100644 index 0000000000..7cd58069ac --- /dev/null +++ b/src/app/submission/submit/submission-submit.component.html @@ -0,0 +1,8 @@ +
+
+ +
+
diff --git a/src/app/submission/submit/submission-submit.component.scss b/src/app/submission/submit/submission-submit.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/submission/submit/submission-submit.component.ts b/src/app/submission/submit/submission-submit.component.ts new file mode 100644 index 0000000000..f761a9b193 --- /dev/null +++ b/src/app/submission/submit/submission-submit.component.ts @@ -0,0 +1,67 @@ +import { ChangeDetectorRef, Component, OnDestroy, OnInit, ViewContainerRef } from '@angular/core'; +import { Router } from '@angular/router'; + +import { Subscription } from 'rxjs/Subscription'; + +import { hasValue, isEmpty, isNotNull } from '../../shared/empty.util'; +import { SubmissionDefinitionsModel } from '../../core/shared/config/config-submission-definitions.model'; +import { TranslateService } from '@ngx-translate/core'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { SubmissionService } from '../submission.service'; +import { SubmissionObject } from '../../core/submission/models/submission-object.model'; + +@Component({ + selector: 'ds-submit-page', + styleUrls: ['./submission-submit.component.scss'], + templateUrl: './submission-submit.component.html' +}) +export class SubmissionSubmitComponent implements OnDestroy, OnInit { + + public collectionId: string; + public model: any; + public selfUrl: string; + public submissionDefinition: SubmissionDefinitionsModel; + public submissionId: string; + + protected subs: Subscription[] = []; + + constructor(private changeDetectorRef: ChangeDetectorRef, + private notificationsService: NotificationsService, + private router: Router, + private submissioService: SubmissionService, + private translate: TranslateService, + private viewContainerRef: ViewContainerRef) { + } + + ngOnInit() { + // NOTE execute the code on the browser side only, otherwise it is executed twice + this.subs.push( + this.submissioService.createSubmission() + .subscribe((submissionObject: SubmissionObject) => { + // NOTE new submission is created on the browser side only + if (isNotNull(submissionObject)) { + if (isEmpty(submissionObject)) { + this.notificationsService.info(null, this.translate.get('submission.general.cannot_submit')); + this.router.navigate(['/mydspace']); + } else { + this.collectionId = submissionObject.collection[0].id; + this.selfUrl = submissionObject.self; + this.submissionDefinition = submissionObject.submissionDefinition[0]; + this.submissionId = submissionObject.id; + this.changeDetectorRef.detectChanges(); + } + } + }) + ) + } + + ngOnDestroy() { + this.subs + .filter((subscription) => hasValue(subscription)) + .forEach((subscription) => subscription.unsubscribe()); + + this.viewContainerRef.clear(); + this.changeDetectorRef.markForCheck(); + } + +} diff --git a/src/app/submission/utils/parseSectionErrorPaths.ts b/src/app/submission/utils/parseSectionErrorPaths.ts new file mode 100644 index 0000000000..b47b9d0b05 --- /dev/null +++ b/src/app/submission/utils/parseSectionErrorPaths.ts @@ -0,0 +1,41 @@ +import { hasValue } from '../../shared/empty.util'; + +export interface SectionErrorPath { + sectionId: string; + fieldId?: string; + fieldIndex?: number; + originalPath: string; +} + +const regex = /([^\/]+)/g; +// const regex = /\/sections\/(.*)\/(.*)\/(.*)/; +const regexShort = /\/sections\/(.*)/; + +/** + * the following method accept an array of section path strings and return a path object + * @param {string | string[]} path + * @returns {SectionErrorPath[]} + */ +const parseSectionErrorPaths = (path: string | string[]): SectionErrorPath[] => { + const paths = typeof path === 'string' ? [path] : path; + + return paths.map((item) => { + if (item.match(regex) && item.match(regex).length > 2) { + return { + sectionId: item.match(regex)[1], + fieldId: item.match(regex)[2], + fieldIndex: hasValue(item.match(regex)[3]) ? +item.match(regex)[3] : 0, + originalPath: item, + }; + } else { + return { + sectionId: item.match(regexShort)[1], + originalPath: item, + }; + } + + } + ); +}; + +export default parseSectionErrorPaths; diff --git a/src/app/submission/utils/parseSectionErrors.ts b/src/app/submission/utils/parseSectionErrors.ts new file mode 100644 index 0000000000..5f2867c8b8 --- /dev/null +++ b/src/app/submission/utils/parseSectionErrors.ts @@ -0,0 +1,27 @@ +import { SubmissionObjectError } from '../../core/submission/models/submission-object.model'; +import { default as parseSectionErrorPaths, SectionErrorPath } from './parseSectionErrorPaths'; + +/** + * the following method accept an array of SubmissionObjectError and return a section errors object + * @param {errors: SubmissionObjectError[]} errors + * @returns {any} + */ +const parseSectionErrors = (errors: SubmissionObjectError[] = []): any => { + const errorsList = Object.create({}); + + errors.forEach((error: SubmissionObjectError) => { + const paths: SectionErrorPath[] = parseSectionErrorPaths(error.paths); + + paths.forEach((path: SectionErrorPath) => { + const sectionError = {path: path.originalPath, message: error.message}; + if (!errorsList[path.sectionId]) { + errorsList[path.sectionId] = []; + } + errorsList[path.sectionId].push(sectionError); + }); + }); + + return errorsList; +}; + +export default parseSectionErrors; diff --git a/src/config/global-config.interface.ts b/src/config/global-config.interface.ts index b623a4bf8c..3062d0bd44 100644 --- a/src/config/global-config.interface.ts +++ b/src/config/global-config.interface.ts @@ -3,6 +3,7 @@ import { ServerConfig } from './server-config.interface'; import { CacheConfig } from './cache-config.interface'; import { UniversalConfig } from './universal-config.interface'; import { INotificationBoardOptions } from './notifications-config.interfaces'; +import { SubmissionConfig } from './submission-config.interface'; import { FormConfig } from './form-config.interfaces'; export interface GlobalConfig extends Config { @@ -12,6 +13,7 @@ export interface GlobalConfig extends Config { cache: CacheConfig; form: FormConfig; notifications: INotificationBoardOptions; + submission: SubmissionConfig; universal: UniversalConfig; gaTrackingId: string; logDirectory: string; diff --git a/src/config/submission-config.interface.ts b/src/config/submission-config.interface.ts new file mode 100644 index 0000000000..2def1dbf1e --- /dev/null +++ b/src/config/submission-config.interface.ts @@ -0,0 +1,16 @@ +import { Config } from './config.interface'; +import { MetadataIconsConfig } from '../app/shared/chips/models/chips.model'; + +interface AutosaveConfig extends Config { + metadata: string[], + timer: number +} + +interface MetadataConfig extends Config { + icons: MetadataIconsConfig[] +} + +export interface SubmissionConfig extends Config { + autosave: AutosaveConfig, + metadata: MetadataConfig +} diff --git a/src/modules/app/browser-app.module.ts b/src/modules/app/browser-app.module.ts index a7a59dc837..b20894880b 100644 --- a/src/modules/app/browser-app.module.ts +++ b/src/modules/app/browser-app.module.ts @@ -20,6 +20,8 @@ import { CookieService } from '../../app/shared/services/cookie.service'; import { AuthService } from '../../app/core/auth/auth.service'; import { Angulartics2Module } from 'angulartics2'; import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; +import { ServerSubmissionService } from '../../app/submission/server-submission.service'; +import { SubmissionService } from '../../app/submission/submission.service'; export const REQ_KEY = makeStateKey('req'); @@ -71,6 +73,10 @@ export function getRequest(transferState: TransferState): any { { provide: CookieService, useClass: ClientCookieService + }, + { + provide: SubmissionService, + useClass: SubmissionService } ] }) diff --git a/src/modules/app/server-app.module.ts b/src/modules/app/server-app.module.ts index 10285e75f5..e8c03e6fd8 100644 --- a/src/modules/app/server-app.module.ts +++ b/src/modules/app/server-app.module.ts @@ -22,6 +22,8 @@ import { ServerAuthService } from '../../app/core/auth/server-auth.service'; import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; import { AngularticsMock } from '../../app/shared/mocks/mock-angulartics.service'; +import { SubmissionService } from '../../app/submission/submission.service'; +import { ServerSubmissionService } from '../../app/submission/server-submission.service'; export function createTranslateLoader() { return new TranslateUniversalLoader('dist/assets/i18n/', '.json'); @@ -57,7 +59,11 @@ export function createTranslateLoader() { { provide: CookieService, useClass: ServerCookieService - } + }, + { + provide: SubmissionService, + useClass: ServerSubmissionService + }, ] }) export class ServerAppModule { diff --git a/src/routes.ts b/src/routes.ts index 392d3925a5..f3e963b25a 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -1,10 +1,15 @@ export const ROUTES: string[] = [ 'home', 'items/:id', + 'login', + 'logout', 'collections/:id', 'communities/:id', 'login', 'logout', 'search', + 'submit', + 'workspaceitems/:id/edit', + 'workflowitems/:id/edit', '**' ]; diff --git a/src/styles/_custom_variables.scss b/src/styles/_custom_variables.scss index f378c2b7c9..f6f68eaec1 100644 --- a/src/styles/_custom_variables.scss +++ b/src/styles/_custom_variables.scss @@ -8,4 +8,6 @@ $drop-zone-area-z-index: 1025; $drop-zone-area-inner-z-index: 1021; $login-logo-height:72px; $login-logo-width:72px; +$submission-header-z-index: 1001; +$submission-footer-z-index: 1000; diff --git a/yarn.lock b/yarn.lock index 3207959415..48296826cc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -77,9 +77,9 @@ version "1.0.1" resolved "https://registry.yarnpkg.com/@angularclass/bootloader/-/bootloader-1.0.1.tgz#75de7cf3901b445900a419c2aeca44181d465060" -"@ng-bootstrap/ng-bootstrap@^1.0.0": - version "1.0.0" - resolved "https://registry.yarnpkg.com/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-1.0.0.tgz#8f2ae70db2fe1dcbf5e0acb49dc2b1bbba2be8d2" +"@ng-bootstrap/ng-bootstrap@1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-1.1.2.tgz#276b1c488687ca3e53d1694b63835fd57ca552ca" "@ng-dynamic-forms/core@5.4.7": version "5.4.7" @@ -193,6 +193,10 @@ "@types/express-serve-static-core" "*" "@types/serve-static" "*" +"@types/file-saver@^1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@types/file-saver/-/file-saver-1.3.0.tgz#0ef213077e704fc3f4e7a86cfd31c9de4f4f47a7" + "@types/fs-extra@4.0.0": version "4.0.0" resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-4.0.0.tgz#1dd742ad5c9bce308f7a52d02ebc01421bc9102f" @@ -3114,6 +3118,10 @@ fd-slicer@~1.0.1: dependencies: pend "~1.2.0" +file-saver@^1.3.8: + version "1.3.8" + resolved "https://registry.yarnpkg.com/file-saver/-/file-saver-1.3.8.tgz#e68a30c7cb044e2fb362b428469feb291c2e09d8" + file-uri-to-path@1: version "1.0.0" resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" From f1f11bcdaf9cf6e6c53253f4ebff759505a471f7 Mon Sep 17 00:00:00 2001 From: Giuseppe Date: Thu, 26 Jul 2018 18:47:11 +0200 Subject: [PATCH 004/457] Removed unused model --- .../shared/config/config-authority.model.ts | 23 ------------------- .../shared/config/config-object-factory.ts | 4 ---- 2 files changed, 27 deletions(-) delete mode 100644 src/app/core/shared/config/config-authority.model.ts diff --git a/src/app/core/shared/config/config-authority.model.ts b/src/app/core/shared/config/config-authority.model.ts deleted file mode 100644 index bbb8605bcc..0000000000 --- a/src/app/core/shared/config/config-authority.model.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; -import { ConfigObject } from './config.model'; -import { SubmissionSectionModel } from './config-submission-section.model'; - -@inheritSerialization(ConfigObject) -export class ConfigAuthorityModel extends ConfigObject { - - @autoserialize - id: string; - - @autoserialize - display: string; - - @autoserialize - value: string; - - @autoserialize - otherInformation: any; - - @autoserialize - language: string; - -} diff --git a/src/app/core/shared/config/config-object-factory.ts b/src/app/core/shared/config/config-object-factory.ts index b43d4456f4..68e2ccbcb3 100644 --- a/src/app/core/shared/config/config-object-factory.ts +++ b/src/app/core/shared/config/config-object-factory.ts @@ -5,7 +5,6 @@ import { SubmissionFormsModel } from './config-submission-forms.model'; import { SubmissionDefinitionsModel } from './config-submission-definitions.model'; import { ConfigType } from './config-type'; import { ConfigObject } from './config.model'; -import { ConfigAuthorityModel } from './config-authority.model'; import { SubmissionUploadsModel } from './config-submission-uploads.model'; export class ConfigObjectFactory { @@ -27,9 +26,6 @@ export class ConfigObjectFactory { case ConfigType.SubmissionUploads: { return SubmissionUploadsModel } - case ConfigType.Authority: { - return ConfigAuthorityModel - } default: { return undefined; } From 9424116b419e5504e2b889f21b2fc43ad6ea832a Mon Sep 17 00:00:00 2001 From: Giuseppe Date: Fri, 27 Jul 2018 14:35:33 +0200 Subject: [PATCH 005/457] Fixed tests --- .../metadata-schema.component.spec.ts | 15 ++--- src/app/+item-page/item-page.module.ts | 2 - .../+login-page/login-page.component.spec.ts | 10 +++- src/app/core/browse/browse.service.spec.ts | 6 +- src/app/core/data/request.service.spec.ts | 60 +++++++++++-------- .../core/metadata/metadata.service.spec.ts | 33 ++++++---- src/app/core/shared/item.model.ts | 4 +- src/app/core/shared/operators.spec.ts | 4 +- .../dynamic-group.component.spec.ts | 10 ++++ src/app/shared/form/form.service.spec.ts | 1 - 10 files changed, 88 insertions(+), 57 deletions(-) diff --git a/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.spec.ts b/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.spec.ts index 7e6064ddff..148dba5b88 100644 --- a/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.spec.ts +++ b/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.spec.ts @@ -1,16 +1,17 @@ import { MetadataSchemaComponent } from './metadata-schema.component'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + import { Observable } from 'rxjs/Observable'; +import 'rxjs/add/observable/of'; + import { RemoteData } from '../../../core/data/remote-data'; import { PaginatedList } from '../../../core/data/paginated-list'; import { MetadataSchema } from '../../../core/metadata/metadataschema.model'; -import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { TranslateModule } from '@ngx-translate/core'; import { CommonModule } from '@angular/common'; import { ActivatedRoute, Router } from '@angular/router'; import { By } from '@angular/platform-browser'; -import { MockTranslateLoader } from '../../../shared/testing/mock-translate-loader'; import { RegistryService } from '../../../core/registry/registry.service'; -import { SharedModule } from '../../../shared/shared.module'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { EnumKeysPipe } from '../../../shared/utils/enum-keys-pipe'; import { PaginationComponent } from '../../../shared/pagination/pagination.component'; @@ -86,10 +87,10 @@ describe('MetadataSchemaComponent', () => { imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], declarations: [MetadataSchemaComponent, PaginationComponent, EnumKeysPipe], providers: [ - { provide: RegistryService, useValue: registryServiceStub }, - { provide: ActivatedRoute, useValue: activatedRouteStub }, - { provide: HostWindowService, useValue: new HostWindowServiceStub(0) }, - { provide: Router, useValue: new RouterStub() } + {provide: RegistryService, useValue: registryServiceStub}, + {provide: ActivatedRoute, useValue: activatedRouteStub}, + {provide: HostWindowService, useValue: new HostWindowServiceStub(0)}, + {provide: Router, useValue: new RouterStub()} ] }).compileComponents(); })); diff --git a/src/app/+item-page/item-page.module.ts b/src/app/+item-page/item-page.module.ts index d574681b21..0ba82b882a 100644 --- a/src/app/+item-page/item-page.module.ts +++ b/src/app/+item-page/item-page.module.ts @@ -18,13 +18,11 @@ import { FileSectionComponent } from './simple/field-components/file-section/fil import { CollectionsComponent } from './field-components/collections/collections.component'; import { FullItemPageComponent } from './full/full-item-page.component'; import { FullFileSectionComponent } from './full/field-components/file-section/full-file-section.component'; -import { SubmissionModule } from '../submission/submission.module'; @NgModule({ imports: [ CommonModule, SharedModule, - SubmissionModule, ItemPageRoutingModule ], declarations: [ diff --git a/src/app/+login-page/login-page.component.spec.ts b/src/app/+login-page/login-page.component.spec.ts index 609cf47794..b700621665 100644 --- a/src/app/+login-page/login-page.component.spec.ts +++ b/src/app/+login-page/login-page.component.spec.ts @@ -1,5 +1,6 @@ import { NO_ERRORS_SCHEMA } from '@angular/core'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; import { Store } from '@ngrx/store'; import { TranslateModule } from '@ngx-translate/core'; @@ -7,10 +8,14 @@ import { Observable } from 'rxjs/Observable'; import 'rxjs/add/observable/of'; import { LoginPageComponent } from './login-page.component'; +import { ActivatedRouteStub } from '../shared/testing/active-router-stub'; describe('LoginPageComponent', () => { let comp: LoginPageComponent; let fixture: ComponentFixture; + const activatedRouteStub = Object.assign(new ActivatedRouteStub(), { + params: Observable.of({}) + }); const store: Store = jasmine.createSpyObj('store', { /* tslint:disable:no-empty */ @@ -26,9 +31,8 @@ describe('LoginPageComponent', () => { ], declarations: [LoginPageComponent], providers: [ - { - provide: Store, useValue: store - } + { provide: ActivatedRoute, useValue: activatedRouteStub }, + { provide: Store, useValue: store } ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); diff --git a/src/app/core/browse/browse.service.spec.ts b/src/app/core/browse/browse.service.spec.ts index 5118ea7ecc..1bbea199e7 100644 --- a/src/app/core/browse/browse.service.spec.ts +++ b/src/app/core/browse/browse.service.spec.ts @@ -123,7 +123,7 @@ describe('BrowseService', () => { scheduler.schedule(() => service.getBrowseDefinitions().subscribe()); scheduler.flush(); - expect(requestService.configure).toHaveBeenCalledWith(expected); + expect(requestService.configure).toHaveBeenCalledWith(expected, undefined); }); it('should call RemoteDataBuildService to create the RemoteData Observable', () => { @@ -163,7 +163,7 @@ describe('BrowseService', () => { scheduler.schedule(() => service.getBrowseEntriesFor(browseDefinitions[1].id).subscribe()); scheduler.flush(); - expect(requestService.configure).toHaveBeenCalledWith(expected); + expect(requestService.configure).toHaveBeenCalledWith(expected, undefined); }); it('should call RemoteDataBuildService to create the RemoteData Observable', () => { @@ -179,7 +179,7 @@ describe('BrowseService', () => { it('should throw an Error', () => { const definitionID = 'invalidID'; - const expected = cold('--#-', undefined, new Error(`No metadata browse definition could be found for id '${definitionID}'`)) + const expected = cold('--#-', undefined, new Error(`No metadata browse definition could be found for id '${definitionID}'`)); expect(service.getBrowseEntriesFor(definitionID)).toBeObservable(expected); }); diff --git a/src/app/core/data/request.service.spec.ts b/src/app/core/data/request.service.spec.ts index aa9954f680..d5b8268397 100644 --- a/src/app/core/data/request.service.spec.ts +++ b/src/app/core/data/request.service.spec.ts @@ -1,7 +1,7 @@ import { Store } from '@ngrx/store'; import { cold, hot } from 'jasmine-marbles'; import { Observable } from 'rxjs/Observable'; -import 'rxjs/add/observable/of'; +import 'rxjs/add/observable/of' import { getMockObjectCacheService } from '../../shared/mocks/mock-object-cache.service'; import { getMockResponseCacheService } from '../../shared/mocks/mock-response-cache.service'; import { getMockStore } from '../../shared/mocks/mock-store'; @@ -235,6 +235,7 @@ describe('RequestService', () => { service.configure(request); expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).toHaveBeenCalledWith(request); }); + describe('and it isn\'t cached or pending', () => { beforeEach(() => { spyOn(serviceAsAny, 'isCachedOrPending').and.returnValue(false); @@ -277,28 +278,6 @@ describe('RequestService', () => { service.configure(testPatchRequest); expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(testPatchRequest); }); - - it('shouldn\'t track it on it\'s way to the store', () => { - spyOn(serviceAsAny, 'trackRequestsOnTheirWayToTheStore'); - - serviceAsAny.dispatchRequest(testPostRequest); - expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); - - serviceAsAny.dispatchRequest(testPutRequest); - expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); - - serviceAsAny.dispatchRequest(testDeleteRequest); - expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); - - serviceAsAny.dispatchRequest(testOptionsRequest); - expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); - - serviceAsAny.dispatchRequest(testHeadRequest); - expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); - - serviceAsAny.dispatchRequest(testPatchRequest); - expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); - }); }); }); @@ -409,6 +388,30 @@ describe('RequestService', () => { serviceAsAny.dispatchRequest(request); expect(store.dispatch).toHaveBeenCalledWith(new RequestExecuteAction(request.uuid)); }); + + describe('when it\'s not a GET request', () => { + it('shouldn\'t track it', () => { + spyOn(serviceAsAny, 'trackRequestsOnTheirWayToTheStore'); + + serviceAsAny.dispatchRequest(testPostRequest); + expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); + + serviceAsAny.dispatchRequest(testPutRequest); + expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); + + serviceAsAny.dispatchRequest(testDeleteRequest); + expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); + + serviceAsAny.dispatchRequest(testOptionsRequest); + expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); + + serviceAsAny.dispatchRequest(testHeadRequest); + expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); + + serviceAsAny.dispatchRequest(testPatchRequest); + expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); + }); + }); }); describe('trackRequestsOnTheirWayToTheStore', () => { @@ -427,9 +430,18 @@ describe('RequestService', () => { }); describe('when the request is added to the store', () => { + beforeEach(() => { + spyOn(service, 'getByHref').and.returnValue(Observable.of({ + request, + requestPending: false, + responsePending: true, + completed: false + })); + }); + it('should stop tracking the request', () => { - (store.select as any).and.returnValues(Observable.of({ request })); serviceAsAny.trackRequestsOnTheirWayToTheStore(request); + expect(service.getByHref).toHaveBeenCalledWith(request.href); expect(serviceAsAny.requestsOnTheirWayToTheStore.includes(request.href)).toBeFalsy(); }); }); diff --git a/src/app/core/metadata/metadata.service.spec.ts b/src/app/core/metadata/metadata.service.spec.ts index f8f36a358e..9998b18677 100644 --- a/src/app/core/metadata/metadata.service.spec.ts +++ b/src/app/core/metadata/metadata.service.spec.ts @@ -1,17 +1,17 @@ -import { ComponentFixture, TestBed, async, fakeAsync, inject, tick } from '@angular/core/testing'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; -import { Location, CommonModule } from '@angular/common'; +import { CommonModule, Location } from '@angular/common'; import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; -import { By, Meta, MetaDefinition, Title } from '@angular/platform-browser'; +import { Meta, MetaDefinition, Title } from '@angular/platform-browser'; import { ActivatedRoute, Router } from '@angular/router'; -import { TranslateModule, TranslateLoader } from '@ngx-translate/core'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { Store, StoreModule } from '@ngrx/store'; import { Observable } from 'rxjs/Observable'; -import { RemoteDataError } from '../data/remote-data-error'; +import 'rxjs/add/observable/of'; import { UUIDService } from '../shared/uuid.service'; import { MetadataService } from './metadata.service'; @@ -27,8 +27,8 @@ import { RemoteDataBuildService } from '../cache/builders/remote-data-build.serv import { RequestService } from '../data/request.service'; import { ResponseCacheService } from '../cache/response-cache.service'; -import { RemoteData } from '../../core/data/remote-data'; -import { Item } from '../../core/shared/item.model'; +import { RemoteData } from '../data/remote-data'; +import { Item } from '../shared/item.model'; import { MockItem } from '../../shared/mocks/mock-item'; import { MockTranslateLoader } from '../../shared/mocks/mock-translate-loader'; @@ -45,13 +45,15 @@ class TestComponent { } } -@Component({ template: '' }) class DummyItemComponent { +@Component({ template: '' }) +class DummyItemComponent { constructor(private route: ActivatedRoute, private items: ItemDataService, private metadata: MetadataService) { this.route.params.subscribe((params) => { this.metadata.processRemoteData(this.items.findById(params.id)); }); } } + /* tslint:enable:max-classes-per-file */ describe('MetadataService', () => { @@ -101,7 +103,12 @@ describe('MetadataService', () => { }), RouterTestingModule.withRoutes([ { path: 'items/:id', component: DummyItemComponent, pathMatch: 'full' }, - { path: 'other', component: DummyItemComponent, pathMatch: 'full', data: { title: 'Dummy Title', description: 'This is a dummy item component for testing!' } } + { + path: 'other', + component: DummyItemComponent, + pathMatch: 'full', + data: { title: 'Dummy Title', description: 'This is a dummy item component for testing!' } + } ]) ], declarations: [ @@ -114,7 +121,7 @@ describe('MetadataService', () => { { provide: RequestService, useValue: requestService }, { provide: RemoteDataBuildService, useValue: remoteDataBuildService }, { provide: GLOBAL_CONFIG, useValue: ENV_CONFIG }, - { provide: HALEndpointService, useValue: {}}, + { provide: HALEndpointService, useValue: {} }, Meta, Title, ItemDataService, @@ -172,7 +179,7 @@ describe('MetadataService', () => { spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(MockItem)); router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']); tick(); - expect(tagStore.size).toBeGreaterThan(0) + expect(tagStore.size).toBeGreaterThan(0); router.navigate(['/other']); tick(); expect(tagStore.size).toEqual(2); @@ -189,7 +196,7 @@ describe('MetadataService', () => { undefined, MockItem )); - } + }; const mockType = (mockItem: Item, type: string): Item => { const typedMockItem = Object.assign(new Item(), mockItem) as Item; @@ -200,7 +207,7 @@ describe('MetadataService', () => { } } return typedMockItem; - } + }; const mockPublisher = (mockItem: Item): Item => { const publishedMockItem = Object.assign(new Item(), mockItem) as Item; diff --git a/src/app/core/shared/item.model.ts b/src/app/core/shared/item.model.ts index be22c28b91..8250c73af1 100644 --- a/src/app/core/shared/item.model.ts +++ b/src/app/core/shared/item.model.ts @@ -88,10 +88,10 @@ export class Item extends DSpaceObject { */ getBitstreamsByBundleName(bundleName: string): Observable { return this.bitstreams - .filter((rd: RemoteData) => rd.hasSucceeded) + .filter((rd: RemoteData) => !rd.isResponsePending) .map((rd: RemoteData) => rd.payload) .filter((bitstreams: Bitstream[]) => hasValue(bitstreams)) - .first() + .take(1) .startWith([]) .map((bitstreams) => { return bitstreams diff --git a/src/app/core/shared/operators.spec.ts b/src/app/core/shared/operators.spec.ts index bb2fc263fd..4f3245088b 100644 --- a/src/app/core/shared/operators.spec.ts +++ b/src/app/core/shared/operators.spec.ts @@ -4,7 +4,7 @@ import { getMockRequestService } from '../../shared/mocks/mock-request.service'; import { getMockResponseCacheService } from '../../shared/mocks/mock-response-cache.service'; import { ResponseCacheEntry } from '../cache/response-cache.reducer'; import { ResponseCacheService } from '../cache/response-cache.service'; -import { GetRequest, RestRequest } from '../data/request.models'; +import { GetRequest } from '../data/request.models'; import { RequestEntry } from '../data/request.reducer'; import { RequestService } from '../data/request.service'; import { @@ -133,7 +133,7 @@ describe('Core Module - RxJS Operators', () => { scheduler.schedule(() => source.pipe(configureRequest(requestService)).subscribe()); scheduler.flush(); - expect(requestService.configure).toHaveBeenCalledWith(testRequest) + expect(requestService.configure).toHaveBeenCalledWith(testRequest, undefined) }); }); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-group/dynamic-group.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-group/dynamic-group.component.spec.ts index d1e6f67287..6d6f6fcb54 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-group/dynamic-group.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-group/dynamic-group.component.spec.ts @@ -82,6 +82,16 @@ describe('DsDynamicGroupComponent test suite', () => { required: 'required', regex: 'pattern' } + }, + submission: { + metadata: { + icons: [ + { + name: 'default', + config: {} + } + ] + } } } as any; let testComp: TestComponent; diff --git a/src/app/shared/form/form.service.spec.ts b/src/app/shared/form/form.service.spec.ts index 06125c9034..794b362909 100644 --- a/src/app/shared/form/form.service.spec.ts +++ b/src/app/shared/form/form.service.spec.ts @@ -13,7 +13,6 @@ import { FormService } from './form.service'; import { FormBuilderService } from './builder/form-builder.service'; import { AppState } from '../../app.reducer'; import { formReducer } from './form.reducer'; -import { GlobalConfig } from '../../../config/global-config.interface'; describe('FormService test suite', () => { const config = { From ba1337ec8f72750bb88d076fbd2a53ffe894d3ef Mon Sep 17 00:00:00 2001 From: Giuseppe Date: Tue, 31 Jul 2018 17:51:10 +0200 Subject: [PATCH 006/457] Improved redirect after 'Save for later' in Submission edit page --- src/app/app.component.spec.ts | 9 +++++- src/app/app.component.ts | 6 +++- src/app/shared/chips/models/chips.model.ts | 11 ++++++- src/app/shared/mocks/mock-router.ts | 4 +++ src/app/shared/services/route.service.spec.ts | 7 +++-- src/app/shared/services/route.service.ts | 29 +++++++++++++++---- src/app/submission/submission.service.ts | 12 ++++++-- 7 files changed, 65 insertions(+), 13 deletions(-) diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index cbab798f1e..6b8ec5a4bc 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -34,6 +34,10 @@ import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; import { AngularticsMock } from './shared/mocks/mock-angulartics.service'; import { AuthServiceMock } from './shared/mocks/mock-auth.service'; import { AuthService } from './core/auth/auth.service'; +import { RouteService } from './shared/services/route.service'; +import { ActivatedRoute, Router } from '@angular/router'; +import { MockActivatedRoute } from './shared/mocks/mock-active-router'; +import { MockRouter } from './shared/mocks/mock-router'; let comp: AppComponent; let fixture: ComponentFixture; @@ -62,7 +66,10 @@ describe('App component', () => { { provide: MetadataService, useValue: new MockMetadataService() }, { provide: Angulartics2GoogleAnalytics, useValue: new AngularticsMock() }, { provide: AuthService, useValue: new AuthServiceMock() }, - AppComponent + { provide: ActivatedRoute, useValue: new MockActivatedRoute() }, + { provide: Router, useValue: new MockRouter() }, + AppComponent, + RouteService ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }) diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 1c1a47cf12..f98f0185df 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -13,6 +13,7 @@ import { NativeWindowRef, NativeWindowService } from './shared/services/window.s import { isAuthenticated } from './core/auth/selectors'; import { AuthService } from './core/auth/auth.service'; import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; +import { RouteService } from './shared/services/route.service'; @Component({ selector: 'ds-app', @@ -30,7 +31,8 @@ export class AppComponent implements OnInit { private store: Store, private metadata: MetadataService, private angulartics2GoogleAnalytics: Angulartics2GoogleAnalytics, - private authService: AuthService + private authService: AuthService, + private routeService: RouteService ) { // this language will be used as a fallback when a translation isn't found in the current language translate.setDefaultLang('en'); @@ -39,6 +41,8 @@ export class AppComponent implements OnInit { metadata.listenForRouteChange(); + routeService.saveRouting(); + if (config.debug) { console.info(config); } diff --git a/src/app/shared/chips/models/chips.model.ts b/src/app/shared/chips/models/chips.model.ts index e133a416f4..92a1b18fb9 100644 --- a/src/app/shared/chips/models/chips.model.ts +++ b/src/app/shared/chips/models/chips.model.ts @@ -2,6 +2,7 @@ import { findIndex, isEqual, isObject } from 'lodash'; import { BehaviorSubject } from 'rxjs/BehaviorSubject'; import { ChipsItem, ChipsItemIcon } from './chips-item.model'; import { hasValue, isNotEmpty } from '../../empty.util'; +import { PLACEHOLDER_PARENT_METADATA } from '../../form/builder/ds-dynamic-form-ui/models/dynamic-group/dynamic-group.model'; export interface IconsConfig { withAuthority?: { @@ -83,6 +84,14 @@ export class Chips { return this._items.length > 0; } + private hasPlaceholder(value) { + if (isObject(value)) { + return value.value === PLACEHOLDER_PARENT_METADATA; + } else { + return value === PLACEHOLDER_PARENT_METADATA; + } + } + public remove(chipsItem: ChipsItem): void { const index = findIndex(this._items, {id: chipsItem.id}); this._items.splice(index, 1); @@ -119,7 +128,7 @@ export class Chips { config = (configIndex !== -1) ? this.iconsConfig[configIndex].config : defaultConfig; - if (hasValue(value) && isNotEmpty(config)) { + if (hasValue(value) && isNotEmpty(config) && !this.hasPlaceholder(value)) { let icon: ChipsItemIcon; const hasAuthority: boolean = !!(isObject(value) && ((value.hasOwnProperty('authority') && value.authority) || (value.hasOwnProperty('id') && value.id))); diff --git a/src/app/shared/mocks/mock-router.ts b/src/app/shared/mocks/mock-router.ts index 054c63d4c0..cf9a522f07 100644 --- a/src/app/shared/mocks/mock-router.ts +++ b/src/app/shared/mocks/mock-router.ts @@ -1,4 +1,8 @@ +import { Observable } from 'rxjs/Observable'; + export class MockRouter { + public events = Observable.of({}); + // noinspection TypeScriptUnresolvedFunction navigate = jasmine.createSpy('navigate'); } diff --git a/src/app/shared/services/route.service.spec.ts b/src/app/shared/services/route.service.spec.ts index b134771b3e..6ec9ef8d53 100644 --- a/src/app/shared/services/route.service.spec.ts +++ b/src/app/shared/services/route.service.spec.ts @@ -1,7 +1,9 @@ import { RouteService } from './route.service'; import { async, TestBed } from '@angular/core/testing'; -import { ActivatedRoute, convertToParamMap, Params } from '@angular/router'; +import { ActivatedRoute, convertToParamMap, Params, Router } from '@angular/router'; import { Observable } from 'rxjs/Observable'; +import 'rxjs/add/observable/of' +import { MockRouter } from '../mocks/mock-router'; describe('RouteService', () => { let service: RouteService; @@ -28,12 +30,13 @@ describe('RouteService', () => { queryParamMap: Observable.of(convertToParamMap(paramObject)) }, }, + { provide: Router, useValue: new MockRouter() }, ] }); })); beforeEach(() => { - service = new RouteService(TestBed.get(ActivatedRoute)); + service = new RouteService(TestBed.get(ActivatedRoute), TestBed.get(Router)); }); describe('hasQueryParam', () => { diff --git a/src/app/shared/services/route.service.ts b/src/app/shared/services/route.service.ts index aa683a6403..5b6ad44aba 100644 --- a/src/app/shared/services/route.service.ts +++ b/src/app/shared/services/route.service.ts @@ -1,15 +1,14 @@ import { Injectable } from '@angular/core'; import { Observable } from 'rxjs/Observable'; -import { - ActivatedRoute, convertToParamMap, NavigationExtras, Params, - Router, -} from '@angular/router'; -import { isNotEmpty } from '../empty.util'; +import { ActivatedRoute, NavigationEnd, Params, Router } from '@angular/router'; +import { filter } from 'rxjs/operators'; @Injectable() export class RouteService { - constructor(private route: ActivatedRoute) { + private history = []; + + constructor(private route: ActivatedRoute, private router: Router) { } getQueryParameterValues(paramName: string): Observable { @@ -40,4 +39,22 @@ export class RouteService { return params; }).distinctUntilChanged(); } + + public saveRouting(): void { + this.router.events + .pipe(filter((event) => event instanceof NavigationEnd)) + .subscribe(({urlAfterRedirects}: NavigationEnd) => { + this.history = [...this.history, urlAfterRedirects]; + console.log(this.history); + }); + } + + public getHistory(): string[] { + return this.history; + } + + public getPreviousUrl(): string { + return this.history[this.history.length - 2] || ''; + } + } diff --git a/src/app/submission/submission.service.ts b/src/app/submission/submission.service.ts index cef39c9a52..7eedd55ab6 100644 --- a/src/app/submission/submission.service.ts +++ b/src/app/submission/submission.service.ts @@ -1,4 +1,6 @@ import { Inject, Injectable } from '@angular/core'; +import { Router } from '@angular/router'; + import { Observable } from 'rxjs/Observable'; import { Subscription } from 'rxjs/Subscription'; import { Store } from '@ngrx/store'; @@ -17,10 +19,10 @@ import { GLOBAL_CONFIG } from '../../config'; import { HttpHeaders } from '@angular/common/http'; import { HttpOptions } from '../core/dspace-rest-v2/dspace-rest-v2.service'; import { SubmissionRestService } from './submission-rest.service'; -import { Router } from '@angular/router'; import { SectionDataObject } from './sections/models/section-data.model'; import { SubmissionScopeType } from '../core/submission/submission-scope-type'; import { SubmissionObject } from '../core/submission/models/submission-object.model'; +import { RouteService } from '../shared/services/route.service'; @Injectable() export class SubmissionService { @@ -31,6 +33,7 @@ export class SubmissionService { constructor(@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, protected restService: SubmissionRestService, protected router: Router, + protected routeService: RouteService, protected store: Store) { } @@ -191,7 +194,12 @@ export class SubmissionService { } redirectToMyDSpace() { - this.router.navigate(['/mydspace']); + const previousUrl = this.routeService.getPreviousUrl(); + if (isEmpty(previousUrl)) { + this.router.navigate(['/mydspace']); + } else { + this.router.navigateByUrl(previousUrl); + } } retrieveSubmission(submissionId): Observable { From 7d08e5813f75fa490c0fa7e8ff4f3be5dff61a01 Mon Sep 17 00:00:00 2001 From: Giuseppe Date: Wed, 1 Aug 2018 16:27:49 +0200 Subject: [PATCH 007/457] Fixed chips tooltip and tag fields --- src/app/shared/chips/chips.component.html | 9 +++++++-- src/app/shared/chips/chips.component.ts | 6 +++++- .../models/tag/dynamic-tag.component.html | 4 +++- .../models/tag/dynamic-tag.component.scss | 1 + 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/app/shared/chips/chips.component.html b/src/app/shared/chips/chips.component.html index 21ce99ecdb..3e10839f22 100644 --- a/src/app/shared/chips/chips.component.html +++ b/src/app/shared/chips/chips.component.html @@ -3,8 +3,13 @@ {{tipText}}
- +
+ +
+ Cancel
diff --git a/src/app/+item-page/edit-item-page/edit-item-page.component.scss b/src/app/+item-page/edit-item-page/edit-item-page.component.scss new file mode 100644 index 0000000000..f22ca8f8de --- /dev/null +++ b/src/app/+item-page/edit-item-page/edit-item-page.component.scss @@ -0,0 +1,5 @@ +@import '../../../styles/variables.scss'; + +.btn { + min-width: $edit-item-button-min-width; +} diff --git a/src/app/+item-page/edit-item-page/edit-item-page.component.ts b/src/app/+item-page/edit-item-page/edit-item-page.component.ts index d7ab3ea199..efde7c52fb 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.component.ts +++ b/src/app/+item-page/edit-item-page/edit-item-page.component.ts @@ -6,6 +6,7 @@ import { Item } from '../../core/shared/item.model'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { isNotEmpty } from '../../shared/empty.util'; +import { getItemPageRoute } from '../item-page-routing.module'; @Component({ selector: 'ds-edit-item-page', @@ -48,4 +49,8 @@ export class EditItemPageComponent implements OnInit { .filter((path: string) => isNotEmpty(path)); // ignore reroutes this.itemRD$ = this.route.data.pipe(map((data) => data.item)); } + + getItemPage(item: Item): string { + return getItemPageRoute(item.id) + } } diff --git a/src/app/+item-page/edit-item-page/edit-item-page.module.ts b/src/app/+item-page/edit-item-page/edit-item-page.module.ts index 98904517f9..0c1de642ce 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.module.ts +++ b/src/app/+item-page/edit-item-page/edit-item-page.module.ts @@ -14,6 +14,7 @@ import { ItemPublicComponent } from './item-public/item-public.component'; import { ItemDeleteComponent } from './item-delete/item-delete.component'; import { ItemMetadataComponent } from './item-metadata/item-metadata.component'; import { EditInPlaceFieldComponent } from './item-metadata/edit-in-place-field/edit-in-place-field.component'; +import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component'; /** * Module that contains all components related to the Edit Item page administrator functionality @@ -36,6 +37,7 @@ import { EditInPlaceFieldComponent } from './item-metadata/edit-in-place-field/e ItemDeleteComponent, ItemStatusComponent, ItemMetadataComponent, + ItemBitstreamsComponent, EditInPlaceFieldComponent ] }) diff --git a/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts b/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts index 8a45c42ff6..48fd6ceb9b 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts +++ b/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts @@ -9,6 +9,7 @@ import { ItemPublicComponent } from './item-public/item-public.component'; import { ItemDeleteComponent } from './item-delete/item-delete.component'; import { ItemStatusComponent } from './item-status/item-status.component'; import { ItemMetadataComponent } from './item-metadata/item-metadata.component'; +import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component'; const ITEM_EDIT_WITHDRAW_PATH = 'withdraw'; const ITEM_EDIT_REINSTATE_PATH = 'reinstate'; @@ -39,8 +40,7 @@ const ITEM_EDIT_DELETE_PATH = 'delete'; }, { path: 'bitstreams', - /* TODO - change when bitstreams page exists */ - component: ItemStatusComponent + component: ItemBitstreamsComponent }, { path: 'metadata', @@ -49,12 +49,12 @@ const ITEM_EDIT_DELETE_PATH = 'delete'; { path: 'view', /* TODO - change when view page exists */ - component: ItemStatusComponent + component: ItemBitstreamsComponent }, { path: 'curate', /* TODO - change when curate page exists */ - component: ItemStatusComponent + component: ItemBitstreamsComponent }, ] }, diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html new file mode 100644 index 0000000000..b80e6e0678 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss new file mode 100644 index 0000000000..88eb98509a --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss @@ -0,0 +1 @@ +@import '../../../../styles/variables.scss'; \ No newline at end of file diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts new file mode 100644 index 0000000000..71f25cd5cf --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'ds-item-bitstreams', + styleUrls: ['./item-bitstreams.component.scss'], + templateUrl: './item-bitstreams.component.html', +}) +/** + * Component for displaying an item's bitstreams edit page + */ +export class ItemBitstreamsComponent { + /* TODO implement */ +} diff --git a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html index 25f586d0ed..44c6cf2258 100644 --- a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html +++ b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html @@ -1,11 +1,6 @@ - -
- - + + + {{"item.edit.metadata.metadatafield.invalid" | translate}} - - -
- {{metadata?.value}} -
-
+
+ + +
+ {{metadata?.value}} +
+
-
- - -
- {{metadata?.language}} -
-
- -
- - -
- - - - -
- -
\ No newline at end of file + + + +
+ {{metadata?.language}} +
+
+ +
+ + +
+ + + + +
+ \ No newline at end of file diff --git a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.scss b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.scss index 3575cae797..65bdbc38e0 100644 --- a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.scss +++ b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.scss @@ -1 +1,10 @@ @import '../../../../../styles/variables.scss'; +.btn[disabled] { + color: $gray-600; + border-color: $gray-600; + z-index: 0; // prevent border colors jumping on hover +} + +.metadata-field { + width: $edit-item-metadata-field-width; +} \ No newline at end of file diff --git a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts index cefbb3620a..133ee19e43 100644 --- a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts @@ -400,66 +400,64 @@ describe('EditInPlaceFieldComponent', () => { }); describe('canRemove', () => { - describe('when editable is currently true', () => { + describe('when the fieldUpdate\'s changeType is currently not REMOVE or ADD', () => { beforeEach(() => { - comp.editable = observableOf(true); - fixture.detectChanges(); + comp.fieldUpdate.changeType = FieldChangeType.UPDATE; + }); + it('canRemove should return an observable emitting true', () => { + const expected = '(a|)'; + scheduler.expectObservable(comp.canRemove()).toBe(expected, { a: true }); + }); + }); + + describe('when the fieldUpdate\'s changeType is currently ADD', () => { + beforeEach(() => { + comp.fieldUpdate.changeType = FieldChangeType.ADD; }); it('canRemove should return an observable emitting false', () => { const expected = '(a|)'; scheduler.expectObservable(comp.canRemove()).toBe(expected, { a: false }); }); - }); - - describe('when editable is currently false', () => { - beforeEach(() => { - comp.editable = observableOf(false); - }); - - describe('when the fieldUpdate\'s changeType is currently not REMOVE or ADD', () => { - beforeEach(() => { - comp.fieldUpdate.changeType = FieldChangeType.UPDATE; - }); - it('canRemove should return an observable emitting true', () => { - const expected = '(a|)'; - scheduler.expectObservable(comp.canRemove()).toBe(expected, { a: true }); - }); - }); - - describe('when the fieldUpdate\'s changeType is currently ADD', () => { - beforeEach(() => { - comp.fieldUpdate.changeType = FieldChangeType.ADD; - }); - it('canRemove should return an observable emitting false', () => { - const expected = '(a|)'; - scheduler.expectObservable(comp.canRemove()).toBe(expected, { a: false }); - }); - }) - }); + }) }); describe('canUndo', () => { - describe('when the fieldUpdate\'s changeType is currently ADD, UPDATE or REMOVE', () => { + describe('when editable is currently true', () => { beforeEach(() => { - comp.fieldUpdate.changeType = FieldChangeType.ADD; + comp.editable = observableOf(true); + comp.fieldUpdate.changeType = undefined; + fixture.detectChanges(); }); - it('canUndo should return an observable emitting true', () => { const expected = '(a|)'; scheduler.expectObservable(comp.canUndo()).toBe(expected, { a: true }); }); }); - describe('when the fieldUpdate\'s changeType is currently undefined', () => { - beforeEach(() => { - comp.fieldUpdate.changeType = undefined; + describe('when editable is currently false', () => { + describe('when the fieldUpdate\'s changeType is currently ADD, UPDATE or REMOVE', () => { + beforeEach(() => { + comp.fieldUpdate.changeType = FieldChangeType.ADD; + }); + + it('canUndo should return an observable emitting true', () => { + const expected = '(a|)'; + scheduler.expectObservable(comp.canUndo()).toBe(expected, { a: true }); + }); }); - it('canUndo should return an observable emitting false', () => { - const expected = '(a|)'; - scheduler.expectObservable(comp.canUndo()).toBe(expected, { a: false }); + describe('when the fieldUpdate\'s changeType is currently undefined', () => { + beforeEach(() => { + comp.fieldUpdate.changeType = undefined; + }); + + it('canUndo should return an observable emitting false', () => { + const expected = '(a|)'; + scheduler.expectObservable(comp.canUndo()).toBe(expected, { a: false }); + }); }); }); + }); }); diff --git a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.ts b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.ts index f24de359b8..7d4922de43 100644 --- a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.ts +++ b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.ts @@ -14,7 +14,8 @@ import { getSucceededRemoteData } from '../../../../core/shared/operators'; import { FormControl } from '@angular/forms'; @Component({ - selector: 'ds-edit-in-place-field', + // tslint:disable-next-line:component-selector + selector: '[ds-edit-in-place-field]', styleUrls: ['./edit-in-place-field.component.scss'], templateUrl: './edit-in-place-field.component.html', }) @@ -131,7 +132,7 @@ export class EditInPlaceFieldComponent implements OnInit, OnChanges { (fields: MetadataField[]) => this.metadataFieldSuggestions.next( fields.map((field: MetadataField) => { return { - displayValue: field.toString(), + displayValue: field.toString().split('.').join('.​'), value: field.toString() } }) diff --git a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.html b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.html index 04896ee4a6..bb0c188f05 100644 --- a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.html +++ b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.html @@ -1,56 +1,63 @@ -
- diff --git a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.scss b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.scss index 1ae2839606..f3075702e6 100644 --- a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.scss +++ b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.scss @@ -1,13 +1,22 @@ @import '../../../../styles/variables.scss'; .button-row { - .spaced-btn-group > .btn { + .btn { margin-right: 0.5 * $spacer; + &:last-child { margin-right: 0; } + + @media screen and (min-width: map-get($grid-breakpoints, sm)) { + min-width: $edit-item-button-min-width; + } } - .btn { - min-width: $button-min-width; + + &.top .btn { + margin-top: $spacer/2; + margin-bottom: $spacer/2; } + + } \ No newline at end of file diff --git a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts index 380e05c334..46098ff69d 100644 --- a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts +++ b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts @@ -57,6 +57,9 @@ export class ItemMetadataComponent implements OnInit { } + /** + * Set up and initialize all fields + */ ngOnInit(): void { this.route.parent.data.pipe(map((data) => data.item)) .pipe( @@ -116,8 +119,10 @@ export class ItemMetadataComponent implements OnInit { this.objectUpdatesService.initialize(this.url, this.item.metadata, this.item.lastModified); } - /* Prevent unnecessary rerendering so fields don't lose focus **/ - protected trackUpdate(index, update: FieldUpdate) { + /** + * Prevent unnecessary rerendering so fields don't lose focus + */ + trackUpdate(index, update: FieldUpdate) { return update && update.field ? update.field.uuid : undefined; } diff --git a/src/app/+item-page/edit-item-page/item-status/item-status.component.html b/src/app/+item-page/edit-item-page/item-status/item-status.component.html index 0f7d9a5607..e60fa0490d 100644 --- a/src/app/+item-page/edit-item-page/item-status/item-status.component.html +++ b/src/app/+item-page/edit-item-page/item-status/item-status.component.html @@ -12,7 +12,7 @@ {{'item.edit.tabs.status.labels.itemPage' | translate}}:
diff --git a/src/app/+item-page/edit-item-page/item-status/item-status.component.ts b/src/app/+item-page/edit-item-page/item-status/item-status.component.ts index 28cd23a5fe..b9a9e4a2f3 100644 --- a/src/app/+item-page/edit-item-page/item-status/item-status.component.ts +++ b/src/app/+item-page/edit-item-page/item-status/item-status.component.ts @@ -6,6 +6,7 @@ import { ItemOperation } from '../item-operation/itemOperation.model'; import { first, map } from 'rxjs/operators'; import { Observable } from 'rxjs'; import { RemoteData } from '../../../core/data/remote-data'; +import { getItemEditPath, getItemPageRoute } from '../../item-page-routing.module'; @Component({ selector: 'ds-item-status', @@ -68,16 +69,16 @@ export class ItemStatusComponent implements OnInit { */ this.operations = []; if (item.isWithdrawn) { - this.operations.push(new ItemOperation('reinstate', this.getCurrentUrl() + '/reinstate')); + this.operations.push(new ItemOperation('reinstate', this.getCurrentUrl(item) + '/reinstate')); } else { - this.operations.push(new ItemOperation('withdraw', this.getCurrentUrl() + '/withdraw')); + this.operations.push(new ItemOperation('withdraw', this.getCurrentUrl(item) + '/withdraw')); } if (item.isDiscoverable) { - this.operations.push(new ItemOperation('private', this.getCurrentUrl() + '/private')); + this.operations.push(new ItemOperation('private', this.getCurrentUrl(item) + '/private')); } else { - this.operations.push(new ItemOperation('public', this.getCurrentUrl() + '/public')); + this.operations.push(new ItemOperation('public', this.getCurrentUrl(item) + '/public')); } - this.operations.push(new ItemOperation('delete', this.getCurrentUrl() + '/delete')); + this.operations.push(new ItemOperation('delete', this.getCurrentUrl(item) + '/delete')); }); } @@ -86,20 +87,16 @@ export class ItemStatusComponent implements OnInit { * Get the url to the simple item page * @returns {string} url */ - getItemPage(): string { - return this.router.url.substr(0, this.router.url.lastIndexOf('/')); + getItemPage(item: Item): string { + return getItemPageRoute(item.id) } /** * Get the current url without query params * @returns {string} url */ - getCurrentUrl(): string { - if (this.router.url.indexOf('?') > -1) { - return this.router.url.substr(0, this.router.url.indexOf('?')); - } else { - return this.router.url; - } + getCurrentUrl(item: Item): string { + return getItemEditPath(item.id); } } diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 1039ded993..357f552074 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -68,6 +68,7 @@ import { MenuService } from '../shared/menu/menu.service'; import { NormalizedObjectBuildService } from './cache/builders/normalized-object-build.service'; import { DSOChangeAnalyzer } from './data/dso-change-analyzer.service'; import { ObjectUpdatesService } from './data/object-updates/object-updates.service'; +import { DefaultChangeAnalyzer } from './data/default-change-analyzer.service'; const IMPORTS = [ CommonModule, @@ -133,6 +134,7 @@ const PROVIDERS = [ UUIDService, DSpaceObjectDataService, DSOChangeAnalyzer, + DefaultChangeAnalyzer, CSSVariableService, MenuService, ObjectUpdatesService, diff --git a/src/app/core/data/default-change-analyzer.service.ts b/src/app/core/data/default-change-analyzer.service.ts new file mode 100644 index 0000000000..1fd207d2bf --- /dev/null +++ b/src/app/core/data/default-change-analyzer.service.ts @@ -0,0 +1,29 @@ +import { Operation } from 'fast-json-patch/lib/core'; +import { compare } from 'fast-json-patch'; +import { ChangeAnalyzer } from './change-analyzer'; +import { NormalizedDSpaceObject } from '../cache/models/normalized-dspace-object.model'; +import { Injectable } from '@angular/core'; +import { DSpaceObject } from '../shared/dspace-object.model'; +import { CacheableObject } from '../cache/object-cache.reducer'; +import { NormalizedObject } from '../cache/models/normalized-object.model'; + +/** + * A class to determine what differs between two + * CacheableObjects + */ +@Injectable() +export class DefaultChangeAnalyzer implements ChangeAnalyzer { + + /** + * Compare the metadata of two CacheableObject and return the differences as + * a JsonPatch Operation Array + * + * @param {NormalizedObject} object1 + * The first object to compare + * @param {NormalizedObject} object2 + * The second object to compare + */ + diff(object1: T | NormalizedObject, object2: T | NormalizedObject): Operation[] { + return compare(object1, object2); + } +} diff --git a/src/app/core/data/metadata-schema-data.service.ts b/src/app/core/data/metadata-schema-data.service.ts index 7f17ad9cf1..ab5b859530 100644 --- a/src/app/core/data/metadata-schema-data.service.ts +++ b/src/app/core/data/metadata-schema-data.service.ts @@ -11,10 +11,10 @@ import { HALEndpointService } from '../shared/hal-endpoint.service'; import { FindAllOptions } from './request.models'; import { ObjectCacheService } from '../cache/object-cache.service'; import { MetadataSchema } from '../metadata/metadataschema.model'; -import { ChangeAnalyzer } from './change-analyzer'; import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; import { HttpClient } from '@angular/common/http'; import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; @Injectable() export class MetadataSchemaDataService extends DataService { @@ -27,7 +27,7 @@ export class MetadataSchemaDataService extends DataService { private bs: BrowseService, protected halService: HALEndpointService, protected objectCache: ObjectCacheService, - protected comparator: ChangeAnalyzer, + protected comparator: DefaultChangeAnalyzer, protected dataBuildService: NormalizedObjectBuildService, protected http: HttpClient, protected notificationsService: NotificationsService) { diff --git a/src/app/core/metadata/metadata.service.spec.ts b/src/app/core/metadata/metadata.service.spec.ts index 1841fba6a0..25579a0690 100644 --- a/src/app/core/metadata/metadata.service.spec.ts +++ b/src/app/core/metadata/metadata.service.spec.ts @@ -228,6 +228,7 @@ describe('MetadataService', () => { const mockPublisher = (mockItem: Item): Item => { const publishedMockItem = Object.assign(new Item(), mockItem) as Item; publishedMockItem.metadata.push({ + uuid: 'b3826cf5-5f07-44cf-88d8-2da968354d18', key: 'dc.publisher', language: 'en_US', value: 'Mock Publisher' diff --git a/src/styles/_bootstrap_variables.scss b/src/styles/_bootstrap_variables.scss index d24811b382..7be76ff5d3 100644 --- a/src/styles/_bootstrap_variables.scss +++ b/src/styles/_bootstrap_variables.scss @@ -19,7 +19,6 @@ $gray-700: lighten($gray-base, 46.6%) !default; // #777 $gray-600: lighten($gray-base, 73.3%) !default; // #bbb $gray-100: lighten($gray-base, 93.5%) !default; // #eee - /* Reassign color vars to semantic color scheme */ $blue: #2B4E72 !default; $green: #94BA65 !default; diff --git a/src/styles/_custom_variables.scss b/src/styles/_custom_variables.scss index dda018ad2c..05387e8740 100644 --- a/src/styles/_custom_variables.scss +++ b/src/styles/_custom_variables.scss @@ -1,7 +1,6 @@ $content-spacing: $spacer * 1.5; $button-height: $input-btn-padding-y * 2 + $input-btn-line-height + calculateRem($input-btn-border-width*2); -$button-min-width: 100px; $card-height-percentage:98%; $card-thumbnail-height:240px; @@ -24,3 +23,6 @@ $admin-sidebar-header-bg: darken($dark, 7%); $dark-scrollbar-background: $admin-sidebar-active-bg; $dark-scrollbar-foreground: #47495d; + +$edit-item-button-min-width: 100px; +$edit-item-metadata-field-width: 190px; \ No newline at end of file From 1cea791cba6e014d3a1283227c5286fb3768a7a2 Mon Sep 17 00:00:00 2001 From: lotte Date: Tue, 19 Feb 2019 15:59:03 +0100 Subject: [PATCH 343/457] fixed tests and added some more documentation --- .../edit-item-page.component.ts | 4 ++ .../edit-in-place-field.component.spec.ts | 38 ++------------- .../item-metadata.component.html | 4 +- .../item-metadata.component.spec.ts | 46 +++++++++++++++---- .../item-status/item-status.component.spec.ts | 9 +--- .../item-status/item-status.component.ts | 6 +-- .../object-updates/object-updates.actions.ts | 5 +- .../object-updates/object-updates.reducer.ts | 32 ++++++++++++- .../input-suggestions.component.ts | 6 +-- .../input-suggestions.model.ts | 10 ++++ 10 files changed, 100 insertions(+), 60 deletions(-) diff --git a/src/app/+item-page/edit-item-page/edit-item-page.component.ts b/src/app/+item-page/edit-item-page/edit-item-page.component.ts index efde7c52fb..4ea47f08e7 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.component.ts +++ b/src/app/+item-page/edit-item-page/edit-item-page.component.ts @@ -50,6 +50,10 @@ export class EditItemPageComponent implements OnInit { this.itemRD$ = this.route.data.pipe(map((data) => data.item)); } + /** + * Get the item page url + * @param item The item for which the url is requested + */ getItemPage(item: Item): string { return getItemPageRoute(item.id) } diff --git a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts index 133ee19e43..565c720a75 100644 --- a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts @@ -89,7 +89,7 @@ describe('EditInPlaceFieldComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(EditInPlaceFieldComponent); comp = fixture.componentInstance; // EditInPlaceFieldComponent test instance - de = fixture.debugElement.query(By.css('div.d-flex')); + de = fixture.debugElement; el = de.nativeElement; comp.url = url; @@ -109,36 +109,6 @@ describe('EditInPlaceFieldComponent', () => { }); }); - describe('changeType is UPDATE', () => { - beforeEach(() => { - comp.fieldUpdate.changeType = FieldChangeType.UPDATE; - fixture.detectChanges(); - }); - it('the div should have class table-warning', () => { - expect(el.classList).toContain('table-warning'); - }); - }); - - describe('changeType is ADD', () => { - beforeEach(() => { - comp.fieldUpdate.changeType = FieldChangeType.ADD; - fixture.detectChanges(); - }); - it('the div should have class table-success', () => { - expect(el.classList).toContain('table-success'); - }); - }); - - describe('changeType is REMOVE', () => { - beforeEach(() => { - comp.fieldUpdate.changeType = FieldChangeType.REMOVE; - fixture.detectChanges(); - }); - it('the div should have class table-danger', () => { - expect(el.classList).toContain('table-danger'); - }); - }); - describe('setEditable', () => { const editable = false; beforeEach(() => { @@ -223,9 +193,9 @@ describe('EditInPlaceFieldComponent', () => { const metadataFieldSuggestions: InputSuggestion[] = [ - { displayValue: mdField1.toString(), value: mdField1.toString() }, - { displayValue: mdField2.toString(), value: mdField2.toString() }, - { displayValue: mdField3.toString(), value: mdField3.toString() } + { displayValue: mdField1.toString().split('.').join('.​'), value: mdField1.toString() }, + { displayValue: mdField2.toString().split('.').join('.​'), value: mdField2.toString() }, + { displayValue: mdField3.toString().split('.').join('.​'), value: mdField3.toString() } ]; beforeEach(() => { diff --git a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.html b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.html index bb0c188f05..03b0e143b4 100644 --- a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.html +++ b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.html @@ -30,8 +30,8 @@ {{'item.edit.metadata.headers.language' | translate}} {{'item.edit.metadata.headers.edit' | translate}} - (request: RestRequest, forceBypassCache: boolean = false): void { const isGetRequest = request.method === RestRequestMethod.GET; - if (forceBypassCache) { - this.clearRequestsOnTheirWayToTheStore(request.href); - } - if (!isGetRequest || !this.isCachedOrPending(request) || (forceBypassCache && !this.isPending(request))) { + if (!isGetRequest || !this.isCachedOrPending(request) || forceBypassCache) { this.dispatchRequest(request); - if (isGetRequest) { + if (isGetRequest && !forceBypassCache) { this.trackRequestsOnTheirWayToTheStore(request); } } else { diff --git a/src/app/core/eperson/eperson-response-parsing.service.ts b/src/app/core/eperson/eperson-response-parsing.service.ts index 4af9da71a7..56429340da 100644 --- a/src/app/core/eperson/eperson-response-parsing.service.ts +++ b/src/app/core/eperson/eperson-response-parsing.service.ts @@ -3,7 +3,7 @@ import { Inject, Injectable } from '@angular/core'; import { RestRequest } from '../data/request.models'; import { ResponseParsingService } from '../data/parsing.service'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; -import { EpersonSuccessResponse, ErrorResponse, RestResponse } from '../cache/response-cache.models'; +import { EpersonSuccessResponse, ErrorResponse, RestResponse } from '../cache/response.models'; import { isNotEmpty } from '../../shared/empty.util'; import { BaseResponseParsingService } from '../data/base-response-parsing.service'; import { GLOBAL_CONFIG } from '../../../config'; diff --git a/src/app/core/eperson/eperson.service.ts b/src/app/core/eperson/eperson.service.ts index 777dc397ae..6aff82421d 100644 --- a/src/app/core/eperson/eperson.service.ts +++ b/src/app/core/eperson/eperson.service.ts @@ -1,21 +1,13 @@ import { Observable } from 'rxjs'; -import { RequestService } from '../data/request.service'; -import { ResponseCacheService } from '../cache/response-cache.service'; -import { EpersonRequest, FindAllOptions } from '../data/request.models'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { FindAllOptions } from '../data/request.models'; import { NormalizedObject } from '../cache/models/normalized-object.model'; import { DataService } from '../data/data.service'; +import { CacheableObject } from '../cache/object-cache.reducer'; /** * An abstract class that provides methods to make HTTP request to eperson endpoint. */ -export abstract class EpersonService extends DataService { - protected request: EpersonRequest; - protected abstract responseCache: ResponseCacheService; - protected abstract requestService: RequestService; - protected abstract linkPath: string; - protected abstract browseEndpoint: string; - protected abstract halService: HALEndpointService; +export abstract class EpersonService extends DataService { public getBrowseEndpoint(options: FindAllOptions): Observable { return this.halService.getEndpoint(this.linkPath); diff --git a/src/app/core/eperson/group-eperson.service.ts b/src/app/core/eperson/group-eperson.service.ts index f26ef7d8b7..0e12c55139 100644 --- a/src/app/core/eperson/group-eperson.service.ts +++ b/src/app/core/eperson/group-eperson.service.ts @@ -1,11 +1,11 @@ import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; import { filter, map, take } from 'rxjs/operators'; import { EpersonService } from './eperson.service'; -import { ResponseCacheService } from '../cache/response-cache.service'; import { RequestService } from '../data/request.service'; import { FindAllOptions } from '../data/request.models'; import { HALEndpointService } from '../shared/hal-endpoint.service'; @@ -17,6 +17,9 @@ import { ObjectCacheService } from '../cache/object-cache.service'; import { SearchParam } from '../cache/models/search-param.model'; import { RemoteData } from '../data/remote-data'; import { PaginatedList } from '../data/paginated-list'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; /** * Provides methods to retrieve eperson group resources. @@ -28,7 +31,10 @@ export class GroupEpersonService extends EpersonService protected forceBypassCache = false; constructor( - protected responseCache: ResponseCacheService, + protected comparator: DSOChangeAnalyzer, + protected dataBuildService: NormalizedObjectBuildService, + protected http: HttpClient, + protected notificationsService: NotificationsService, protected requestService: RequestService, protected rdbService: RemoteDataBuildService, protected store: Store, @@ -57,4 +63,5 @@ export class GroupEpersonService extends EpersonService map((groups: RemoteData>) => groups.payload.totalElements > 0) ); } + } diff --git a/src/app/core/eperson/models/normalized-group.model.ts b/src/app/core/eperson/models/normalized-group.model.ts index be5995d9c5..3189adbc8f 100644 --- a/src/app/core/eperson/models/normalized-group.model.ts +++ b/src/app/core/eperson/models/normalized-group.model.ts @@ -12,6 +12,9 @@ export class NormalizedGroup extends NormalizedDSpaceObject implements Cacheable @autoserialize public handle: string; + @autoserialize + public name: string; + @autoserialize public permanent: boolean; } diff --git a/src/app/core/integration/integration-response-parsing.service.spec.ts b/src/app/core/integration/integration-response-parsing.service.spec.ts index 9f24aef9b2..4187606265 100644 --- a/src/app/core/integration/integration-response-parsing.service.spec.ts +++ b/src/app/core/integration/integration-response-parsing.service.spec.ts @@ -35,35 +35,35 @@ describe('IntegrationResponseParsingService', () => { function initVars() { pageInfo = Object.assign(new PageInfo(), { elementsPerPage: 5, totalElements: 5, totalPages: 1, currentPage: 1, self: 'https://rest.api/integration/authorities/type/entries'}); definitions = new PaginatedList(pageInfo,[ - Object.assign({}, new AuthorityValue(), { + Object.assign(new AuthorityValue(), { type: 'authority', display: 'One', id: 'One', otherInformation: undefined, value: 'One' }), - Object.assign({}, new AuthorityValue(), { + Object.assign(new AuthorityValue(), { type: 'authority', display: 'Two', id: 'Two', otherInformation: undefined, value: 'Two' }), - Object.assign({}, new AuthorityValue(), { + Object.assign(new AuthorityValue(), { type: 'authority', display: 'Three', id: 'Three', otherInformation: undefined, value: 'Three' }), - Object.assign({}, new AuthorityValue(), { + Object.assign(new AuthorityValue(), { type: 'authority', display: 'Four', id: 'Four', otherInformation: undefined, value: 'Four' }), - Object.assign({}, new AuthorityValue(), { + Object.assign(new AuthorityValue(), { type: 'authority', display: 'Five', id: 'Five', diff --git a/src/app/core/integration/integration.service.ts b/src/app/core/integration/integration.service.ts index 22028e1427..5826f4646d 100644 --- a/src/app/core/integration/integration.service.ts +++ b/src/app/core/integration/integration.service.ts @@ -2,16 +2,12 @@ import { Observable, of as observableOf, throwError as observableThrowError } fr import { distinctUntilChanged, filter, map, mergeMap, tap } from 'rxjs/operators'; import { RequestService } from '../data/request.service'; import { IntegrationSuccessResponse } from '../cache/response.models'; -import { ResponseCacheService } from '../cache/response-cache.service'; -import { IntegrationSuccessResponse, RestResponse } from '../cache/response-cache.models'; import { GetRequest, IntegrationRequest } from '../data/request.models'; import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { IntegrationData } from './integration-data'; import { IntegrationSearchOptions } from './models/integration-options.model'; -import { RequestEntry } from '../data/request.reducer'; import { getResponseFromEntry } from '../shared/operators'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; export abstract class IntegrationService { protected request: IntegrationRequest; diff --git a/src/app/core/json-patch/json-patch-operations.service.spec.ts b/src/app/core/json-patch/json-patch-operations.service.spec.ts index a06f1ec5e4..67751ee9a9 100644 --- a/src/app/core/json-patch/json-patch-operations.service.spec.ts +++ b/src/app/core/json-patch/json-patch-operations.service.spec.ts @@ -1,12 +1,11 @@ import { async, TestBed } from '@angular/core/testing'; -import { cold, getTestScheduler } from 'jasmine-marbles'; +import { getTestScheduler } from 'jasmine-marbles'; import { TestScheduler } from 'rxjs/testing'; import { of as observableOf } from 'rxjs'; import { Store, StoreModule } from '@ngrx/store'; import { getMockRequestService } from '../../shared/mocks/mock-request.service'; -import { ResponseCacheService } from '../cache/response-cache.service'; import { RequestService } from '../data/request.service'; import { SubmissionPatchRequest } from '../data/request.models'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; @@ -23,13 +22,13 @@ import { StartTransactionPatchOperationsAction } from './json-patch-operations.actions'; import { MockStore } from '../../shared/testing/mock-store'; +import { RequestEntry } from '../data/request.reducer'; class TestService extends JsonPatchOperationsService { protected linkPath = ''; protected patchRequestConstructor = SubmissionPatchRequest; constructor( - protected responseCache: ResponseCacheService, protected requestService: RequestService, protected store: Store, protected halService: HALEndpointService) { @@ -41,7 +40,6 @@ class TestService extends JsonPatchOperationsService { let scheduler: TestScheduler; let service: TestService; - let responseCache: ResponseCacheService; let requestService: RequestService; let rdbService: RemoteDataBuildService; let halService: any; @@ -84,18 +82,14 @@ describe('JsonPatchOperationsService test suite', () => { value: ['test'] }]; - function initMockResponseCacheService(isSuccessful: boolean): ResponseCacheService { - return jasmine.createSpyObj('responseCache', { - get: cold('c-', { - c: {response: {isSuccessful}, - timeAdded: timestampResponse} - }) - }); - } + const getRequestEntry$ = (successful: boolean) => { + return observableOf({ + response: { isSuccessful: successful, timeAdded: timestampResponse } as any + } as RequestEntry) + }; function initTestService(): TestService { return new TestService( - responseCache, requestService, store, halService @@ -116,8 +110,7 @@ describe('JsonPatchOperationsService test suite', () => { beforeEach(() => { store = TestBed.get(Store); - responseCache = initMockResponseCacheService(true); - requestService = getMockRequestService(); + requestService = getMockRequestService(getRequestEntry$(true)); rdbService = getMockRemoteDataBuildService(); scheduler = getTestScheduler(); halService = new HALEndpointServiceStub(resourceEndpointURL); @@ -146,7 +139,7 @@ describe('JsonPatchOperationsService test suite', () => { scheduler.schedule(() => service.jsonPatchByResourceType(resourceEndpoint, resourceScope, testJsonPatchResourceType).subscribe()); scheduler.flush(); - expect(requestService.configure).toHaveBeenCalledWith(expected, true); + expect(requestService.configure).toHaveBeenCalledWith(expected); }); it('should dispatch a new StartTransactionPatchOperationsAction', () => { @@ -170,8 +163,7 @@ describe('JsonPatchOperationsService test suite', () => { describe('when request is not successful', () => { beforeEach(() => { store = TestBed.get(Store); - responseCache = initMockResponseCacheService(false); - requestService = getMockRequestService(); + requestService = getMockRequestService(getRequestEntry$(false)); rdbService = getMockRemoteDataBuildService(); scheduler = getTestScheduler(); halService = new HALEndpointServiceStub(resourceEndpointURL); @@ -208,7 +200,7 @@ describe('JsonPatchOperationsService test suite', () => { scheduler.schedule(() => service.jsonPatchByResourceID(resourceEndpoint, resourceScope, testJsonPatchResourceType, testJsonPatchResourceId).subscribe()); scheduler.flush(); - expect(requestService.configure).toHaveBeenCalledWith(expected, true); + expect(requestService.configure).toHaveBeenCalledWith(expected); }); it('should dispatch a new StartTransactionPatchOperationsAction', () => { @@ -232,8 +224,7 @@ describe('JsonPatchOperationsService test suite', () => { describe('when request is not successful', () => { beforeEach(() => { store = TestBed.get(Store); - responseCache = initMockResponseCacheService(false); - requestService = getMockRequestService(); + requestService = getMockRequestService(getRequestEntry$(false)); rdbService = getMockRemoteDataBuildService(); scheduler = getTestScheduler(); halService = new HALEndpointServiceStub(resourceEndpointURL); diff --git a/src/app/core/json-patch/json-patch-operations.service.ts b/src/app/core/json-patch/json-patch-operations.service.ts index a6b3ddc7e1..7ca59e8b39 100644 --- a/src/app/core/json-patch/json-patch-operations.service.ts +++ b/src/app/core/json-patch/json-patch-operations.service.ts @@ -1,12 +1,10 @@ -import { merge as observableMerge, Observable, of as observableOf, throwError as observableThrowError } from 'rxjs'; -import { distinctUntilChanged, filter, flatMap, map, mergeMap, partition, take, tap } from 'rxjs/operators'; +import { merge as observableMerge, Observable, of as observableOf } from 'rxjs'; +import { distinctUntilChanged, filter, find, flatMap, map, partition, take, tap } from 'rxjs/operators'; import { Store } from '@ngrx/store'; import { hasValue, isEmpty, isNotEmpty, isNotUndefined, isUndefined } from '../../shared/empty.util'; -import { ErrorResponse, PostPatchSuccessResponse, RestResponse } from '../cache/response-cache.models'; -import { ResponseCacheEntry } from '../cache/response-cache.reducer'; -import { ResponseCacheService } from '../cache/response-cache.service'; -import { PatchRequest, RestRequest } from '../data/request.models'; +import { ErrorResponse, PostPatchSuccessResponse, RestResponse } from '../cache/response.models'; +import { PatchRequest } from '../data/request.models'; import { RequestService } from '../data/request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { CoreState } from '../core.reducers'; @@ -18,31 +16,20 @@ import { StartTransactionPatchOperationsAction } from './json-patch-operations.actions'; import { JsonPatchOperationModel } from './json-patch.model'; +import { getResponseFromEntry } from '../shared/operators'; +import { ObjectCacheEntry } from '../cache/object-cache.reducer'; /** * An abstract class that provides methods to make JSON Patch requests. */ export abstract class JsonPatchOperationsService { - protected abstract responseCache: ResponseCacheService; + protected abstract requestService: RequestService; protected abstract store: Store; protected abstract linkPath: string; protected abstract halService: HALEndpointService; protected abstract patchRequestConstructor: any; - protected submitData(request: RestRequest): Observable { - const responses = this.responseCache.get(request.href).pipe(map((entry: ResponseCacheEntry) => entry.response)); - const errorResponses = responses.pipe( - filter((response) => !response.isSuccessful), - mergeMap(() => observableThrowError(new Error(`Couldn't send data to server`))) - ); - const successResponses = responses.pipe( - filter((response: PostPatchSuccessResponse) => isNotEmpty(response)), - map((response: PostPatchSuccessResponse) => response.dataDefinition) - ); - return observableMerge(errorResponses, successResponses); - } - /** * Submit a new JSON Patch request with all operations stored in the state that are ready to be dispatched * @@ -56,6 +43,7 @@ export abstract class JsonPatchOperationsService, resourceType: string, resourceId?: string): Observable { + const requestId = this.requestService.generateRequestId(); let startTransactionTime = null; const [patchRequest$, emptyRequest$] = partition((request: PatchRequestDefinition) => isNotEmpty(request.body))(hrefObs.pipe( flatMap((endpointURL: string) => { @@ -84,7 +72,7 @@ export abstract class JsonPatchOperationsService isNotEmpty(request.body)), tap(() => this.store.dispatch(new StartTransactionPatchOperationsAction(resourceType, resourceId, startTransactionTime))), - tap((request: PatchRequestDefinition) => this.requestService.configure(request, true)), - flatMap((request: PatchRequestDefinition) => { - const [successResponse$, errorResponse$] = partition((response: RestResponse) => response.isSuccessful)(this.responseCache.get(request.href).pipe( - filter((entry: ResponseCacheEntry) => startTransactionTime < entry.timeAdded), - take(1), - map((entry: ResponseCacheEntry) => entry.response) + tap((request: PatchRequestDefinition) => this.requestService.configure(request)), + flatMap(() => { + const [successResponse$, errorResponse$] = partition((response: RestResponse) => response.isSuccessful)(this.requestService.getByUUID(requestId).pipe( + getResponseFromEntry(), + find((entry: ObjectCacheEntry) => startTransactionTime < entry.timeAdded), + map((entry: ObjectCacheEntry) => entry), )); return observableMerge( errorResponse$.pipe( diff --git a/src/app/core/shared/dspace-object.model.ts b/src/app/core/shared/dspace-object.model.ts index 8016f3e425..f61f013064 100644 --- a/src/app/core/shared/dspace-object.model.ts +++ b/src/app/core/shared/dspace-object.model.ts @@ -1,5 +1,5 @@ import { Metadatum } from './metadatum.model' -import { isEmpty, isNotEmpty } from '../../shared/empty.util'; +import { isEmpty, isNotEmpty, isUndefined } from '../../shared/empty.util'; import { CacheableObject } from '../cache/object-cache.reducer'; import { RemoteData } from '../data/remote-data'; import { ResourceType } from './resource-type'; @@ -12,6 +12,8 @@ import { autoserialize } from 'cerialize'; */ export class DSpaceObject implements CacheableObject, ListableObject { + private _name: string; + self: string; /** @@ -35,7 +37,14 @@ export class DSpaceObject implements CacheableObject, ListableObject { * The name for this DSpaceObject */ get name(): string { - return this.findMetadata('dc.title'); + return (isUndefined(this._name)) ? this.findMetadata('dc.title') : this._name; + } + + /** + * The name for this DSpaceObject + */ + set name(name) { + this._name = name; } /** diff --git a/src/app/core/shared/file.service.ts b/src/app/core/shared/file.service.ts index 6dfdb2b647..6d4c997733 100644 --- a/src/app/core/shared/file.service.ts +++ b/src/app/core/shared/file.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'; import { HttpHeaders } from '@angular/common/http'; import { DSpaceRESTv2Service, HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; -import { RestRequestMethod } from '../data/request.models'; +import { RestRequestMethod } from '../data/rest-request-method'; import { saveAs } from 'file-saver'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; @@ -24,7 +24,7 @@ export class FileService { downloadFile(url: string) { const headers = new HttpHeaders(); const options: HttpOptions = Object.create({headers, responseType: 'blob'}); - return this.restService.request(RestRequestMethod.Get, url, null, options) + return this.restService.request(RestRequestMethod.GET, url, null, options) .subscribe((data) => { saveAs(data.payload as Blob, this.getFileNameFromResponseContentDisposition(data)); }); diff --git a/src/app/core/submission/models/submission-object.model.ts b/src/app/core/submission/models/submission-object.model.ts index 3a899e4a07..7e3b74a6a9 100644 --- a/src/app/core/submission/models/submission-object.model.ts +++ b/src/app/core/submission/models/submission-object.model.ts @@ -48,7 +48,7 @@ export abstract class SubmissionObject extends DSpaceObject implements Cacheable /** * The submission config definition */ - submissionDefinition: SubmissionDefinitionsModel; + submissionDefinition: Observable> | SubmissionDefinitionsModel; /** * The workspaceitem submitter diff --git a/src/app/core/submission/submission-json-patch-operations.service.spec.ts b/src/app/core/submission/submission-json-patch-operations.service.spec.ts index d0682720d1..39e6cd42fb 100644 --- a/src/app/core/submission/submission-json-patch-operations.service.spec.ts +++ b/src/app/core/submission/submission-json-patch-operations.service.spec.ts @@ -3,7 +3,6 @@ import { Store } from '@ngrx/store'; import { getTestScheduler } from 'jasmine-marbles'; import { TestScheduler } from 'rxjs/testing'; -import { ResponseCacheService } from '../cache/response-cache.service'; import { CoreState } from '../core.reducers'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { SubmissionJsonPatchOperationsService } from './submission-json-patch-operations.service'; @@ -14,13 +13,11 @@ describe('SubmissionJsonPatchOperationsService', () => { let scheduler: TestScheduler; let service: SubmissionJsonPatchOperationsService; const requestService = {} as RequestService; - const responseCache = {} as ResponseCacheService; const store = {} as Store; const halEndpointService = {} as HALEndpointService; function initTestService() { return new SubmissionJsonPatchOperationsService( - responseCache, requestService, store, halEndpointService diff --git a/src/app/core/submission/submission-json-patch-operations.service.ts b/src/app/core/submission/submission-json-patch-operations.service.ts index 076c843d69..f9371100d6 100644 --- a/src/app/core/submission/submission-json-patch-operations.service.ts +++ b/src/app/core/submission/submission-json-patch-operations.service.ts @@ -2,7 +2,6 @@ import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; -import { ResponseCacheService } from '../cache/response-cache.service'; import { RequestService } from '../data/request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { JsonPatchOperationsService } from '../json-patch/json-patch-operations.service'; @@ -16,7 +15,6 @@ export class SubmissionJsonPatchOperationsService extends JsonPatchOperationsSer protected patchRequestConstructor = SubmissionPatchRequest; constructor( - protected responseCache: ResponseCacheService, protected requestService: RequestService, protected store: Store, protected halService: HALEndpointService) { diff --git a/src/app/core/submission/submission-response-parsing.service.ts b/src/app/core/submission/submission-response-parsing.service.ts index 5cfd524d62..c9c944d74d 100644 --- a/src/app/core/submission/submission-response-parsing.service.ts +++ b/src/app/core/submission/submission-response-parsing.service.ts @@ -3,7 +3,7 @@ import { Inject, Injectable } from '@angular/core'; import { ResponseParsingService } from '../data/parsing.service'; import { RestRequest } from '../data/request.models'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; -import { ErrorResponse, RestResponse, SubmissionSuccessResponse } from '../cache/response-cache.models'; +import { ErrorResponse, RestResponse, SubmissionSuccessResponse } from '../cache/response.models'; import { isEmpty, isNotEmpty, isNotNull } from '../../shared/empty.util'; import { ConfigObject } from '../config/models/config.model'; import { BaseResponseParsingService } from '../data/base-response-parsing.service'; diff --git a/src/app/core/submission/workflowitem-data.service.ts b/src/app/core/submission/workflowitem-data.service.ts index 0b71812cbf..4468ffdf04 100644 --- a/src/app/core/submission/workflowitem-data.service.ts +++ b/src/app/core/submission/workflowitem-data.service.ts @@ -1,17 +1,19 @@ import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; import { Store } from '@ngrx/store'; -import { BrowseService } from '../browse/browse.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { ResponseCacheService } from '../cache/response-cache.service'; import { CoreState } from '../core.reducers'; - import { DataService } from '../data/data.service'; import { RequestService } from '../data/request.service'; import { NormalizedWorkflowItem } from './models/normalized-workflowitem.model'; import { Workflowitem } from './models/workflowitem.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { FindAllOptions } from '../data/request.models'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; @Injectable() export class WorkflowitemDataService extends DataService { @@ -19,12 +21,15 @@ export class WorkflowitemDataService extends DataService, - protected bs: BrowseService, - protected halService: HALEndpointService) { + protected objectCache: ObjectCacheService, + protected store: Store) { super(); } diff --git a/src/app/core/submission/workspaceitem-data.service.ts b/src/app/core/submission/workspaceitem-data.service.ts index b3685f9775..2edd086fd0 100644 --- a/src/app/core/submission/workspaceitem-data.service.ts +++ b/src/app/core/submission/workspaceitem-data.service.ts @@ -1,17 +1,19 @@ import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; import { Store } from '@ngrx/store'; -import { BrowseService } from '../browse/browse.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { ResponseCacheService } from '../cache/response-cache.service'; import { CoreState } from '../core.reducers'; - import { DataService } from '../data/data.service'; import { RequestService } from '../data/request.service'; import { Workspaceitem } from './models/workspaceitem.model'; import { NormalizedWorkspaceItem } from './models/normalized-workspaceitem.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { FindAllOptions } from '../data/request.models'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; @Injectable() export class WorkspaceitemDataService extends DataService { @@ -19,12 +21,15 @@ export class WorkspaceitemDataService extends DataService, - protected bs: BrowseService, - protected halService: HALEndpointService) { + protected objectCache: ObjectCacheService, + protected store: Store) { super(); } diff --git a/src/app/shared/comcol-forms/delete-comcol-page/delete-comcol-page.component.spec.ts b/src/app/shared/comcol-forms/delete-comcol-page/delete-comcol-page.component.spec.ts index 81ec9c47a0..c1444ec25b 100644 --- a/src/app/shared/comcol-forms/delete-comcol-page/delete-comcol-page.component.spec.ts +++ b/src/app/shared/comcol-forms/delete-comcol-page/delete-comcol-page.component.spec.ts @@ -108,7 +108,6 @@ describe('DeleteComColPageComponent', () => { it('should show an error notification on failure', () => { (dsoDataService.delete as any).and.returnValue(observableOf(false)); - spyOn(notificationsService, 'error'); spyOn(router, 'navigate'); comp.onConfirm(data2); fixture.detectChanges(); @@ -117,7 +116,6 @@ describe('DeleteComColPageComponent', () => { }); it('should show a success notification on success and navigate', () => { - spyOn(notificationsService, 'success'); spyOn(router, 'navigate'); comp.onConfirm(data1); fixture.detectChanges(); diff --git a/src/app/shared/testing/submission-rest-service-stub.ts b/src/app/shared/testing/submission-rest-service-stub.ts index 1ef821f626..53b2341b50 100644 --- a/src/app/shared/testing/submission-rest-service-stub.ts +++ b/src/app/shared/testing/submission-rest-service-stub.ts @@ -1,14 +1,12 @@ import { of as observableOf } from 'rxjs'; import { Store } from '@ngrx/store'; -import { ResponseCacheService } from '../../core/cache/response-cache.service'; import { RequestService } from '../../core/data/request.service'; import { CoreState } from '../../core/core.reducers'; import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; export class SubmissionRestServiceStub { protected linkPath = 'workspaceitems'; - protected responseCache: ResponseCacheService; protected requestService: RequestService; protected store: Store; protected halService: HALEndpointService; diff --git a/src/app/submission/form/submission-form.component.ts b/src/app/submission/form/submission-form.component.ts index 57a6d77a79..deb39fbae2 100644 --- a/src/app/submission/form/submission-form.component.ts +++ b/src/app/submission/form/submission-form.component.ts @@ -104,9 +104,9 @@ export class SubmissionFormComponent implements OnChanges, OnDestroy { onCollectionChange(submissionObject: SubmissionObject) { this.collectionId = (submissionObject.collection as Collection).id; - if (this.definitionId !== submissionObject.submissionDefinition.name) { + if (this.definitionId !== (submissionObject.submissionDefinition as SubmissionDefinitionsModel).name) { this.sections = submissionObject.sections; - this.submissionDefinition = submissionObject.submissionDefinition; + this.submissionDefinition = (submissionObject.submissionDefinition as SubmissionDefinitionsModel); this.definitionId = this.submissionDefinition.name; this.submissionService.resetSubmissionObject( this.collectionId, diff --git a/src/app/submission/sections/upload/file/view/file-view.component.spec.ts b/src/app/submission/sections/upload/file/view/file-view.component.spec.ts index c79c444a3f..5fbc0fadd8 100644 --- a/src/app/submission/sections/upload/file/view/file-view.component.spec.ts +++ b/src/app/submission/sections/upload/file/view/file-view.component.spec.ts @@ -77,7 +77,6 @@ describe('UploadSectionFileViewComponent test suite', () => { fixture.detectChanges(); - console.log(comp.metadata); expect(comp.metadata.length).toBe(2); }); diff --git a/src/app/submission/submission-rest.service.spec.ts b/src/app/submission/submission-rest.service.spec.ts index 0759e7cfba..c5992d7d10 100644 --- a/src/app/submission/submission-rest.service.spec.ts +++ b/src/app/submission/submission-rest.service.spec.ts @@ -1,8 +1,7 @@ import { TestScheduler } from 'rxjs/testing'; -import { cold, getTestScheduler } from 'jasmine-marbles'; +import { getTestScheduler } from 'jasmine-marbles'; import { SubmissionRestService } from './submission-rest.service'; -import { ResponseCacheService } from '../core/cache/response-cache.service'; import { RequestService } from '../core/data/request.service'; import { RemoteDataBuildService } from '../core/cache/builders/remote-data-build.service'; import { getMockRequestService } from '../shared/mocks/mock-request.service'; @@ -19,7 +18,6 @@ import { FormFieldMetadataValueObject } from '../shared/form/builder/models/form describe('SubmissionRestService test suite', () => { let scheduler: TestScheduler; let service: SubmissionRestService; - let responseCache: ResponseCacheService; let requestService: RequestService; let rdbService: RemoteDataBuildService; let halService: any; @@ -31,27 +29,15 @@ describe('SubmissionRestService test suite', () => { const resourceHref = resourceEndpointURL + '/' + resourceEndpoint + '/' + resourceScope; const timestampResponse = 1545994811992; - function initMockResponseCacheService(isSuccessful: boolean): ResponseCacheService { - return jasmine.createSpyObj('responseCache', { - get: cold('c-', { - c: {response: {isSuccessful}, - timeAdded: timestampResponse} - }), - remove: jasmine.createSpy('remove') - }); - } - function initTestService() { return new SubmissionRestService( rdbService, - responseCache, requestService, halService ); } beforeEach(() => { - responseCache = initMockResponseCacheService(true); requestService = getMockRequestService(); rdbService = getMockRemoteDataBuildService(); scheduler = getTestScheduler(); @@ -86,7 +72,7 @@ describe('SubmissionRestService test suite', () => { scheduler.schedule(() => service.postToEndpoint(resourceEndpoint, body, resourceScope).subscribe()); scheduler.flush(); - expect(requestService.configure).toHaveBeenCalledWith(expected, true); + expect(requestService.configure).toHaveBeenCalledWith(expected); }); }); @@ -96,7 +82,7 @@ describe('SubmissionRestService test suite', () => { scheduler.schedule(() => service.patchToEndpoint(resourceEndpoint, body, resourceScope).subscribe()); scheduler.flush(); - expect(requestService.configure).toHaveBeenCalledWith(expected, true); + expect(requestService.configure).toHaveBeenCalledWith(expected); }); }); }); diff --git a/src/app/submission/submission-rest.service.ts b/src/app/submission/submission-rest.service.ts index 83c8e5f594..b5d563549f 100644 --- a/src/app/submission/submission-rest.service.ts +++ b/src/app/submission/submission-rest.service.ts @@ -3,10 +3,7 @@ import { Injectable } from '@angular/core'; import { merge as observableMerge, Observable, throwError as observableThrowError } from 'rxjs'; import { distinctUntilChanged, filter, flatMap, map, mergeMap, tap } from 'rxjs/operators'; -import { ResponseCacheService } from '../core/cache/response-cache.service'; import { RequestService } from '../core/data/request.service'; -import { ResponseCacheEntry } from '../core/cache/response-cache.reducer'; -import { ErrorResponse, RestResponse, SubmissionSuccessResponse } from '../core/cache/response-cache.models'; import { isNotEmpty } from '../shared/empty.util'; import { DeleteRequest, @@ -21,6 +18,8 @@ import { SubmitDataResponseDefinitionObject } from '../core/shared/submit-data-r import { HttpOptions } from '../core/dspace-rest-v2/dspace-rest-v2.service'; import { HALEndpointService } from '../core/shared/hal-endpoint.service'; import { RemoteDataBuildService } from '../core/cache/builders/remote-data-build.service'; +import { ErrorResponse, RestResponse, SubmissionSuccessResponse } from '../core/cache/response.models'; +import { getResponseFromEntry } from '../core/shared/operators'; @Injectable() export class SubmissionRestService { @@ -28,13 +27,14 @@ export class SubmissionRestService { constructor( protected rdbService: RemoteDataBuildService, - protected responseCache: ResponseCacheService, protected requestService: RequestService, protected halService: HALEndpointService) { } - protected submitData(request: RestRequest): Observable { - const responses = this.responseCache.get(request.href).pipe(map((entry: ResponseCacheEntry) => entry.response)); + protected fetchRequest(requestId: string): Observable { + const responses = this.requestService.getByUUID(requestId).pipe( + getResponseFromEntry() + ); const errorResponses = responses.pipe( filter((response: RestResponse) => !response.isSuccessful), mergeMap((error: ErrorResponse) => observableThrowError(error)) @@ -47,67 +47,55 @@ export class SubmissionRestService { return observableMerge(errorResponses, successResponses); } - protected fetchRequest(request: RestRequest): Observable { - const responses = this.responseCache.get(request.href).pipe( - map((entry: ResponseCacheEntry) => entry.response), - tap(() => this.responseCache.remove(request.href))); - const errorResponses = responses.pipe( - filter((response: RestResponse) => !response.isSuccessful), - mergeMap((error: ErrorResponse) => observableThrowError(error)) - ); - const successResponses = responses.pipe( - filter((response: SubmissionSuccessResponse) => response.isSuccessful && isNotEmpty(response)), - map((response: SubmissionSuccessResponse) => response.dataDefinition as any), - distinctUntilChanged() - ); - return observableMerge(errorResponses, successResponses); - } - protected getEndpointByIDHref(endpoint, resourceID): string { return isNotEmpty(resourceID) ? `${endpoint}/${resourceID}` : `${endpoint}`; } public deleteById(scopeId: string, linkName?: string): Observable { + const requestId = this.requestService.generateRequestId(); return this.halService.getEndpoint(linkName || this.linkPath).pipe( filter((href: string) => isNotEmpty(href)), distinctUntilChanged(), map((endpointURL: string) => this.getEndpointByIDHref(endpointURL, scopeId)), - map((endpointURL: string) => new SubmissionDeleteRequest(this.requestService.generateRequestId(), endpointURL)), + map((endpointURL: string) => new SubmissionDeleteRequest(requestId, endpointURL)), tap((request: DeleteRequest) => this.requestService.configure(request)), - flatMap((request: DeleteRequest) => this.submitData(request)), + flatMap((request: DeleteRequest) => this.fetchRequest(requestId)), distinctUntilChanged()); } public getDataById(linkName: string, id: string): Observable { + const requestId = this.requestService.generateRequestId(); return this.halService.getEndpoint(linkName).pipe( map((endpointURL: string) => this.getEndpointByIDHref(endpointURL, id)), filter((href: string) => isNotEmpty(href)), distinctUntilChanged(), - map((endpointURL: string) => new SubmissionRequest(this.requestService.generateRequestId(), endpointURL)), + map((endpointURL: string) => new SubmissionRequest(requestId, endpointURL)), tap((request: RestRequest) => this.requestService.configure(request, true)), - flatMap((request: RestRequest) => this.fetchRequest(request)), + flatMap((request: RestRequest) => this.fetchRequest(requestId)), distinctUntilChanged()); } public postToEndpoint(linkName: string, body: any, scopeId?: string, options?: HttpOptions): Observable { + const requestId = this.requestService.generateRequestId(); return this.halService.getEndpoint(linkName).pipe( filter((href: string) => isNotEmpty(href)), map((endpointURL: string) => this.getEndpointByIDHref(endpointURL, scopeId)), distinctUntilChanged(), - map((endpointURL: string) => new SubmissionPostRequest(this.requestService.generateRequestId(), endpointURL, body, options)), - tap((request: PostRequest) => this.requestService.configure(request, true)), - flatMap((request: PostRequest) => this.submitData(request)), + map((endpointURL: string) => new SubmissionPostRequest(requestId, endpointURL, body, options)), + tap((request: PostRequest) => this.requestService.configure(request)), + flatMap((request: PostRequest) => this.fetchRequest(requestId)), distinctUntilChanged()); } public patchToEndpoint(linkName: string, body: any, scopeId?: string): Observable { + const requestId = this.requestService.generateRequestId(); return this.halService.getEndpoint(linkName).pipe( filter((href: string) => isNotEmpty(href)), map((endpointURL: string) => this.getEndpointByIDHref(endpointURL, scopeId)), distinctUntilChanged(), - map((endpointURL: string) => new SubmissionPatchRequest(this.requestService.generateRequestId(), endpointURL, body)), - tap((request: PostRequest) => this.requestService.configure(request, true)), - flatMap((request: PostRequest) => this.submitData(request)), + map((endpointURL: string) => new SubmissionPatchRequest(requestId, endpointURL, body)), + tap((request: PostRequest) => this.requestService.configure(request)), + flatMap((request: PostRequest) => this.fetchRequest(requestId)), distinctUntilChanged()); } diff --git a/src/app/submission/submission.service.ts b/src/app/submission/submission.service.ts index 53a89547d5..2357d97c6b 100644 --- a/src/app/submission/submission.service.ts +++ b/src/app/submission/submission.service.ts @@ -41,7 +41,7 @@ import { NotificationsService } from '../shared/notifications/notifications.serv import { SubmissionDefinitionsModel } from '../core/config/models/config-submission-definitions.model'; import { WorkspaceitemSectionsObject } from '../core/submission/models/workspaceitem-sections.model'; import { RemoteData } from '../core/data/remote-data'; -import { ErrorResponse } from '../core/cache/response-cache.models'; +import { ErrorResponse } from '../core/cache/response.models'; import { RemoteDataError } from '../core/data/remote-data-error'; @Injectable() diff --git a/src/config/auto-sync-config.interface.ts b/src/config/auto-sync-config.interface.ts index 90e7ebee80..b737314f56 100644 --- a/src/config/auto-sync-config.interface.ts +++ b/src/config/auto-sync-config.interface.ts @@ -27,4 +27,4 @@ export interface AutoSyncConfig { * The max number of requests in the buffer before a sync to the server */ maxBufferSize: number; -}; +} diff --git a/yarn.lock b/yarn.lock index 279d0e8135..dd6298f480 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3500,6 +3500,7 @@ fast-glob@^2.0.2: fast-json-patch@^2.0.7: version "2.0.7" resolved "https://registry.yarnpkg.com/fast-json-patch/-/fast-json-patch-2.0.7.tgz#55864b08b1e50381d2f37fd472bb2e18fe54a733" + integrity sha512-DQeoEyPYxdTtfmB3yDlxkLyKTdbJ6ABfFGcMynDqjvGhPYLto/pZyb/dG2Nyd/n9CArjEWN9ZST++AFmgzgbGw== dependencies: deep-equal "^1.0.1" From 16f448021b1d4cd43b185405a1fe50b40308bba5 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Wed, 20 Feb 2019 08:54:51 +0100 Subject: [PATCH 345/457] 60168: Browse-by startsWith text component --- resources/i18n/en.json | 1 + .../browse-by-metadata-page.component.ts | 6 +++++- .../browse-by-title-page.component.ts | 2 +- ...browse-by-starts-with-abstract.component.ts | 8 ++++++++ .../browse-by-starts-with-text.component.html | 18 +++++++++++++++++- 5 files changed, 32 insertions(+), 3 deletions(-) diff --git a/resources/i18n/en.json b/resources/i18n/en.json index a9a456df33..b1e76631f0 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -285,6 +285,7 @@ "jump": "Jump to a point in the index:", "choose_year": "(Choose year)", "type_year": "Or type in a year:", + "type_text": "Or enter first few letters:", "submit": "Go" }, "metadata": { diff --git a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts index cfd8c74107..79a8ff0421 100644 --- a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts +++ b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts @@ -123,7 +123,11 @@ export class BrowseByMetadataPageComponent implements OnInit { } this.updateParent(params.scope); })); - this.startsWithOptions = []; + this.updateStartsWithTextOptions(); + } + + updateStartsWithTextOptions() { + this.startsWithOptions = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); } /** diff --git a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.ts b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.ts index 8703193dc5..717275bf8b 100644 --- a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.ts +++ b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.ts @@ -45,7 +45,7 @@ export class BrowseByTitlePageComponent extends BrowseByMetadataPageComponent { this.updatePageWithItems(browseParamsToOptions(params, this.paginationConfig, this.sortConfig, this.metadata), undefined); this.updateParent(params.scope) })); - this.startsWithOptions = []; + this.updateStartsWithTextOptions(); } ngOnDestroy(): void { diff --git a/src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-abstract.component.ts b/src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-abstract.component.ts index a51ad5e2ea..97030f7cdf 100644 --- a/src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-abstract.component.ts +++ b/src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-abstract.component.ts @@ -39,6 +39,14 @@ export class BrowseByStartsWithAbstractComponent implements OnInit, OnDestroy { }); } + getStartsWithAsText() { + if (hasValue(this.startsWith)) { + return this.startsWith; + } else { + return ''; + } + } + /** * Get startsWith as a number; */ diff --git a/src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.html b/src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.html index 7a1b58e832..5255423d81 100644 --- a/src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.html +++ b/src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.html @@ -1 +1,17 @@ - +
+
+
+ +
+
+ + + + +
+
+
From a0501b0c3ca40939a20782a42bc6083c753d4162 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Wed, 20 Feb 2019 09:24:07 +0100 Subject: [PATCH 346/457] 60168: Refactor browse-by-starts-with components to starts-with components + further refactoring by seperating date from text --- .../browse-by-date-page.component.ts | 4 +- .../browse-by-metadata-page.component.ts | 4 +- .../browse-by-starts-with-date.component.ts | 18 --------- .../browse-by-starts-with-text.component.scss | 0 .../browse-by-starts-with-text.component.ts | 17 --------- .../shared/browse-by/browse-by.component.ts | 12 +----- src/app/shared/shared.module.ts | 8 ++-- .../date/starts-with-date.component.html} | 2 +- .../date/starts-with-date.component.scss} | 2 +- .../date/starts-with-date.component.spec.ts} | 16 ++++---- .../date/starts-with-date.component.ts | 34 +++++++++++++++++ .../starts-with-abstract.component.ts} | 21 +++------- .../starts-with-decorator.spec.ts} | 7 ++-- .../starts-with-decorator.ts} | 14 +++++-- .../text/starts-with-text.component.html} | 2 +- .../text/starts-with-text.component.scss | 7 ++++ .../text/starts-with-text.component.ts | 38 +++++++++++++++++++ 17 files changed, 118 insertions(+), 88 deletions(-) delete mode 100644 src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.ts delete mode 100644 src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.scss delete mode 100644 src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.ts rename src/app/shared/{browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.html => starts-with/date/starts-with-date.component.html} (94%) rename src/app/shared/{browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.scss => starts-with/date/starts-with-date.component.scss} (72%) rename src/app/shared/{browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.spec.ts => starts-with/date/starts-with-date.component.spec.ts} (87%) create mode 100644 src/app/shared/starts-with/date/starts-with-date.component.ts rename src/app/shared/{browse-by/browse-by-starts-with/browse-by-starts-with-abstract.component.ts => starts-with/starts-with-abstract.component.ts} (80%) rename src/app/shared/{browse-by/browse-by-starts-with/browse-by-starts-with-decorator.spec.ts => starts-with/starts-with-decorator.spec.ts} (54%) rename src/app/shared/{browse-by/browse-by-starts-with/browse-by-starts-with-decorator.ts => starts-with/starts-with-decorator.ts} (60%) rename src/app/shared/{browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.html => starts-with/text/starts-with-text.component.html} (92%) create mode 100644 src/app/shared/starts-with/text/starts-with-text.component.scss create mode 100644 src/app/shared/starts-with/text/starts-with-text.component.ts diff --git a/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts b/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts index c5048c9520..8e7502fec9 100644 --- a/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts +++ b/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts @@ -5,7 +5,6 @@ import { } from '../+browse-by-metadata-page/browse-by-metadata-page.component'; import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search-options.model'; import { combineLatest as observableCombineLatest } from 'rxjs/internal/observable/combineLatest'; -import { BrowseByStartsWithType } from '../../shared/browse-by/browse-by.component'; import { RemoteData } from '../../core/data/remote-data'; import { PaginatedList } from '../../core/data/paginated-list'; import { Item } from '../../core/shared/item.model'; @@ -14,6 +13,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { BrowseService } from '../../core/browse/browse.service'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; +import { StartsWithType } from '../../shared/starts-with/starts-with-decorator'; @Component({ selector: 'ds-browse-by-date-page', @@ -42,7 +42,7 @@ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent { } ngOnInit(): void { - this.startsWithType = BrowseByStartsWithType.date; + this.startsWithType = StartsWithType.date; this.updatePage(new BrowseEntrySearchOptions(null, this.paginationConfig, this.sortConfig)); this.subs.push( observableCombineLatest( diff --git a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts index 79a8ff0421..e960ac2ae9 100644 --- a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts +++ b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts @@ -14,7 +14,7 @@ import { getSucceededRemoteData } from '../../core/shared/operators'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { take } from 'rxjs/operators'; -import { BrowseByStartsWithType } from '../../shared/browse-by/browse-by.component'; +import { StartsWithType } from '../../shared/starts-with/starts-with-decorator'; @Component({ selector: 'ds-browse-by-metadata-page', @@ -76,7 +76,7 @@ export class BrowseByMetadataPageComponent implements OnInit { * The type of StartsWith options to render * Defaults to text */ - startsWithType = BrowseByStartsWithType.text; + startsWithType = StartsWithType.text; /** * The list of StartsWith options diff --git a/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.ts b/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.ts deleted file mode 100644 index 78551270d6..0000000000 --- a/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Component } from '@angular/core'; -import { renderStartsWithFor } from '../browse-by-starts-with-decorator'; -import { BrowseByStartsWithType } from '../../browse-by.component'; -import { BrowseByStartsWithAbstractComponent } from '../browse-by-starts-with-abstract.component'; - -/** - * A switchable component rendering StartsWith options for the type "Date". - * The options are rendered in a dropdown with an input field (of type number) next to it. - */ -@Component({ - selector: 'ds-browse-by-starts-with-date', - styleUrls: ['./browse-by-starts-with-date.component.scss'], - templateUrl: './browse-by-starts-with-date.component.html' -}) -@renderStartsWithFor(BrowseByStartsWithType.date) -export class BrowseByStartsWithDateComponent extends BrowseByStartsWithAbstractComponent { - -} diff --git a/src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.scss b/src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.scss deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.ts b/src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.ts deleted file mode 100644 index 23ecacfa34..0000000000 --- a/src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Component, Inject } from '@angular/core'; -import { renderStartsWithFor } from '../browse-by-starts-with-decorator'; -import { BrowseByStartsWithType } from '../../browse-by.component'; -import { BrowseByStartsWithAbstractComponent } from '../browse-by-starts-with-abstract.component'; - -/** - * A switchable component rendering StartsWith options for the type "Text". - */ -@Component({ - selector: 'ds-browse-by-starts-with-text', - styleUrls: ['./browse-by-starts-with-text.component.scss'], - templateUrl: './browse-by-starts-with-text.component.html' -}) -@renderStartsWithFor(BrowseByStartsWithType.text) -export class BrowseByStartsWithTextComponent extends BrowseByStartsWithAbstractComponent { - -} diff --git a/src/app/shared/browse-by/browse-by.component.ts b/src/app/shared/browse-by/browse-by.component.ts index 4d5c35f3bc..6c4bc78213 100644 --- a/src/app/shared/browse-by/browse-by.component.ts +++ b/src/app/shared/browse-by/browse-by.component.ts @@ -6,15 +6,7 @@ import { SortDirection, SortOptions } from '../../core/cache/models/sort-options import { fadeIn, fadeInOut } from '../animations/fade'; import { Observable } from 'rxjs'; import { ListableObject } from '../object-collection/shared/listable-object.model'; -import { getStartsWithComponent } from './browse-by-starts-with/browse-by-starts-with-decorator'; - -/** - * An enum that defines the type of StartsWith options - */ -export enum BrowseByStartsWithType { - text = 'Text', - date = 'Date' -} +import { getStartsWithComponent, StartsWithType } from '../starts-with/starts-with-decorator'; @Component({ selector: 'ds-browse-by', @@ -53,7 +45,7 @@ export class BrowseByComponent implements OnInit { * The type of StartsWith options used to define what component to render for the options * Defaults to text */ - @Input() type = BrowseByStartsWithType.text; + @Input() type = StartsWithType.text; /** * The list of options to render for the StartsWith component diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index b8530101f9..18f151fccb 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -88,8 +88,8 @@ import { MomentModule } from 'ngx-moment'; import { MenuModule } from './menu/menu.module'; import {LangSwitchComponent} from './lang-switch/lang-switch.component'; import { ComcolPageBrowseByComponent } from './comcol-page-browse-by/comcol-page-browse-by.component'; -import { BrowseByStartsWithDateComponent } from './browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component'; -import { BrowseByStartsWithTextComponent } from './browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component'; +import { StartsWithDateComponent } from './starts-with/date/starts-with-date.component'; +import { StartsWithTextComponent } from './starts-with/text/starts-with-text.component'; const MODULES = [ // Do NOT include UniversalModule, HttpModule, or JsonpModule here @@ -180,8 +180,8 @@ const ENTRY_COMPONENTS = [ CommunityGridElementComponent, SearchResultGridElementComponent, BrowseEntryListElementComponent, - BrowseByStartsWithDateComponent, - BrowseByStartsWithTextComponent + StartsWithDateComponent, + StartsWithTextComponent ]; const PROVIDERS = [ diff --git a/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.html b/src/app/shared/starts-with/date/starts-with-date.component.html similarity index 94% rename from src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.html rename to src/app/shared/starts-with/date/starts-with-date.component.html index 0403e1b94f..22f59b0875 100644 --- a/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.html +++ b/src/app/shared/starts-with/date/starts-with-date.component.html @@ -16,7 +16,7 @@
- + diff --git a/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.scss b/src/app/shared/starts-with/date/starts-with-date.component.scss similarity index 72% rename from src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.scss rename to src/app/shared/starts-with/date/starts-with-date.component.scss index e516151d57..ceec56c8c2 100644 --- a/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.scss +++ b/src/app/shared/starts-with/date/starts-with-date.component.scss @@ -1,4 +1,4 @@ -@import '../../../../../styles/variables.scss'; +@import '../../../../styles/variables.scss'; // temporary fix for bootstrap 4 beta btn color issue .btn-secondary { diff --git a/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.spec.ts b/src/app/shared/starts-with/date/starts-with-date.component.spec.ts similarity index 87% rename from src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.spec.ts rename to src/app/shared/starts-with/date/starts-with-date.component.spec.ts index c0812245e9..e2956a2a97 100644 --- a/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.spec.ts +++ b/src/app/shared/starts-with/date/starts-with-date.component.spec.ts @@ -1,20 +1,20 @@ -import { BrowseByStartsWithDateComponent } from './browse-by-starts-with-date.component'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { CommonModule } from '@angular/common'; import { RouterTestingModule } from '@angular/router/testing'; import { TranslateModule } from '@ngx-translate/core'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; -import { EnumKeysPipe } from '../../../utils/enum-keys-pipe'; import { ActivatedRoute, Router } from '@angular/router'; import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ActivatedRouteStub } from '../../../testing/active-router-stub'; import { of as observableOf } from 'rxjs/internal/observable/of'; -import { RouterStub } from '../../../testing/router-stub'; import { By } from '@angular/platform-browser'; +import { StartsWithDateComponent } from './starts-with-date.component'; +import { ActivatedRouteStub } from '../../testing/active-router-stub'; +import { EnumKeysPipe } from '../../utils/enum-keys-pipe'; +import { RouterStub } from '../../testing/router-stub'; describe('BrowseByStartsWithDateComponent', () => { - let comp: BrowseByStartsWithDateComponent; - let fixture: ComponentFixture; + let comp: StartsWithDateComponent; + let fixture: ComponentFixture; let route: ActivatedRoute; let router: Router; @@ -28,7 +28,7 @@ describe('BrowseByStartsWithDateComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], - declarations: [BrowseByStartsWithDateComponent, EnumKeysPipe], + declarations: [StartsWithDateComponent, EnumKeysPipe], providers: [ { provide: 'startsWithOptions', useValue: options }, { provide: ActivatedRoute, useValue: activatedRouteStub }, @@ -39,7 +39,7 @@ describe('BrowseByStartsWithDateComponent', () => { })); beforeEach(() => { - fixture = TestBed.createComponent(BrowseByStartsWithDateComponent); + fixture = TestBed.createComponent(StartsWithDateComponent); comp = fixture.componentInstance; fixture.detectChanges(); route = (comp as any).route; diff --git a/src/app/shared/starts-with/date/starts-with-date.component.ts b/src/app/shared/starts-with/date/starts-with-date.component.ts new file mode 100644 index 0000000000..ab4a26498e --- /dev/null +++ b/src/app/shared/starts-with/date/starts-with-date.component.ts @@ -0,0 +1,34 @@ +import { Component } from '@angular/core'; +import { renderStartsWithFor, StartsWithType } from '../starts-with-decorator'; +import { StartsWithAbstractComponent } from '../starts-with-abstract.component'; + +/** + * A switchable component rendering StartsWith options for the type "Date". + * The options are rendered in a dropdown with an input field (of type number) next to it. + */ +@Component({ + selector: 'ds-starts-with-date', + styleUrls: ['./starts-with-date.component.scss'], + templateUrl: './starts-with-date.component.html' +}) +@renderStartsWithFor(StartsWithType.date) +export class StartsWithDateComponent extends StartsWithAbstractComponent { + + /** + * Get startsWith as a number; + */ + getStartsWith() { + return +this.startsWith; + } + + /** + * Add/Change the url query parameter startsWith using the local variable + */ + setStartsWithParam() { + if (this.startsWith === '-1') { + this.startsWith = undefined; + } + super.setStartsWithParam(); + } + +} diff --git a/src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-abstract.component.ts b/src/app/shared/starts-with/starts-with-abstract.component.ts similarity index 80% rename from src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-abstract.component.ts rename to src/app/shared/starts-with/starts-with-abstract.component.ts index 97030f7cdf..f1137004a6 100644 --- a/src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-abstract.component.ts +++ b/src/app/shared/starts-with/starts-with-abstract.component.ts @@ -1,13 +1,13 @@ import { Inject, OnDestroy, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { hasValue } from '../../empty.util'; import { Subscription } from 'rxjs/internal/Subscription'; import { FormControl, FormGroup } from '@angular/forms'; +import { hasValue } from '../empty.util'; /** * An abstract component to render StartsWith options */ -export class BrowseByStartsWithAbstractComponent implements OnInit, OnDestroy { +export class StartsWithAbstractComponent implements OnInit, OnDestroy { /** * The currently selected startsWith in string format */ @@ -39,19 +39,11 @@ export class BrowseByStartsWithAbstractComponent implements OnInit, OnDestroy { }); } - getStartsWithAsText() { - if (hasValue(this.startsWith)) { - return this.startsWith; - } else { - return ''; - } - } - /** - * Get startsWith as a number; + * Get startsWith */ - getStartsWithAsNumber() { - return +this.startsWith; + getStartsWith(): any { + return this.startsWith; } /** @@ -67,9 +59,6 @@ export class BrowseByStartsWithAbstractComponent implements OnInit, OnDestroy { * Add/Change the url query parameter startsWith using the local variable */ setStartsWithParam() { - if (this.startsWith === '-1') { - this.startsWith = undefined; - } this.router.navigate([], { queryParams: Object.assign({ startsWith: this.startsWith }), queryParamsHandling: 'merge' diff --git a/src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-decorator.spec.ts b/src/app/shared/starts-with/starts-with-decorator.spec.ts similarity index 54% rename from src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-decorator.spec.ts rename to src/app/shared/starts-with/starts-with-decorator.spec.ts index 8eaa9eee09..0ba72d8ac4 100644 --- a/src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-decorator.spec.ts +++ b/src/app/shared/starts-with/starts-with-decorator.spec.ts @@ -1,9 +1,8 @@ -import { renderStartsWithFor } from './browse-by-starts-with-decorator'; -import { BrowseByStartsWithType } from '../browse-by.component'; +import { renderStartsWithFor, StartsWithType } from './starts-with-decorator'; describe('BrowseByStartsWithDecorator', () => { - const textDecorator = renderStartsWithFor(BrowseByStartsWithType.text); - const dateDecorator = renderStartsWithFor(BrowseByStartsWithType.date); + const textDecorator = renderStartsWithFor(StartsWithType.text); + const dateDecorator = renderStartsWithFor(StartsWithType.date); it('should have a decorator for both text and date', () => { expect(textDecorator.length).not.toBeNull(); expect(dateDecorator.length).not.toBeNull(); diff --git a/src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-decorator.ts b/src/app/shared/starts-with/starts-with-decorator.ts similarity index 60% rename from src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-decorator.ts rename to src/app/shared/starts-with/starts-with-decorator.ts index 88f07c766f..7592f00a8b 100644 --- a/src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-decorator.ts +++ b/src/app/shared/starts-with/starts-with-decorator.ts @@ -1,12 +1,18 @@ -import { BrowseByStartsWithType } from '../browse-by.component'; - const startsWithMap = new Map(); +/** + * An enum that defines the type of StartsWith options + */ +export enum StartsWithType { + text = 'Text', + date = 'Date' +} + /** * Fetch a decorator to render a StartsWith component for type * @param type */ -export function renderStartsWithFor(type: BrowseByStartsWithType) { +export function renderStartsWithFor(type: StartsWithType) { return function decorator(objectElement: any) { if (!objectElement) { return; @@ -19,6 +25,6 @@ export function renderStartsWithFor(type: BrowseByStartsWithType) { * Get the correct component depending on the StartsWith type * @param type */ -export function getStartsWithComponent(type: BrowseByStartsWithType) { +export function getStartsWithComponent(type: StartsWithType) { return startsWithMap.get(type); } diff --git a/src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.html b/src/app/shared/starts-with/text/starts-with-text.component.html similarity index 92% rename from src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.html rename to src/app/shared/starts-with/text/starts-with-text.component.html index 5255423d81..8ca2ad7565 100644 --- a/src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.html +++ b/src/app/shared/starts-with/text/starts-with-text.component.html @@ -8,7 +8,7 @@
- + diff --git a/src/app/shared/starts-with/text/starts-with-text.component.scss b/src/app/shared/starts-with/text/starts-with-text.component.scss new file mode 100644 index 0000000000..ceec56c8c2 --- /dev/null +++ b/src/app/shared/starts-with/text/starts-with-text.component.scss @@ -0,0 +1,7 @@ +@import '../../../../styles/variables.scss'; + +// temporary fix for bootstrap 4 beta btn color issue +.btn-secondary { + background-color: $input-bg; + color: $input-color; +} diff --git a/src/app/shared/starts-with/text/starts-with-text.component.ts b/src/app/shared/starts-with/text/starts-with-text.component.ts new file mode 100644 index 0000000000..ef6ff87163 --- /dev/null +++ b/src/app/shared/starts-with/text/starts-with-text.component.ts @@ -0,0 +1,38 @@ +import { Component, Inject } from '@angular/core'; +import { renderStartsWithFor, StartsWithType } from '../starts-with-decorator'; +import { StartsWithAbstractComponent } from '../starts-with-abstract.component'; +import { hasValue } from '../../empty.util'; + +/** + * A switchable component rendering StartsWith options for the type "Text". + */ +@Component({ + selector: 'ds-starts-with-text', + styleUrls: ['./starts-with-text.component.scss'], + templateUrl: './starts-with-text.component.html' +}) +@renderStartsWithFor(StartsWithType.text) +export class StartsWithTextComponent extends StartsWithAbstractComponent { + + /** + * Get startsWith as text; + */ + getStartsWith() { + if (hasValue(this.startsWith)) { + return this.startsWith; + } else { + return ''; + } + } + + /** + * Add/Change the url query parameter startsWith using the local variable + */ + setStartsWithParam() { + if (this.startsWith === '0-9') { + this.startsWith = '0'; + } + super.setStartsWithParam(); + } + +} From 7ce9b01adfb1131ba826805dd2707c0b21047aec Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Wed, 20 Feb 2019 09:38:00 +0100 Subject: [PATCH 347/457] 60168: 0-9 option for alphabetic startsWith --- .../browse-by-metadata-page.component.ts | 2 +- .../starts-with/date/starts-with-date.component.html | 2 +- .../starts-with/starts-with-abstract.component.ts | 11 ++++++++++- .../starts-with/text/starts-with-text.component.html | 2 +- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts index e960ac2ae9..47ca9e76c0 100644 --- a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts +++ b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts @@ -127,7 +127,7 @@ export class BrowseByMetadataPageComponent implements OnInit { } updateStartsWithTextOptions() { - this.startsWithOptions = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); + this.startsWithOptions = ['0-9', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')]; } /** diff --git a/src/app/shared/starts-with/date/starts-with-date.component.html b/src/app/shared/starts-with/date/starts-with-date.component.html index 22f59b0875..bddbb6f391 100644 --- a/src/app/shared/starts-with/date/starts-with-date.component.html +++ b/src/app/shared/starts-with/date/starts-with-date.component.html @@ -4,7 +4,7 @@ {{'browse.startsWith.jump' | translate}}
- diff --git a/src/app/shared/starts-with/starts-with-abstract.component.ts b/src/app/shared/starts-with/starts-with-abstract.component.ts index f1137004a6..f9105e2756 100644 --- a/src/app/shared/starts-with/starts-with-abstract.component.ts +++ b/src/app/shared/starts-with/starts-with-abstract.component.ts @@ -50,11 +50,20 @@ export class StartsWithAbstractComponent implements OnInit, OnDestroy { * Set the startsWith by event * @param event */ - setStartsWith(event: Event) { + setStartsWithEvent(event: Event) { this.startsWith = (event.target as HTMLInputElement).value; this.setStartsWithParam(); } + /** + * Set the startsWith by string + * @param startsWith + */ + setStartsWith(startsWith: string) { + this.startsWith = startsWith; + this.setStartsWithParam(); + } + /** * Add/Change the url query parameter startsWith using the local variable */ diff --git a/src/app/shared/starts-with/text/starts-with-text.component.html b/src/app/shared/starts-with/text/starts-with-text.component.html index 8ca2ad7565..41ab7294f1 100644 --- a/src/app/shared/starts-with/text/starts-with-text.component.html +++ b/src/app/shared/starts-with/text/starts-with-text.component.html @@ -3,7 +3,7 @@ From 9a0989f240c8b22d5c0907f5b88963a3e8960eeb Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Wed, 20 Feb 2019 10:08:12 +0100 Subject: [PATCH 348/457] 60168: Starts-With text dropdown options + styling --- resources/i18n/en.json | 1 + .../browse-by-metadata-page.component.html | 2 +- .../date/starts-with-date.component.ts | 10 ---------- .../starts-with-abstract.component.ts | 3 +++ .../text/starts-with-text.component.html | 16 ++++++++++++++-- .../text/starts-with-text.component.ts | 11 +++++++++++ 6 files changed, 30 insertions(+), 13 deletions(-) diff --git a/resources/i18n/en.json b/resources/i18n/en.json index b1e76631f0..5778edfa6c 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -284,6 +284,7 @@ "startsWith": { "jump": "Jump to a point in the index:", "choose_year": "(Choose year)", + "choose_start": "(Choose start)", "type_year": "Or type in a year:", "type_text": "Or enter first few letters:", "submit": "Go" diff --git a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.html b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.html index 23f52e506a..cf43f74eb0 100644 --- a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.html +++ b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.html @@ -1,5 +1,5 @@
-