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"