diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000000..8e4ed0811d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,22 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug, needs triage +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. Include the version(s) of DSpace where you've seen this problem & what *web browser* you were using. Link to examples if they are public. + +**To Reproduce** +Steps to reproduce the behavior: +1. Do this +2. Then this... + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Related work** +Link to any related tickets or PRs here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000000..34cc2c9e4f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest a new feature for this project +title: '' +labels: new feature, needs triage +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives or workarounds you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/disabled-workflows/pull_request_opened.yml b/.github/disabled-workflows/pull_request_opened.yml new file mode 100644 index 0000000000..0dc718c0b9 --- /dev/null +++ b/.github/disabled-workflows/pull_request_opened.yml @@ -0,0 +1,26 @@ +# This workflow runs whenever a new pull request is created +# TEMPORARILY DISABLED. Unfortunately this doesn't work for PRs created from forked repositories (which is how we tend to create PRs). +# There is no known workaround yet. See https://github.community/t/how-to-use-github-token-for-prs-from-forks/16818 +name: Pull Request opened + +# Only run for newly opened PRs against the "main" branch +on: + pull_request: + types: [opened] + branches: + - main + +jobs: + automation: + runs-on: ubuntu-latest + steps: + # Assign the PR to whomever created it. This is useful for visualizing assignments on project boards + # See https://github.com/marketplace/actions/pull-request-assigner + - name: Assign PR to creator + uses: thomaseizinger/assign-pr-creator-action@v1.0.0 + # Note, this authentication token is created automatically + # See: https://docs.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + # Ignore errors. It is possible the PR was created by someone who cannot be assigned + continue-on-error: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 682f67294b..3e727e2bdf 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,7 +1,7 @@ ## References -_Add references/links to any related tickets or PRs. These may include:_ -* Link to [Angular issue or PR](https://github.com/DSpace/dspace-angular/issues) related to this PR, if any -* Link to [JIRA](https://jira.lyrasis.org/projects/DS/summary) ticket(s), if any +_Add references/links to any related issues or PRs. These may include:_ +* Fixes [GitHub issue](https://github.com/DSpace/dspace-angular/issues), if any +* Requires [REST API PR](https://github.com/DSpace/DSpace/pulls), if any ## Description Short summary of changes (1-2 sentences). @@ -25,4 +25,4 @@ _This checklist provides a reminder of what we are going to look for when review * Include tests for different user types (if behavior differs), including: (1) Anonymous user, (2) Logged in user (non-admin), and (3) Administrator. * Include tests for error scenarios, e.g. when errors/warnings should appear (or buttons should be disabled). * For bug fixes, include a test that reproduces the bug and proves it is fixed. For clarity, it may be useful to provide the test in a separate commit from the bug fix. -- [ ] If my PR includes new, third-party dependencies (in `package.json`), I've made sure their licenses align with the [DSpace BSD License](https://github.com/DSpace/DSpace/blob/master/LICENSE) based on the [Licensing of Contributions](https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines#CodeContributionGuidelines-LicensingofContributions) documentation. +- [ ] If my PR includes new, third-party dependencies (in `package.json`), I've made sure their licenses align with the [DSpace BSD License](https://github.com/DSpace/DSpace/blob/main/LICENSE) based on the [Licensing of Contributions](https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines#CodeContributionGuidelines-LicensingofContributions) documentation. diff --git a/.github/workflows/issue_opened.yml b/.github/workflows/issue_opened.yml new file mode 100644 index 0000000000..6b9a273ab6 --- /dev/null +++ b/.github/workflows/issue_opened.yml @@ -0,0 +1,29 @@ +# This workflow runs whenever a new issue is created +name: Issue opened + +on: + issues: + types: [opened] + +jobs: + automation: + runs-on: ubuntu-latest + steps: + # Add the new issue to a project board, if it needs triage + # See https://github.com/marketplace/actions/create-project-card-action + - name: Add issue to project board + # Only add to project board if issue is flagged as "needs triage" or has no labels + # NOTE: By default we flag new issues as "needs triage" in our issue template + if: (contains(github.event.issue.labels.*.name, 'needs triage') || join(github.event.issue.labels.*.name) == '') + uses: technote-space/create-project-card-action@v1 + # Note, the authentication token below is an ORG level Secret. + # It must be created/recreated manually via a personal access token with "public_repo" and "admin:org" permissions + # See: https://docs.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token#permissions-for-the-github_token + # This is necessary because the "DSpace Backlog" project is an org level project (i.e. not repo specific) + with: + GITHUB_TOKEN: ${{ secrets.ORG_PROJECT_TOKEN }} + PROJECT: DSpace Backlog + COLUMN: Triage + CHECK_ORG_PROJECT: true + # Ignore errors + continue-on-error: true diff --git a/.travis.yml b/.travis.yml index 0d65d76f41..54b3c4752a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,4 @@ -sudo: required +os: linux dist: bionic language: node_js @@ -35,10 +35,11 @@ before_install: - google-chrome-stable --version install: - # Start up DSpace 7 using the entities database dump + # Start up a test DSpace 7 REST backend using the entities database dump - docker-compose -f ./docker/docker-compose-travis.yml up -d # Use the dspace-cli image to populate the assetstore. Triggers a discovery and oai update - docker-compose -f ./docker/cli.yml -f ./docker/cli.assetstore.yml run --rm dspace-cli + # Install all local dependencies (retry if initially fails) - travis_retry yarn install before_script: @@ -49,9 +50,17 @@ before_script: #- curl http://localhost:8080/server/ script: - - yarn run ci - - cat coverage/dspace-angular-cli/lcov.info | ./node_modules/coveralls/bin/coveralls.js + # build app and run all tests + - ng lint + - travis_wait yarn run build:prod + - yarn test:headless + - yarn run e2e:ci after_script: # Shutdown docker after everything runs - docker-compose -f ./docker/docker-compose-travis.yml down + +# After a successful build and test (see 'script'), send code coverage reports to coveralls.io +# These code coverage reports are generated by the coveralls node module in our package.json +after_success: + - cat coverage/dspace-angular/lcov.info | ./node_modules/coveralls/bin/coveralls.js diff --git a/README.md b/README.md index 5d8a323461..4addff3e1a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![Build Status](https://travis-ci.org/DSpace/dspace-angular.svg?branch=master)](https://travis-ci.org/DSpace/dspace-angular) [![Coverage Status](https://coveralls.io/repos/github/DSpace/dspace-angular/badge.svg?branch=master)](https://coveralls.io/github/DSpace/dspace-angular?branch=master) [![Universal Angular](https://img.shields.io/badge/universal-angular2-brightgreen.svg?style=flat)](https://github.com/angular/universal) +[![Build Status](https://travis-ci.com/DSpace/dspace-angular.svg?branch=main)](https://travis-ci.com/DSpace/dspace-angular) [![Coverage Status](https://coveralls.io/repos/github/DSpace/dspace-angular/badge.svg?branch=main)](https://coveralls.io/github/DSpace/dspace-angular?branch=main) [![Universal Angular](https://img.shields.io/badge/universal-angular2-brightgreen.svg?style=flat)](https://github.com/angular/universal) dspace-angular ============== diff --git a/angular.json b/angular.json index 92c0f27d2b..9c55d648b3 100644 --- a/angular.json +++ b/angular.json @@ -3,7 +3,7 @@ "version": 1, "newProjectRoot": "projects", "projects": { - "dspace-angular-cli": { + "dspace-angular": { "projectType": "application", "schematics": { "@schematics/angular:component": { @@ -21,7 +21,7 @@ "path": "./webpack/webpack.common.ts", "mergeStrategies": { "loaders": "prepend" - }, + } }, "outputPath": "dist/browser", "index": "src/index.html", @@ -65,19 +65,19 @@ "serve": { "builder": "@angular-builders/custom-webpack:dev-server", "options": { - "browserTarget": "dspace-angular-cli:build", + "browserTarget": "dspace-angular:build", "port": 4000 }, "configurations": { "production": { - "browserTarget": "dspace-angular-cli:build:production" + "browserTarget": "dspace-angular:build:production" } } }, "extract-i18n": { "builder": "@angular-devkit/build-angular:extract-i18n", "options": { - "browserTarget": "dspace-angular-cli:build" + "browserTarget": "dspace-angular:build" } }, "test": { @@ -119,11 +119,11 @@ "builder": "@angular-devkit/build-angular:protractor", "options": { "protractorConfig": "e2e/protractor.conf.js", - "devServerTarget": "dspace-angular-cli:serve" + "devServerTarget": "dspace-angular:serve" }, "configurations": { "production": { - "devServerTarget": "dspace-angular-cli:serve:production" + "devServerTarget": "dspace-angular:serve:production" } } }, @@ -153,5 +153,5 @@ } } }, - "defaultProject": "dspace-angular-cli" + "defaultProject": "dspace-angular" } diff --git a/docker/cli.ingest.yml b/docker/cli.ingest.yml index f5ec7eb90d..3972a87d50 100644 --- a/docker/cli.ingest.yml +++ b/docker/cli.ingest.yml @@ -11,7 +11,7 @@ version: "3.7" services: dspace-cli: environment: - - AIPZIP=https://github.com/DSpace-Labs/AIP-Files/raw/master/dogAndReport.zip + - AIPZIP=https://github.com/DSpace-Labs/AIP-Files/raw/main/dogAndReport.zip - ADMIN_EMAIL=test@test.edu - AIPDIR=/tmp/aip-dir entrypoint: diff --git a/karma.conf.js b/karma.conf.js index 9844d65904..a3b6803e6d 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -17,7 +17,7 @@ module.exports = function (config) { clearContext: false // leave Jasmine Spec Runner output visible in browser }, coverageIstanbulReporter: { - dir: require('path').join(__dirname, './coverage/dspace-angular-cli'), + dir: require('path').join(__dirname, './coverage/dspace-angular'), reports: ['html', 'lcovonly', 'text-summary'], fixWebpackSourcePaths: true }, diff --git a/package.json b/package.json index 21a89400bf..c1e5b05010 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "dspace-angular-cli", + "name": "dspace-angular", "version": "0.0.0", "scripts": { "ng": "ng", @@ -23,7 +23,7 @@ "build": "ng build", "build:prod": "yarn run build:ssr", "build:ssr": "yarn run build:client-and-server-bundles && yarn run compile:server", - "build:client-and-server-bundles": "node --max_old_space_size=8192 node_modules/@angular/cli/bin/ng build --prod && ng run dspace-angular-cli:server:production --bundleDependencies all", + "build:client-and-server-bundles": "node --max_old_space_size=8192 node_modules/@angular/cli/bin/ng build --prod && ng run dspace-angular:server:production --bundleDependencies all", "test:watch": "npm-run-all --parallel config:test:watch test", "test": "node --max_old_space_size=8192 node_modules/@angular/cli/bin/ng test --sourceMap=true --watch=true", "test:headless": "node --max_old_space_size=8192 node_modules/@angular/cli/bin/ng test --watch=false --sourceMap=true --browsers=ChromeHeadless --code-coverage", @@ -32,7 +32,6 @@ "e2e:ci": "ng e2e --protractor-config=./e2e/protractor-ci.conf.js", "compile:server": "webpack --config webpack.server.config.js --progress --colors", "serve:ssr": "node dist/server", - "ci": "ng lint && yarn run build:prod && yarn test:headless && yarn run e2e:ci", "clean:coverage": "rimraf coverage", "clean:dist": "rimraf dist", "clean:doc": "rimraf doc", @@ -140,7 +139,7 @@ "codelyzer": "^5.0.0", "compression-webpack-plugin": "^3.0.1", "copy-webpack-plugin": "^5.1.1", - "coveralls": "3.0.0", + "coveralls": "^3.0.0", "css-loader": "3.4.0", "cssnano": "^4.1.10", "deep-freeze": "0.0.1", diff --git a/src/app/+admin/admin-curation-tasks/admin-curation-tasks.component.html b/src/app/+admin/admin-curation-tasks/admin-curation-tasks.component.html new file mode 100644 index 0000000000..a702a7e6b0 --- /dev/null +++ b/src/app/+admin/admin-curation-tasks/admin-curation-tasks.component.html @@ -0,0 +1,4 @@ +
+

{{'admin.curation-tasks.header' |translate }}

+ +
diff --git a/src/app/+admin/admin-curation-tasks/admin-curation-tasks.component.spec.ts b/src/app/+admin/admin-curation-tasks/admin-curation-tasks.component.spec.ts new file mode 100644 index 0000000000..b84f619ff1 --- /dev/null +++ b/src/app/+admin/admin-curation-tasks/admin-curation-tasks.component.spec.ts @@ -0,0 +1,28 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { AdminCurationTasksComponent } from './admin-curation-tasks.component'; +import { TranslateModule } from '@ngx-translate/core'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; + +describe('AdminCurationTasksComponent', () => { + let comp: AdminCurationTasksComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [AdminCurationTasksComponent], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AdminCurationTasksComponent); + comp = fixture.componentInstance; + }); + describe('init', () => { + it('should initialise the comp', () => { + expect(comp).toBeDefined(); + expect(fixture.debugElement.nativeElement.innerHTML).toContain('ds-curation-form'); + }); + }); +}); diff --git a/src/app/+admin/admin-curation-tasks/admin-curation-tasks.component.ts b/src/app/+admin/admin-curation-tasks/admin-curation-tasks.component.ts new file mode 100644 index 0000000000..9a80f341b9 --- /dev/null +++ b/src/app/+admin/admin-curation-tasks/admin-curation-tasks.component.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; + +/** + * Component responsible for rendering the system wide Curation Task UI + */ +@Component({ + selector: 'ds-admin-curation-task', + templateUrl: './admin-curation-tasks.component.html', +}) +export class AdminCurationTasksComponent { + +} diff --git a/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.spec.ts b/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.spec.ts index 2acfa17c8b..3037744e67 100644 --- a/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.spec.ts +++ b/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.spec.ts @@ -209,7 +209,7 @@ describe('BitstreamFormatsComponent', () => { selectBitstreamFormat: {}, deselectBitstreamFormat: {}, deselectAllBitstreamFormats: {}, - delete: observableOf(true), + delete: observableOf({ isSuccessful: true }), clearBitStreamFormatRequests: observableOf('cleared') }); diff --git a/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.ts b/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.ts index 52010e0132..80ae56ec93 100644 --- a/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.ts +++ b/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.ts @@ -11,6 +11,7 @@ import { hasValue } from '../../../shared/empty.util'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; +import { RestResponse } from '../../../core/cache/response.models'; /** * This component renders a list of bitstream formats @@ -64,7 +65,7 @@ export class BitstreamFormatsComponent implements OnInit { const tasks$ = []; for (const format of formats) { if (hasValue(format.id)) { - tasks$.push(this.bitstreamFormatService.delete(format.id)); + tasks$.push(this.bitstreamFormatService.delete(format.id).pipe(map((response: RestResponse) => response.isSuccessful))); } } zip(...tasks$).subscribe((results: boolean[]) => { diff --git a/src/app/+admin/admin-routing.module.ts b/src/app/+admin/admin-routing.module.ts index b199129c4e..43b3a4ab34 100644 --- a/src/app/+admin/admin-routing.module.ts +++ b/src/app/+admin/admin-routing.module.ts @@ -6,6 +6,7 @@ import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.reso import { AdminWorkflowPageComponent } from './admin-workflow-page/admin-workflow-page.component'; import { I18nBreadcrumbsService } from '../core/breadcrumbs/i18n-breadcrumbs.service'; import { URLCombiner } from '../core/url-combiner/url-combiner'; +import { AdminCurationTasksComponent } from './admin-curation-tasks/admin-curation-tasks.component'; const REGISTRIES_MODULE_PATH = 'registries'; export const ACCESS_CONTROL_MODULE_PATH = 'access-control'; @@ -41,6 +42,12 @@ export function getAccessControlModulePath() { component: AdminWorkflowPageComponent, data: { title: 'admin.workflow.title', breadcrumbKey: 'admin.workflow' } }, + { + path: 'curation-tasks', + resolve: { breadcrumb: I18nBreadcrumbResolver }, + component: AdminCurationTasksComponent, + data: { title: 'admin.curation-tasks.title', breadcrumbKey: 'admin.curation-tasks' } + }, ]) ], providers: [ diff --git a/src/app/+admin/admin-sidebar/admin-sidebar.component.ts b/src/app/+admin/admin-sidebar/admin-sidebar.component.ts index ade3e33aed..eb86de5f3c 100644 --- a/src/app/+admin/admin-sidebar/admin-sidebar.component.ts +++ b/src/app/+admin/admin-sidebar/admin-sidebar.component.ts @@ -469,7 +469,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { model: { type: MenuItemType.LINK, text: 'menu.section.curation_task', - link: '' + link: 'admin/curation-tasks' } as LinkMenuItemModel, icon: 'filter', index: 7 diff --git a/src/app/+admin/admin.module.ts b/src/app/+admin/admin.module.ts index 25b8bd4648..85749afe03 100644 --- a/src/app/+admin/admin.module.ts +++ b/src/app/+admin/admin.module.ts @@ -16,6 +16,7 @@ import { WorkflowItemSearchResultAdminWorkflowGridElementComponent } from './adm import { WorkflowItemAdminWorkflowActionsComponent } from './admin-workflow-page/admin-workflow-search-results/workflow-item-admin-workflow-actions.component'; import { WorkflowItemSearchResultAdminWorkflowListElementComponent } from './admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-list-element/workflow-item/workflow-item-search-result-admin-workflow-list-element.component'; import { AdminWorkflowPageComponent } from './admin-workflow-page/admin-workflow-page.component'; +import { AdminCurationTasksComponent } from './admin-curation-tasks/admin-curation-tasks.component'; @NgModule({ imports: [ @@ -35,6 +36,7 @@ import { AdminWorkflowPageComponent } from './admin-workflow-page/admin-workflow CommunityAdminSearchResultGridElementComponent, CollectionAdminSearchResultGridElementComponent, ItemAdminSearchResultActionsComponent, + AdminCurationTasksComponent, WorkflowItemSearchResultAdminWorkflowListElementComponent, WorkflowItemSearchResultAdminWorkflowGridElementComponent, diff --git a/src/app/+collection-page/create-collection-page/create-collection-page.guard.ts b/src/app/+collection-page/create-collection-page/create-collection-page.guard.ts index 4cd842e926..5bcad1cbd7 100644 --- a/src/app/+collection-page/create-collection-page/create-collection-page.guard.ts +++ b/src/app/+collection-page/create-collection-page/create-collection-page.guard.ts @@ -5,8 +5,7 @@ import { hasNoValue, hasValue } from '../../shared/empty.util'; import { CommunityDataService } from '../../core/data/community-data.service'; import { RemoteData } from '../../core/data/remote-data'; import { Community } from '../../core/shared/community.model'; -import { getFinishedRemoteData } from '../../core/shared/operators'; -import { map, tap } from 'rxjs/operators'; +import { map, tap, find } from 'rxjs/operators'; import { Observable, of as observableOf } from 'rxjs'; /** @@ -29,18 +28,15 @@ export class CreateCollectionPageGuard implements CanActivate { this.router.navigate(['/404']); return observableOf(false); } - const parent: Observable> = this.communityService.findById(parentID) + return this.communityService.findById(parentID) .pipe( - getFinishedRemoteData(), - ); - - return parent.pipe( - map((communityRD: RemoteData) => hasValue(communityRD) && communityRD.hasSucceeded && hasValue(communityRD.payload)), - tap((isValid: boolean) => { - if (!isValid) { - this.router.navigate(['/404']); - } - }) + find((communityRD: RemoteData) => hasValue(communityRD.payload) || hasValue(communityRD.error)), + map((communityRD: RemoteData) => hasValue(communityRD) && communityRD.hasSucceeded && hasValue(communityRD.payload)), + tap((isValid: boolean) => { + if (!isValid) { + this.router.navigate(['/404']); + } + }) ); } } diff --git a/src/app/+collection-page/edit-collection-page/collection-curate/collection-curate.component.html b/src/app/+collection-page/edit-collection-page/collection-curate/collection-curate.component.html index e69de29bb2..38c9d22f4e 100644 --- a/src/app/+collection-page/edit-collection-page/collection-curate/collection-curate.component.html +++ b/src/app/+collection-page/edit-collection-page/collection-curate/collection-curate.component.html @@ -0,0 +1,6 @@ +
+

{{'collection.curate.header' |translate:{collection: (collectionName$ |async)} }}

+ +
diff --git a/src/app/+collection-page/edit-collection-page/collection-curate/collection-curate.component.spec.ts b/src/app/+collection-page/edit-collection-page/collection-curate/collection-curate.component.spec.ts new file mode 100644 index 0000000000..91c264cd0f --- /dev/null +++ b/src/app/+collection-page/edit-collection-page/collection-curate/collection-curate.component.spec.ts @@ -0,0 +1,69 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core'; +import { CollectionCurateComponent } from './collection-curate.component'; +import { of as observableOf } from 'rxjs'; +import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; +import { Collection } from '../../../core/shared/collection.model'; +import { ActivatedRoute } from '@angular/router'; +import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; + +describe('CollectionCurateComponent', () => { + let comp: CollectionCurateComponent; + let fixture: ComponentFixture; + let debugEl: DebugElement; + + let routeStub; + let dsoNameService; + + const collection = Object.assign(new Collection(), { + handle: '123456789/1', metadata: {'dc.title': ['Collection Name']} + }); + + beforeEach(async(() => { + routeStub = { + parent: { + data: observableOf({ + dso: createSuccessfulRemoteDataObject(collection) + }) + } + }; + + dsoNameService = jasmine.createSpyObj('dsoNameService', { + getName: 'Collection Name' + }); + + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [CollectionCurateComponent], + providers: [ + {provide: ActivatedRoute, useValue: routeStub}, + {provide: DSONameService, useValue: dsoNameService} + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(CollectionCurateComponent); + comp = fixture.componentInstance; + debugEl = fixture.debugElement; + + fixture.detectChanges(); + }); + describe('init', () => { + it('should initialise the comp', () => { + expect(comp).toBeDefined(); + expect(debugEl.nativeElement.innerHTML).toContain('ds-curation-form'); + }); + it('should contain the collection information provided in the route', () => { + comp.dsoRD$.subscribe((value) => { + expect(value.payload.handle + ).toEqual('123456789/1'); + }); + comp.collectionName$.subscribe((value) => { + expect(value).toEqual('Collection Name'); + }); + }); + }); +}); diff --git a/src/app/+collection-page/edit-collection-page/collection-curate/collection-curate.component.ts b/src/app/+collection-page/edit-collection-page/collection-curate/collection-curate.component.ts index d7deaea982..e20f229cd6 100644 --- a/src/app/+collection-page/edit-collection-page/collection-curate/collection-curate.component.ts +++ b/src/app/+collection-page/edit-collection-page/collection-curate/collection-curate.component.ts @@ -1,4 +1,11 @@ import { Component } from '@angular/core'; +import { filter, map, take } from 'rxjs/operators'; +import { RemoteData } from '../../../core/data/remote-data'; +import { Observable } from 'rxjs'; +import { ActivatedRoute } from '@angular/router'; +import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; +import { Collection } from '../../../core/shared/collection.model'; +import { hasValue } from '../../../shared/empty.util'; /** * Component for managing a collection's curation tasks @@ -8,5 +15,26 @@ import { Component } from '@angular/core'; templateUrl: './collection-curate.component.html', }) export class CollectionCurateComponent { - /* TODO: Implement Collection Edit - Curate */ + dsoRD$: Observable>; + collectionName$: Observable; + + constructor( + private route: ActivatedRoute, + private dsoNameService: DSONameService, + ) { + } + + ngOnInit(): void { + this.dsoRD$ = this.route.parent.data.pipe( + take(1), + map((data) => data.dso), + ); + + this.collectionName$ = this.dsoRD$.pipe( + filter((rd: RemoteData) => hasValue(rd)), + map((rd: RemoteData) => { + return this.dsoNameService.getName(rd.payload); + }) + ); + } } diff --git a/src/app/+community-page/create-community-page/create-community-page.guard.ts b/src/app/+community-page/create-community-page/create-community-page.guard.ts index 2ee5cb6064..de7026c887 100644 --- a/src/app/+community-page/create-community-page/create-community-page.guard.ts +++ b/src/app/+community-page/create-community-page/create-community-page.guard.ts @@ -5,8 +5,7 @@ import { hasNoValue, hasValue } from '../../shared/empty.util'; import { CommunityDataService } from '../../core/data/community-data.service'; import { RemoteData } from '../../core/data/remote-data'; import { Community } from '../../core/shared/community.model'; -import { getFinishedRemoteData } from '../../core/shared/operators'; -import { map, tap } from 'rxjs/operators'; +import { map, tap, find } from 'rxjs/operators'; import { Observable, of as observableOf } from 'rxjs'; /** @@ -29,18 +28,16 @@ export class CreateCommunityPageGuard implements CanActivate { return observableOf(true); } - const parent: Observable> = this.communityService.findById(parentID) + return this.communityService.findById(parentID) .pipe( - getFinishedRemoteData(), - ); - - return parent.pipe( - map((communityRD: RemoteData) => hasValue(communityRD) && communityRD.hasSucceeded && hasValue(communityRD.payload)), - tap((isValid: boolean) => { - if (!isValid) { - this.router.navigate(['/404']); + find((communityRD: RemoteData) => hasValue(communityRD.payload) || hasValue(communityRD.error)), + map((communityRD: RemoteData) => hasValue(communityRD) && communityRD.hasSucceeded && hasValue(communityRD.payload)), + tap((isValid: boolean) => { + if (!isValid) { + this.router.navigate(['/404']); + } } - }) + ) ); } } diff --git a/src/app/+community-page/edit-community-page/community-curate/community-curate.component.html b/src/app/+community-page/edit-community-page/community-curate/community-curate.component.html index e69de29bb2..6c041d1725 100644 --- a/src/app/+community-page/edit-community-page/community-curate/community-curate.component.html +++ b/src/app/+community-page/edit-community-page/community-curate/community-curate.component.html @@ -0,0 +1,6 @@ +
+

{{'community.curate.header' |translate:{community: (communityName$ |async)} }}

+ +
diff --git a/src/app/+community-page/edit-community-page/community-curate/community-curate.component.spec.ts b/src/app/+community-page/edit-community-page/community-curate/community-curate.component.spec.ts new file mode 100644 index 0000000000..42dc0f08a9 --- /dev/null +++ b/src/app/+community-page/edit-community-page/community-curate/community-curate.component.spec.ts @@ -0,0 +1,69 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core'; +import { of as observableOf } from 'rxjs'; +import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; +import { ActivatedRoute } from '@angular/router'; +import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; +import { CommunityCurateComponent } from './community-curate.component'; +import { Community } from '../../../core/shared/community.model'; + +describe('CommunityCurateComponent', () => { + let comp: CommunityCurateComponent; + let fixture: ComponentFixture; + let debugEl: DebugElement; + + let routeStub; + let dsoNameService; + + const community = Object.assign(new Community(), { + handle: '123456789/1', metadata: {'dc.title': ['Community Name']} + }); + + beforeEach(async(() => { + routeStub = { + parent: { + data: observableOf({ + dso: createSuccessfulRemoteDataObject(community) + }) + } + }; + + dsoNameService = jasmine.createSpyObj('dsoNameService', { + getName: 'Community Name' + }); + + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [CommunityCurateComponent], + providers: [ + {provide: ActivatedRoute, useValue: routeStub}, + {provide: DSONameService, useValue: dsoNameService} + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(CommunityCurateComponent); + comp = fixture.componentInstance; + debugEl = fixture.debugElement; + + fixture.detectChanges(); + }); + describe('init', () => { + it('should initialise the comp', () => { + expect(comp).toBeDefined(); + expect(debugEl.nativeElement.innerHTML).toContain('ds-curation-form'); + }); + it('should contain the community information provided in the route', () => { + comp.dsoRD$.subscribe((value) => { + expect(value.payload.handle + ).toEqual('123456789/1'); + }); + comp.communityName$.subscribe((value) => { + expect(value).toEqual('Community Name'); + }); + }); + }); +}); diff --git a/src/app/+community-page/edit-community-page/community-curate/community-curate.component.ts b/src/app/+community-page/edit-community-page/community-curate/community-curate.component.ts index 6151d3fe9a..5954b3e95e 100644 --- a/src/app/+community-page/edit-community-page/community-curate/community-curate.component.ts +++ b/src/app/+community-page/edit-community-page/community-curate/community-curate.component.ts @@ -1,4 +1,12 @@ -import { Component } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; +import { Community } from '../../../core/shared/community.model'; +import { ActivatedRoute } from '@angular/router'; +import { map, take } from 'rxjs/operators'; +import { RemoteData } from '../../../core/data/remote-data'; +import { Observable } from 'rxjs'; +import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; +import { hasValue } from '../../../shared/empty.util'; +import { filter } from 'rxjs/internal/operators/filter'; /** * Component for managing a community's curation tasks @@ -7,6 +15,29 @@ import { Component } from '@angular/core'; selector: 'ds-community-curate', templateUrl: './community-curate.component.html', }) -export class CommunityCurateComponent { - /* TODO: Implement Community Edit - Curate */ +export class CommunityCurateComponent implements OnInit { + + dsoRD$: Observable>; + communityName$: Observable; + + constructor( + private route: ActivatedRoute, + private dsoNameService: DSONameService, + ) { + } + + ngOnInit(): void { + this.dsoRD$ = this.route.parent.data.pipe( + take(1), + map((data) => data.dso), + ); + + this.communityName$ = this.dsoRD$.pipe( + filter((rd: RemoteData) => hasValue(rd)), + map((rd: RemoteData) => { + return this.dsoNameService.getName(rd.payload); + }) + ); + } + } diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts index 0bdb4be6cb..f86c57d69e 100644 --- a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts @@ -114,7 +114,7 @@ describe('ItemBitstreamsComponent', () => { } ); bitstreamService = jasmine.createSpyObj('bitstreamService', { - deleteAndReturnResponse: jasmine.createSpy('deleteAndReturnResponse') + delete: jasmine.createSpy('delete') }); objectCache = jasmine.createSpyObj('objectCache', { remove: jasmine.createSpy('remove') @@ -182,12 +182,25 @@ describe('ItemBitstreamsComponent', () => { comp.submit(); }); - it('should call deleteAndReturnResponse on the bitstreamService for the marked field', () => { - expect(bitstreamService.deleteAndReturnResponse).toHaveBeenCalledWith(bitstream2.id); + it('should call delete on the bitstreamService for the marked field', () => { + expect(bitstreamService.delete).toHaveBeenCalledWith(bitstream2.id); }); - it('should not call deleteAndReturnResponse on the bitstreamService for the unmarked field', () => { - expect(bitstreamService.deleteAndReturnResponse).not.toHaveBeenCalledWith(bitstream1.id); + it('should not call delete on the bitstreamService for the unmarked field', () => { + expect(bitstreamService.delete).not.toHaveBeenCalledWith(bitstream1.id); + }); + }); + + describe('when dropBitstream is called', () => { + const event = { + fromIndex: 0, + toIndex: 50, + // tslint:disable-next-line:no-empty + finish: () => {} + }; + + beforeEach(() => { + comp.dropBitstream(bundle, event); }); }); diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts index 45b8e23108..e00f4ae4aa 100644 --- a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts @@ -165,7 +165,7 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme take(1), switchMap((removedBistreams: Bitstream[]) => { if (isNotEmpty(removedBistreams)) { - return observableZip(...removedBistreams.map((bitstream: Bitstream) => this.bitstreamService.deleteAndReturnResponse(bitstream.id))); + return observableZip(...removedBistreams.map((bitstream: Bitstream) => this.bitstreamService.delete(bitstream.id))); } else { return observableOf(undefined); } diff --git a/src/app/+item-page/edit-item-page/item-delete/item-delete.component.ts b/src/app/+item-page/edit-item-page/item-delete/item-delete.component.ts index eecbdf8c6f..933919c572 100644 --- a/src/app/+item-page/edit-item-page/item-delete/item-delete.component.ts +++ b/src/app/+item-page/edit-item-page/item-delete/item-delete.component.ts @@ -1,24 +1,27 @@ -import {Component, Input, OnInit} from '@angular/core'; -import {filter, first, map, switchMap, take} from 'rxjs/operators'; -import {AbstractSimpleItemActionComponent} from '../simple-item-action/abstract-simple-item-action.component'; -import {getItemEditPath} from '../../item-page-routing.module'; -import {NgbModal, NgbModalRef} from '@ng-bootstrap/ng-bootstrap'; -import {combineLatest as observableCombineLatest, combineLatest, Observable} from 'rxjs'; -import {RelationshipType} from '../../../core/shared/item-relationships/relationship-type.model'; -import {VirtualMetadata} from '../virtual-metadata/virtual-metadata.component'; -import {Relationship} from '../../../core/shared/item-relationships/relationship.model'; -import {getRemoteDataPayload, getSucceededRemoteData} from '../../../core/shared/operators'; -import {hasValue, isNotEmpty} from '../../../shared/empty.util'; -import {Item} from '../../../core/shared/item.model'; -import {MetadataValue} from '../../../core/shared/metadata.models'; -import {ViewMode} from '../../../core/shared/view-mode.model'; -import {ActivatedRoute, Router} from '@angular/router'; -import {NotificationsService} from '../../../shared/notifications/notifications.service'; -import {ItemDataService} from '../../../core/data/item-data.service'; -import {TranslateService} from '@ngx-translate/core'; -import {ObjectUpdatesService} from '../../../core/data/object-updates/object-updates.service'; -import {RelationshipService} from '../../../core/data/relationship.service'; -import {EntityTypeService} from '../../../core/data/entity-type.service'; +import { Component, Input, OnInit } from '@angular/core'; +import { defaultIfEmpty, filter, first, map, switchMap, take } from 'rxjs/operators'; +import { AbstractSimpleItemActionComponent } from '../simple-item-action/abstract-simple-item-action.component'; +import { getItemEditPath } from '../../item-page-routing.module'; +import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; +import { combineLatest as observableCombineLatest, combineLatest, Observable, of as observableOf } from 'rxjs'; +import { RelationshipType } from '../../../core/shared/item-relationships/relationship-type.model'; +import { VirtualMetadata } from '../virtual-metadata/virtual-metadata.component'; +import { Relationship } from '../../../core/shared/item-relationships/relationship.model'; +import { getRemoteDataPayload, getSucceededRemoteData } from '../../../core/shared/operators'; +import { hasValue, isNotEmpty } from '../../../shared/empty.util'; +import { Item } from '../../../core/shared/item.model'; +import { MetadataValue } from '../../../core/shared/metadata.models'; +import { ViewMode } from '../../../core/shared/view-mode.model'; +import { ActivatedRoute, Router } from '@angular/router'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { TranslateService } from '@ngx-translate/core'; +import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; +import { RelationshipService } from '../../../core/data/relationship.service'; +import { EntityTypeService } from '../../../core/data/entity-type.service'; +import { LinkService } from '../../../core/cache/builders/link.service'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { RestResponse } from '../../../core/cache/response.models'; @Component({ selector: 'ds-item-delete', @@ -80,6 +83,7 @@ export class ItemDeleteComponent protected objectUpdatesService: ObjectUpdatesService, protected relationshipService: RelationshipService, protected entityTypeService: EntityTypeService, + protected linkService: LinkService, ) { super( route, @@ -98,30 +102,33 @@ export class ItemDeleteComponent super.ngOnInit(); this.url = this.router.url; - this.types$ = this.entityTypeService.getEntityTypeByLabel( - this.item.firstMetadataValue('relationship.type') - ).pipe( - getSucceededRemoteData(), - getRemoteDataPayload(), - switchMap((entityType) => this.entityTypeService.getEntityTypeRelationships(entityType.id)), - getSucceededRemoteData(), - getRemoteDataPayload(), - map((relationshipTypes) => relationshipTypes.page), - switchMap((types) => - combineLatest(types.map((type) => this.getRelationships(type))).pipe( - map((relationships) => - types.reduce((includedTypes, type, index) => { - if (!includedTypes.some((includedType) => includedType.id === type.id) - && !(relationships[index].length === 0)) { - return [...includedTypes, type]; - } else { - return includedTypes; - } - }, []) - ), - ) - ), - ); + const label = this.item.firstMetadataValue('relationship.type'); + if (label !== undefined) { + this.types$ = this.entityTypeService.getEntityTypeByLabel(label).pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + switchMap((entityType) => this.entityTypeService.getEntityTypeRelationships(entityType.id)), + getSucceededRemoteData(), + getRemoteDataPayload(), + map((relationshipTypes) => relationshipTypes.page), + switchMap((types) => + combineLatest(types.map((type) => this.getRelationships(type))).pipe( + map((relationships) => + types.reduce((includedTypes, type, index) => { + if (!includedTypes.some((includedType) => includedType.id === type.id) + && !(relationships[index].length === 0)) { + return [...includedTypes, type]; + } else { + return includedTypes; + } + }, []) + ), + ) + ), + ); + } else { + this.types$ = observableOf([]); + } this.types$.pipe( take(1), @@ -187,6 +194,7 @@ export class ItemDeleteComponent observableCombineLatest( relationships.map((relationship) => this.getRelationshipType(relationship)) ).pipe( + defaultIfEmpty([]), map((types) => relationships.filter( (relationship, index) => relationshipType.id === types[index].id )), @@ -205,6 +213,12 @@ export class ItemDeleteComponent */ private getRelationshipType(relationship: Relationship): Observable { + this.linkService.resolveLinks( + relationship, + followLink('relationshipType'), + followLink('leftItem'), + followLink('rightItem'), + ); return relationship.relationshipType.pipe( getSucceededRemoteData(), getRemoteDataPayload(), @@ -305,6 +319,7 @@ export class ItemDeleteComponent combineLatest( types.map((type) => this.isSelected(type)) ).pipe( + defaultIfEmpty([]), map((selection) => types.filter( (type, index) => selection[index] )), @@ -313,8 +328,8 @@ export class ItemDeleteComponent ), ).subscribe((types) => { this.itemDataService.delete(this.item.id, types).pipe(first()).subscribe( - (succeeded: boolean) => { - this.notify(succeeded); + (response: RestResponse) => { + this.notify(response.isSuccessful); } ); }); diff --git a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.html b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.html index 384a469f24..0c9d92dfbf 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.html +++ b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.html @@ -1,48 +1,56 @@
-
- - - -
-
- -
-
-
- - - + + +
+ + + +
+
+ +
+
+
+ + + +
+
+
+ -
+
diff --git a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.ts b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.ts index 2634b4e262..0e228dc246 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.ts +++ b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.ts @@ -3,7 +3,7 @@ import { Item } from '../../../core/shared/item.model'; import { DeleteRelationship, FieldUpdate, FieldUpdates } from '../../../core/data/object-updates/object-updates.reducer'; import { Observable } from 'rxjs/internal/Observable'; import { filter, map, switchMap, take } from 'rxjs/operators'; -import { zip as observableZip } from 'rxjs'; +import { of as observableOf, zip as observableZip} from 'rxjs'; import { followLink } from '../../../shared/utils/follow-link-config.model'; import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component'; import { ItemDataService } from '../../../core/data/item-data.service'; @@ -87,26 +87,30 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl */ public initializeUpdates(): void { - this.entityType$ = this.entityTypeService.getEntityTypeByLabel( - this.item.firstMetadataValue('relationship.type') - ).pipe( - getSucceededRemoteData(), - getRemoteDataPayload(), - ); + const label = this.item.firstMetadataValue('relationship.type'); + if (label !== undefined) { - this.relationshipTypes$ = this.entityType$.pipe( - switchMap((entityType) => - this.entityTypeService.getEntityTypeRelationships( - entityType.id, - followLink('leftType'), - followLink('rightType')) - .pipe( - getSucceededRemoteData(), - getRemoteDataPayload(), - map((relationshipTypes) => relationshipTypes.page), - ) - ), - ); + this.entityType$ = this.entityTypeService.getEntityTypeByLabel(label).pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + ); + + this.relationshipTypes$ = this.entityType$.pipe( + switchMap((entityType) => + this.entityTypeService.getEntityTypeRelationships( + entityType.id, + followLink('leftType'), + followLink('rightType')) + .pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + map((relationshipTypes) => relationshipTypes.page), + ) + ), + ); + } else { + this.entityType$ = observableOf(undefined); + } } /** diff --git a/src/app/+item-page/simple/item-types/shared/item-relationships-utils.ts b/src/app/+item-page/simple/item-types/shared/item-relationships-utils.ts index 73e02ca29d..6ef035f1e6 100644 --- a/src/app/+item-page/simple/item-types/shared/item-relationships-utils.ts +++ b/src/app/+item-page/simple/item-types/shared/item-relationships-utils.ts @@ -5,7 +5,7 @@ import { PaginatedList } from '../../../../core/data/paginated-list'; import { RemoteData } from '../../../../core/data/remote-data'; import { Relationship } from '../../../../core/shared/item-relationships/relationship.model'; import { Item } from '../../../../core/shared/item.model'; -import { getFinishedRemoteData, getSucceededRemoteData } from '../../../../core/shared/operators'; +import { getFirstSucceededRemoteDataPayload, getSucceededRemoteData } from '../../../../core/shared/operators'; import { hasValue } from '../../../../shared/empty.util'; /** @@ -75,16 +75,19 @@ export const paginatedRelationsToItems = (thisId: string) => getSucceededRemoteData(), switchMap((relationshipsRD: RemoteData>) => { return observableCombineLatest( - ...relationshipsRD.payload.page.map((rel: Relationship) => observableCombineLatest(rel.leftItem.pipe(getFinishedRemoteData()), rel.rightItem.pipe(getFinishedRemoteData()))) - ).pipe( + relationshipsRD.payload.page.map((rel: Relationship) => + observableCombineLatest([ + rel.leftItem.pipe(getFirstSucceededRemoteDataPayload()), + rel.rightItem.pipe(getFirstSucceededRemoteDataPayload())] + ) + )).pipe( map((arr) => arr - .filter(([leftItem, rightItem]) => leftItem.hasSucceeded && rightItem.hasSucceeded) .map(([leftItem, rightItem]) => { - if (leftItem.payload.id === thisId) { - return rightItem.payload; - } else if (rightItem.payload.id === thisId) { - return leftItem.payload; + if (leftItem.id === thisId) { + return rightItem; + } else if (rightItem.id === thisId) { + return leftItem; } }) .filter((item: Item) => hasValue(item)) diff --git a/src/app/+workflowitems-edit-page/workflow-item-delete/workflow-item-delete.component.ts b/src/app/+workflowitems-edit-page/workflow-item-delete/workflow-item-delete.component.ts index 43c3e90152..72a39ab53c 100644 --- a/src/app/+workflowitems-edit-page/workflow-item-delete/workflow-item-delete.component.ts +++ b/src/app/+workflowitems-edit-page/workflow-item-delete/workflow-item-delete.component.ts @@ -7,6 +7,8 @@ import { RouteService } from '../../core/services/route.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; import { RequestService } from '../../core/data/request.service'; +import { map } from 'rxjs/operators'; +import { RestResponse } from '../../core/cache/response.models'; @Component({ selector: 'ds-workflow-item-delete', @@ -39,6 +41,6 @@ export class WorkflowItemDeleteComponent extends WorkflowItemActionPageComponent */ sendRequest(id: string): Observable { this.requestService.removeByHrefSubstring('/discover'); - return this.workflowItemService.delete(id); + return this.workflowItemService.delete(id).pipe(map((response: RestResponse) => response.isSuccessful)); } } diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 9e24790fa1..da3cf9537b 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -1,35 +1,22 @@ -import { - async, - ComponentFixture, - inject, - TestBed -} from '@angular/core/testing'; - -import { - CUSTOM_ELEMENTS_SCHEMA, - DebugElement -} from '@angular/core'; - +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core'; import { CommonModule } from '@angular/common'; - import { By } from '@angular/platform-browser'; +import { ActivatedRoute, Router } from '@angular/router'; -import { TranslateModule, TranslateLoader } from '@ngx-translate/core'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { Store, StoreModule } from '@ngrx/store'; +import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; // Load the implementations that should be tested import { AppComponent } from './app.component'; - import { HostWindowState } from './shared/search/host-window.reducer'; import { HostWindowResizeAction } from './shared/host-window.actions'; - import { MetadataService } from './core/metadata/metadata.service'; import { NativeWindowRef, NativeWindowService } from './core/services/window.service'; - import { TranslateLoaderMock } from './shared/mocks/translate-loader.mock'; import { MetadataServiceMock } from './shared/mocks/metadata-service.mock'; -import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; import { AngularticsMock } from './shared/mocks/angulartics.service.mock'; import { AuthServiceMock } from './shared/mocks/auth.service.mock'; import { AuthService } from './core/auth/auth.service'; @@ -39,14 +26,12 @@ import { CSSVariableServiceStub } from './shared/testing/css-variable-service.st import { MenuServiceStub } from './shared/testing/menu-service.stub'; import { HostWindowService } from './shared/host-window.service'; import { HostWindowServiceStub } from './shared/testing/host-window-service.stub'; -import { ActivatedRoute, Router } from '@angular/router'; import { RouteService } from './core/services/route.service'; import { MockActivatedRoute } from './shared/mocks/active-router.mock'; import { RouterMock } from './shared/mocks/router.mock'; -import { CookieServiceMock } from './shared/mocks/cookie.service.mock'; -import { CookieService } from './core/services/cookie.service'; import { Angulartics2DSpace } from './statistics/angulartics/dspace-provider'; import { storeModuleConfig } from './app.reducer'; +import { LocaleService } from './core/locale/locale.service'; let comp: AppComponent; let fixture: ComponentFixture; @@ -56,6 +41,12 @@ const menuService = new MenuServiceStub(); describe('App component', () => { + function getMockLocaleService(): LocaleService { + return jasmine.createSpyObj('LocaleService', { + setCurrentLanguageCode: jasmine.createSpy('setCurrentLanguageCode') + }) + } + // async beforeEach beforeEach(async(() => { return TestBed.configureTestingModule({ @@ -81,7 +72,7 @@ describe('App component', () => { { provide: MenuService, useValue: menuService }, { provide: CSSVariableService, useClass: CSSVariableServiceStub }, { provide: HostWindowService, useValue: new HostWindowServiceStub(800) }, - { provide: CookieService, useValue: new CookieServiceMock()}, + { provide: LocaleService, useValue: getMockLocaleService() }, AppComponent, RouteService ], diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 832a7b642f..35ca4db131 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,10 +1,19 @@ import { delay, filter, map, take } from 'rxjs/operators'; -import { AfterViewInit, ChangeDetectionStrategy, Component, HostListener, Inject, OnInit, ViewEncapsulation } from '@angular/core'; +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + HostListener, + Inject, + OnInit, + ViewEncapsulation +} from '@angular/core'; import { NavigationCancel, NavigationEnd, NavigationStart, Router } from '@angular/router'; +import { BehaviorSubject, combineLatest as combineLatestObservable, Observable, of } from 'rxjs'; import { select, Store } from '@ngrx/store'; - import { TranslateService } from '@ngx-translate/core'; +import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; import { MetadataService } from './core/metadata/metadata.service'; import { HostWindowResizeAction } from './shared/host-window.actions'; @@ -12,20 +21,17 @@ import { HostWindowState } from './shared/search/host-window.reducer'; import { NativeWindowRef, NativeWindowService } from './core/services/window.service'; import { isAuthenticated } from './core/auth/selectors'; import { AuthService } from './core/auth/auth.service'; -import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; import variables from '../styles/_exposed_variables.scss'; import { CSSVariableService } from './shared/sass-helper/sass-helper.service'; import { MenuService } from './shared/menu/menu.service'; import { MenuID } from './shared/menu/initial-menus-state'; -import { BehaviorSubject, combineLatest as combineLatestObservable, Observable, of } from 'rxjs'; import { slideSidebarPadding } from './shared/animations/slide'; import { HostWindowService } from './shared/host-window.service'; import { Theme } from '../config/theme.inferface'; -import { isNotEmpty } from './shared/empty.util'; -import { CookieService } from './core/services/cookie.service'; import { Angulartics2DSpace } from './statistics/angulartics/dspace-provider'; import { environment } from '../environments/environment'; import { models } from './core/core.module'; +import { LocaleService } from './core/locale/locale.service'; export const LANG_COOKIE = 'language_cookie'; @@ -59,7 +65,7 @@ export class AppComponent implements OnInit, AfterViewInit { private cssService: CSSVariableService, private menuService: MenuService, private windowService: HostWindowService, - private cookie: CookieService + private localeService: LocaleService ) { /* Use models object so all decorators are actually called */ this.models = models; @@ -67,23 +73,10 @@ export class AppComponent implements OnInit, AfterViewInit { translate.addLangs(environment.languages.filter((LangConfig) => LangConfig.active === true).map((a) => a.code)); // Load the default language from the config file - translate.setDefaultLang(environment.defaultLanguage); + // translate.setDefaultLang(environment.defaultLanguage); - // Attempt to get the language from a cookie - const lang = cookie.get(LANG_COOKIE); - if (isNotEmpty(lang)) { - // Cookie found - // Use the language from the cookie - translate.use(lang); - } else { - // Cookie not found - // Attempt to get the browser language from the user - if (translate.getLangs().includes(translate.getBrowserLang())) { - translate.use(translate.getBrowserLang()); - } else { - translate.use(environment.defaultLanguage); - } - } + // set the current language code + this.localeService.setCurrentLanguageCode(); angulartics2GoogleAnalytics.startTracking(); angulartics2DSpace.startTracking(); @@ -140,15 +133,15 @@ export class AppComponent implements OnInit, AfterViewInit { // More information on this bug-fix: https://blog.angular-university.io/angular-debugging/ delay(0) ).subscribe((event) => { - if (event instanceof NavigationStart) { - this.isLoading$.next(true); - } else if ( - event instanceof NavigationEnd || - event instanceof NavigationCancel - ) { - this.isLoading$.next(false); - } - }); + if (event instanceof NavigationStart) { + this.isLoading$.next(true); + } else if ( + event instanceof NavigationEnd || + event instanceof NavigationCancel + ) { + this.isLoading$.next(false); + } + }); } @HostListener('window:resize', ['$event']) diff --git a/src/app/core/auth/auth.service.spec.ts b/src/app/core/auth/auth.service.spec.ts index a15d604cc4..7f2c1e29cc 100644 --- a/src/app/core/auth/auth.service.spec.ts +++ b/src/app/core/auth/auth.service.spec.ts @@ -241,6 +241,12 @@ describe('AuthService test', () => { expect(result).toBe(false); }); + it('should return true when authentication is loaded', () => { + authService.isAuthenticationLoaded().subscribe((status: boolean) => { + expect(status).toBe(true); + }); + }); + }); describe('', () => { diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index fe9828bc73..85e5eebb9e 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -21,7 +21,8 @@ import { getAuthenticationToken, getRedirectUrl, isAuthenticated, - isTokenRefreshing + isTokenRefreshing, + isAuthenticatedLoaded } from './selectors'; import { AppState, routerStateSelector } from '../../app.reducer'; import { @@ -148,6 +149,14 @@ export class AuthService { return this.store.pipe(select(isAuthenticated)); } + /** + * Determines if authentication is loaded + * @returns {Observable} + */ + public isAuthenticationLoaded(): Observable { + return this.store.pipe(select(isAuthenticatedLoaded)); + } + /** * Returns the href link to authenticated user * @returns {string} diff --git a/src/app/core/auth/server-auth.service.ts b/src/app/core/auth/server-auth.service.ts index 30767be85a..7b78255001 100644 --- a/src/app/core/auth/server-auth.service.ts +++ b/src/app/core/auth/server-auth.service.ts @@ -27,9 +27,6 @@ export class ServerAuthService extends AuthService { headers = headers.append('Accept', 'application/json'); headers = headers.append('Authorization', `Bearer ${token.accessToken}`); - // NB this is used to pass server client IP check. - const clientIp = this.req.get('x-forwarded-for') || this.req.connection.remoteAddress; - headers = headers.append('X-Forwarded-For', clientIp); options.headers = headers; return this.authRequestService.getRequest('status', options).pipe( diff --git a/src/app/core/cache/builders/remote-data-build.service.ts b/src/app/core/cache/builders/remote-data-build.service.ts index 6c9f40888f..ae3b0e4fd1 100644 --- a/src/app/core/cache/builders/remote-data-build.service.ts +++ b/src/app/core/cache/builders/remote-data-build.service.ts @@ -1,13 +1,7 @@ import { Injectable } from '@angular/core'; import { combineLatest as observableCombineLatest, Observable, of as observableOf, race as observableRace } from 'rxjs'; import { distinctUntilChanged, map, startWith, switchMap } from 'rxjs/operators'; -import { - hasValue, - hasValueOperator, - isEmpty, - isNotEmpty, - isNotUndefined -} from '../../../shared/empty.util'; +import { hasValue, hasValueOperator, isEmpty, isNotEmpty, isNotUndefined } from '../../../shared/empty.util'; import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; import { PaginatedList } from '../../data/paginated-list'; @@ -15,12 +9,7 @@ import { RemoteData } from '../../data/remote-data'; import { RemoteDataError } from '../../data/remote-data-error'; import { RequestEntry } from '../../data/request.reducer'; import { RequestService } from '../../data/request.service'; -import { - filterSuccessfulResponses, - getRequestFromRequestHref, - getRequestFromRequestUUID, - getResourceLinksFromResponse -} from '../../shared/operators'; +import { filterSuccessfulResponses, getRequestFromRequestHref, getRequestFromRequestUUID, getResourceLinksFromResponse } from '../../shared/operators'; import { PageInfo } from '../../shared/page-info.model'; import { CacheableObject } from '../object-cache.reducer'; import { ObjectCacheService } from '../object-cache.service'; @@ -98,7 +87,8 @@ export class RemoteDataBuildService { let error: RemoteDataError; const response = reqEntry ? reqEntry.response : undefined; if (hasValue(response)) { - isSuccessful = response.isSuccessful; + isSuccessful = response.statusCode === 204 || + response.statusCode >= 200 && response.statusCode < 300 && hasValue(payload); const errorMessage = isSuccessful === false ? (response as ErrorResponse).errorMessage : undefined; if (hasValue(errorMessage)) { error = new RemoteDataError( @@ -155,7 +145,7 @@ export class RemoteDataBuildService { }) ); - const payload$ = observableCombineLatest(tDomainList$, pageInfo$).pipe( + const payload$ = observableCombineLatest([tDomainList$, pageInfo$]).pipe( map(([tDomainList, pageInfo]) => { return new PaginatedList(pageInfo, tDomainList); }) diff --git a/src/app/core/cache/object-cache.reducer.ts b/src/app/core/cache/object-cache.reducer.ts index a39ceb4e16..38b52ad8c5 100644 --- a/src/app/core/cache/object-cache.reducer.ts +++ b/src/app/core/cache/object-cache.reducer.ts @@ -1,7 +1,5 @@ -import { autoserialize, deserialize } from 'cerialize'; import { HALLink } from '../shared/hal-link.model'; import { HALResource } from '../shared/hal-resource.model'; -import { excludeFromEquals } from '../utilities/equals.decorators'; import { ObjectCacheAction, ObjectCacheActionTypes, @@ -15,12 +13,6 @@ import { CacheEntry } from './cache-entry'; import { ResourceType } from '../shared/resource-type'; import { applyPatch, Operation } from 'fast-json-patch'; -export enum DirtyType { - Created = 'Created', - Updated = 'Updated', - Deleted = 'Deleted' -} - /** * An interface to represent a JsonPatch */ @@ -72,6 +64,7 @@ export class ObjectCacheEntry implements CacheEntry { patches: Patch[] = []; isDirty: boolean; } + /* tslint:enable:max-classes-per-file */ /** diff --git a/src/app/core/cache/server-sync-buffer.reducer.ts b/src/app/core/cache/server-sync-buffer.reducer.ts index d79dd51da4..7b65db0fbb 100644 --- a/src/app/core/cache/server-sync-buffer.reducer.ts +++ b/src/app/core/cache/server-sync-buffer.reducer.ts @@ -9,7 +9,7 @@ import { RestRequestMethod } from '../data/rest-request-method'; /** * An entry in the ServerSyncBufferState - * href: unique href of an ObjectCacheEntry + * href: unique href of an ServerSyncBufferEntry * method: RestRequestMethod type */ export class ServerSyncBufferEntry { @@ -48,6 +48,7 @@ export function serverSyncBufferReducer(state = initialState, action: ServerSync case ServerSyncBufferActionTypes.EMPTY: { return emptyServerSyncQueue(state, action as EmptySSBAction); } + default: { return state; } diff --git a/src/app/core/config/config-response-parsing.service.spec.ts b/src/app/core/config/config-response-parsing.service.spec.ts index c0bc8b3212..3328b48f04 100644 --- a/src/app/core/config/config-response-parsing.service.spec.ts +++ b/src/app/core/config/config-response-parsing.service.spec.ts @@ -177,6 +177,7 @@ describe('ConfigResponseParsingService', () => { Object.assign(new SubmissionDefinitionModel(), { isDefault: true, name: 'traditional', + id: 'traditional', type: 'submissiondefinition', _links: { sections: { href: 'https://rest.api/config/submissiondefinitions/traditional/sections' }, @@ -187,6 +188,7 @@ describe('ConfigResponseParsingService', () => { header: 'submit.progressbar.describe.stepone', mandatory: true, sectionType: 'submission-form', + id: 'traditionalpageone', visibility: { main: null, other: 'READONLY' @@ -201,6 +203,7 @@ describe('ConfigResponseParsingService', () => { header: 'submit.progressbar.describe.steptwo', mandatory: true, sectionType: 'submission-form', + id: 'traditionalpagetwo', visibility: { main: null, other: 'READONLY' @@ -215,6 +218,7 @@ describe('ConfigResponseParsingService', () => { header: 'submit.progressbar.upload', mandatory: false, sectionType: 'upload', + id: 'upload', visibility: { main: null, other: 'READONLY' @@ -229,6 +233,7 @@ describe('ConfigResponseParsingService', () => { header: 'submit.progressbar.license', mandatory: true, sectionType: 'license', + id: 'license', visibility: { main: null, other: 'READONLY' diff --git a/src/app/core/config/models/config.model.ts b/src/app/core/config/models/config.model.ts index fabb16eb23..53250ee045 100644 --- a/src/app/core/config/models/config.model.ts +++ b/src/app/core/config/models/config.model.ts @@ -6,6 +6,12 @@ import { excludeFromEquals } from '../../utilities/equals.decorators'; export abstract class ConfigObject implements CacheableObject { + /** + * The name for this configuration + */ + @autoserialize + public id: string; + /** * The name for this configuration */ diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 6c8c1ec1dc..64ed79b2e0 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -144,6 +144,7 @@ import { ScriptDataService } from './data/processes/script-data.service'; import { ProcessFilesResponseParsingService } from './data/process-files-response-parsing.service'; import { WorkflowActionDataService } from './data/workflow-action-data.service'; import { WorkflowAction } from './tasks/models/workflow-action-object.model'; +import { LocaleInterceptor } from './locale/locale.interceptor'; import { ItemTemplateDataService } from './data/item-template-data.service'; import { TemplateItem } from './shared/template-item.model'; import { Feature } from './shared/feature.model'; @@ -159,6 +160,8 @@ import { SubmissionCcLicenseDataService } from './submission/submission-cc-licen import { SubmissionCcLicence } from './submission/models/submission-cc-license.model'; import { SubmissionCcLicenceUrl } from './submission/models/submission-cc-license-url.model'; import { SubmissionCcLicenseUrlDataService } from './submission/submission-cc-license-url-data.service'; +import { ConfigurationDataService } from './data/configuration-data.service'; +import { ConfigurationProperty } from './shared/configuration-property.model'; import { SiteRegisterGuard } from './data/feature-authorization/feature-authorization-guard/site-register.guard'; /** @@ -245,6 +248,7 @@ const PROVIDERS = [ UploaderService, FileService, DSpaceObjectDataService, + ConfigurationDataService, DSOChangeAnalyzer, DefaultChangeAnalyzer, ArrayMoveChangeAnalyzer, @@ -293,6 +297,12 @@ const PROVIDERS = [ useClass: AuthInterceptor, multi: true }, + // register LocaleInterceptor as HttpInterceptor + { + provide: HTTP_INTERCEPTORS, + useClass: LocaleInterceptor, + multi: true + }, NotificationsService, FilteredDiscoveryPageResponseParsingService, { provide: NativeWindowService, useFactory: NativeWindowFactory } @@ -345,7 +355,8 @@ export const models = TemplateItem, Feature, Authorization, - Registration + Registration, + ConfigurationProperty ]; @NgModule({ diff --git a/src/app/core/data/bitstream-format-data.service.spec.ts b/src/app/core/data/bitstream-format-data.service.spec.ts index 7954416010..99bf4eea18 100644 --- a/src/app/core/data/bitstream-format-data.service.spec.ts +++ b/src/app/core/data/bitstream-format-data.service.spec.ts @@ -281,7 +281,7 @@ describe('BitstreamFormatDataService', () => { format.uuid = 'format-uuid'; format.id = 'format-id'; - const expected = cold('(b|)', {b: true}); + const expected = cold('(b|)', { b: responseCacheEntry.response }); const result = service.delete(format.id); expect(result).toBeObservable(expected); diff --git a/src/app/core/data/bitstream-format-data.service.ts b/src/app/core/data/bitstream-format-data.service.ts index e8cf030a52..52ca07060a 100644 --- a/src/app/core/data/bitstream-format-data.service.ts +++ b/src/app/core/data/bitstream-format-data.service.ts @@ -155,9 +155,9 @@ export class BitstreamFormatDataService extends DataService { /** * Delete an existing DSpace Object on the server * @param formatID The DSpace Object'id to be removed - * Return an observable that emits true when the deletion was successful, false when it failed + * @return the RestResponse as an Observable */ - delete(formatID: string): Observable { + delete(formatID: string): Observable { const requestId = this.requestService.generateRequestId(); const hrefObs = this.halService.getEndpoint(this.linkPath).pipe( @@ -173,7 +173,7 @@ export class BitstreamFormatDataService extends DataService { return this.requestService.getByUUID(requestId).pipe( find((request: RequestEntry) => request.completed), - map((request: RequestEntry) => request.response.isSuccessful) + map((request: RequestEntry) => request.response) ); } diff --git a/src/app/core/data/bundle-data.service.spec.ts b/src/app/core/data/bundle-data.service.spec.ts new file mode 100644 index 0000000000..1e1bf0eb9c --- /dev/null +++ b/src/app/core/data/bundle-data.service.spec.ts @@ -0,0 +1,94 @@ +import { HttpClient } from '@angular/common/http'; +import { Store } from '@ngrx/store'; +import { compare, Operation } from 'fast-json-patch'; +import { Observable, of as observableOf } from 'rxjs'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { followLink } from '../../shared/utils/follow-link-config.model'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { SortDirection, SortOptions } from '../cache/models/sort-options.model'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { CoreState } from '../core.reducers'; +import { DSpaceObject } from '../shared/dspace-object.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { Item } from '../shared/item.model'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { ChangeAnalyzer } from './change-analyzer'; +import { DataService } from './data.service'; +import { FindListOptions, PatchRequest } from './request.models'; +import { RequestService } from './request.service'; +import { getMockRequestService } from '../../shared/mocks/request.service.mock'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; +import { BundleDataService } from './bundle-data.service'; +import { HALLink } from '../shared/hal-link.model'; + +class DummyChangeAnalyzer implements ChangeAnalyzer { + diff(object1: Item, object2: Item): Operation[] { + return compare((object1 as any).metadata, (object2 as any).metadata); + } +} + +describe('BundleDataService', () => { + let service: BundleDataService; + let requestService; + let halService; + let rdbService; + let notificationsService; + let http; + let comparator; + let objectCache; + let store; + let item; + let bundleLink; + let bundleHALLink; + + function initTestService(): BundleDataService { + bundleLink = '/items/0fdc0cd7-ff8c-433d-b33c-9b56108abc07/bundles'; + bundleHALLink = new HALLink(); + bundleHALLink.href = bundleLink; + item = new Item(); + item._links = { + bundles: bundleHALLink + }; + requestService = getMockRequestService(); + halService = new HALEndpointServiceStub('url') as any; + rdbService = {} as RemoteDataBuildService; + notificationsService = {} as NotificationsService; + http = {} as HttpClient; + comparator = new DummyChangeAnalyzer() as any; + objectCache = { + + addPatch: () => { + /* empty */ + }, + getObjectBySelfLink: () => { + /* empty */ + } + } as any; + store = {} as Store; + return new BundleDataService( + requestService, + rdbService, + store, + objectCache, + halService, + notificationsService, + http, + comparator, + ); + } + + beforeEach(() => { + service = initTestService(); + }); + + describe('findAllByItem', () => { + beforeEach(() => { + spyOn(service, 'findAllByHref'); + service.findAllByItem(item); + }); + + it('should call findAllByHref with the item\'s bundles link', () => { + expect(service.findAllByHref).toHaveBeenCalledWith(bundleLink, undefined); + }) + }); +}); diff --git a/src/app/core/data/collection-data.service.spec.ts b/src/app/core/data/collection-data.service.spec.ts index 7087655a26..76aad4ad56 100644 --- a/src/app/core/data/collection-data.service.spec.ts +++ b/src/app/core/data/collection-data.service.spec.ts @@ -73,7 +73,12 @@ describe('CollectionDataService', () => { describe('when the requests are successful', () => { beforeEach(() => { - createService(); + createService(observableOf({ + request: { + href: 'https://rest.api/request' + }, + completed: true + })); }); describe('when calling getContentSource', () => { @@ -133,7 +138,7 @@ describe('CollectionDataService', () => { }); it('should return a RemoteData> for the getAuthorizedCollection', () => { - const result = service.getAuthorizedCollection(queryString) + const result = service.getAuthorizedCollection(queryString); const expected = cold('a|', { a: paginatedListRD }); @@ -148,7 +153,7 @@ describe('CollectionDataService', () => { }); it('should return a RemoteData> for the getAuthorizedCollectionByCommunity', () => { - const result = service.getAuthorizedCollectionByCommunity(communityId, queryString) + const result = service.getAuthorizedCollectionByCommunity(communityId, queryString); const expected = cold('a|', { a: paginatedListRD }); diff --git a/src/app/core/data/configuration-data.service.spec.ts b/src/app/core/data/configuration-data.service.spec.ts new file mode 100644 index 0000000000..fde55070e1 --- /dev/null +++ b/src/app/core/data/configuration-data.service.spec.ts @@ -0,0 +1,87 @@ +import { cold, getTestScheduler } from 'jasmine-marbles'; +import { TestScheduler } from 'rxjs/testing'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { FindByIDRequest } from './request.models'; +import { RequestService } from './request.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HttpClient } from '@angular/common/http'; +import { ConfigurationDataService } from './configuration-data.service'; +import { ConfigurationProperty } from '../shared/configuration-property.model'; + +describe('ConfigurationDataService', () => { + let scheduler: TestScheduler; + let service: ConfigurationDataService; + let halService: HALEndpointService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + const testObject = { + uuid: 'test-property', + name: 'test-property', + values: ['value-1', 'value-2'] + } as ConfigurationProperty; + const configLink = 'https://rest.api/rest/api/config/properties'; + const requestURL = `https://rest.api/rest/api/config/properties/${testObject.name}`; + const requestUUID = 'test-property'; + + beforeEach(() => { + scheduler = getTestScheduler(); + + halService = jasmine.createSpyObj('halService', { + getEndpoint: cold('a', {a: configLink}) + }); + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + configure: true + }); + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: cold('a', { + a: { + payload: testObject + } + }) + }); + objectCache = {} as ObjectCacheService; + const notificationsService = {} as NotificationsService; + const http = {} as HttpClient; + const comparator = {} as any; + + service = new ConfigurationDataService( + requestService, + rdbService, + objectCache, + halService, + notificationsService, + http, + comparator + ); + }); + + describe('findById', () => { + it('should call HALEndpointService with the path to the properties endpoint', () => { + scheduler.schedule(() => service.findByPropertyName(testObject.name)); + scheduler.flush(); + + expect(halService.getEndpoint).toHaveBeenCalledWith('properties'); + }); + + it('should configure the proper FindByIDRequest', () => { + scheduler.schedule(() => service.findByPropertyName(testObject.name)); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(new FindByIDRequest(requestUUID, requestURL, testObject.name)); + }); + + it('should return a RemoteData for the object with the given name', () => { + const result = service.findByPropertyName(testObject.name); + const expected = cold('a', { + a: { + payload: testObject + } + }); + expect(result).toBeObservable(expected); + }); + }); +}); diff --git a/src/app/core/data/configuration-data.service.ts b/src/app/core/data/configuration-data.service.ts new file mode 100644 index 0000000000..ec8221ebec --- /dev/null +++ b/src/app/core/data/configuration-data.service.ts @@ -0,0 +1,62 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { dataService } from '../cache/builders/build-decorators'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { CoreState } from '../core.reducers'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { DataService } from './data.service'; +import { RemoteData } from './remote-data'; +import { RequestService } from './request.service'; +import { ConfigurationProperty } from '../shared/configuration-property.model'; +import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; +import { CONFIG_PROPERTY } from '../shared/config-property.resource-type'; + +/* tslint:disable:max-classes-per-file */ +class DataServiceImpl extends DataService { + protected linkPath = 'properties'; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer) { + super(); + } +} + +@Injectable() +@dataService(CONFIG_PROPERTY) +/** + * Data Service responsible for retrieving Configuration properties + */ +export class ConfigurationDataService { + protected linkPath = 'properties'; + private dataService: DataServiceImpl; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer) { + this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator); + } + + /** + * Finds a configuration property by name + * @param name + */ + findByPropertyName(name: string): Observable> { + return this.dataService.findById(name); + } +} diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index b6f6465450..0d818f2030 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -2,19 +2,8 @@ import { HttpClient } from '@angular/common/http'; import { Store } from '@ngrx/store'; import { Operation } from 'fast-json-patch'; import { Observable } from 'rxjs'; -import { - distinctUntilChanged, - filter, - find, - first, - map, - mergeMap, - skipWhile, - switchMap, - take, - tap -} from 'rxjs/operators'; -import { hasValue, hasValueOperator, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; +import { distinctUntilChanged, filter, find, first, map, mergeMap, switchMap, take } from 'rxjs/operators'; +import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; import { NotificationOptions } from '../../shared/notifications/models/notification-options.model'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; @@ -28,25 +17,12 @@ import { CoreState } from '../core.reducers'; import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer'; import { DSpaceObject } from '../shared/dspace-object.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { - configureRequest, - getRemoteDataPayload, - getResponseFromEntry, - getSucceededRemoteData -} from '../shared/operators'; +import { configureRequest, getRemoteDataPayload, getResponseFromEntry, getSucceededRemoteData } from '../shared/operators'; import { URLCombiner } from '../url-combiner/url-combiner'; import { ChangeAnalyzer } from './change-analyzer'; import { PaginatedList } from './paginated-list'; import { RemoteData } from './remote-data'; -import { - CreateRequest, - DeleteByIDRequest, - FindByIDRequest, - FindListOptions, - FindListRequest, - GetRequest, - PatchRequest, PutRequest -} from './request.models'; +import { CreateRequest, DeleteByIDRequest, FindByIDRequest, FindListOptions, FindListRequest, GetRequest, PatchRequest, PutRequest } from './request.models'; import { RequestEntry } from './request.reducer'; import { RequestService } from './request.service'; import { RestRequestMethod } from './rest-request-method'; @@ -353,26 +329,24 @@ export abstract class DataService implements UpdateDa * Return an observable that emits response from the server */ searchBy(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: Array>): Observable>> { - + const requestId = this.requestService.generateRequestId(); const hrefObs = this.getSearchByHref(searchMethod, options, ...linksToFollow); - return hrefObs.pipe( - find((href: string) => hasValue(href)), - tap((href: string) => { - this.requestService.removeByHrefSubstring(href); - const request = new FindListRequest(this.requestService.generateRequestId(), href, options); - if (hasValue(this.responseMsToLive)) { - request.responseMsToLive = this.responseMsToLive; - } + hrefObs.pipe( + find((href: string) => hasValue(href)) + ).subscribe((href: string) => { + const request = new FindListRequest(requestId, href, options); + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } + this.requestService.configure(request); + }); - this.requestService.configure(request); - } + return this.requestService.getByUUID(requestId).pipe( + find((requestEntry) => hasValue(requestEntry) && requestEntry.completed), + switchMap((requestEntry) => + this.rdbService.buildList(requestEntry.request.href, ...linksToFollow) ), - switchMap((href) => this.requestService.getByHref(href)), - skipWhile((requestEntry) => hasValue(requestEntry) && requestEntry.completed), - switchMap((href) => - this.rdbService.buildList(hrefObs, ...linksToFollow) as Observable>> - ) ); } @@ -391,6 +365,9 @@ export abstract class DataService implements UpdateDa find((href: string) => hasValue(href)), map((href: string) => { const request = new PatchRequest(requestId, href, operations); + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } this.requestService.configure(request); }) ).subscribe(); @@ -464,7 +441,13 @@ export abstract class DataService implements UpdateDa const request$ = endpoint$.pipe( take(1), - map((endpoint: string) => new CreateRequest(requestId, endpoint, JSON.stringify(serializedDso))) + map((endpoint: string) => { + const request = new CreateRequest(requestId, endpoint, JSON.stringify(serializedDso)); + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } + return request + }) ); // Execute the post request @@ -513,7 +496,13 @@ export abstract class DataService implements UpdateDa const request$ = endpoint$.pipe( take(1), - map((endpoint: string) => new CreateRequest(requestId, endpoint, JSON.stringify(serializedDso))) + map((endpoint: string) => { + const request = new CreateRequest(requestId, endpoint, JSON.stringify(serializedDso)); + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } + return request + }) ); // Execute the post request @@ -542,42 +531,9 @@ export abstract class DataService implements UpdateDa * @param dsoID The DSpace Object' id to be removed * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual * metadata should be saved as real metadata - * @return an observable that emits true when the deletion was successful, false when it failed + * @return the RestResponse as an Observable */ - delete(dsoID: string, copyVirtualMetadata?: string[]): Observable { - const requestId = this.deleteAndReturnRequestId(dsoID, copyVirtualMetadata); - - return this.requestService.getByUUID(requestId).pipe( - find((request: RequestEntry) => isNotEmpty(request) && request.completed), - map((request: RequestEntry) => request.response.isSuccessful) - ); - } - - /** - * Delete an existing DSpace Object on the server - * @param dsoID The DSpace Object' id to be removed - * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual - * metadata should be saved as real metadata - * Return an observable of the completed response - */ - deleteAndReturnResponse(dsoID: string, copyVirtualMetadata?: string[]): Observable { - const requestId = this.deleteAndReturnRequestId(dsoID, copyVirtualMetadata); - - return this.requestService.getByUUID(requestId).pipe( - hasValueOperator(), - find((request: RequestEntry) => request.completed), - map((request: RequestEntry) => request.response) - ); - } - - /** - * Delete an existing DSpace Object on the server - * @param dsoID The DSpace Object' id to be removed - * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual - * metadata should be saved as real metadata - * Return the delete request's ID - */ - private deleteAndReturnRequestId(dsoID: string, copyVirtualMetadata?: string[]): string { + delete(dsoID: string, copyVirtualMetadata?: string[]): Observable { const requestId = this.requestService.generateRequestId(); const hrefObs = this.getIDHrefObs(dsoID); @@ -593,11 +549,17 @@ export abstract class DataService implements UpdateDa ); } const request = new DeleteByIDRequest(requestId, href, dsoID); + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } this.requestService.configure(request); }) ).subscribe(); - return requestId; + return this.requestService.getByUUID(requestId).pipe( + find((request: RequestEntry) => request.completed), + map((request: RequestEntry) => request.response) + ); } /** @@ -608,4 +570,15 @@ export abstract class DataService implements UpdateDa this.requestService.commit(method); } + /** + * Return the links to traverse from the root of the api to the + * endpoint this DataService represents + * + * e.g. if the api root links to 'foo', and the endpoint at 'foo' + * links to 'bar' the linkPath for the BarDataService would be + * 'foo/bar' + */ + getLinkPath(): string { + return this.linkPath; + } } diff --git a/src/app/core/data/item-template-data.service.ts b/src/app/core/data/item-template-data.service.ts index d157c09a7c..a44d48e9bd 100644 --- a/src/app/core/data/item-template-data.service.ts +++ b/src/app/core/data/item-template-data.service.ts @@ -16,9 +16,10 @@ import { NotificationsService } from '../../shared/notifications/notifications.s import { HttpClient } from '@angular/common/http'; import { BrowseService } from '../browse/browse.service'; import { CollectionDataService } from './collection-data.service'; -import { switchMap } from 'rxjs/operators'; +import { switchMap, map } from 'rxjs/operators'; import { BundleDataService } from './bundle-data.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { RestResponse } from '../cache/response.models'; /* tslint:disable:max-classes-per-file */ /** @@ -121,7 +122,7 @@ class DataServiceImpl extends ItemDataService { */ deleteByCollectionID(item: Item, collectionID: string): Observable { this.setRegularEndpoint(); - return super.delete(item.uuid); + return super.delete(item.uuid).pipe(map((response: RestResponse) => response.isSuccessful)); } } diff --git a/src/app/core/data/lookup-relation.service.ts b/src/app/core/data/lookup-relation.service.ts index 395976cbc3..bf0e2469d1 100644 --- a/src/app/core/data/lookup-relation.service.ts +++ b/src/app/core/data/lookup-relation.service.ts @@ -63,6 +63,7 @@ export class LookupRelationService { concat(subject.pipe(take(1))) ) ) as any + , ) as Observable>>>; } diff --git a/src/app/core/data/relationship-type.service.ts b/src/app/core/data/relationship-type.service.ts index eefe663209..bd279dbb0b 100644 --- a/src/app/core/data/relationship-type.service.ts +++ b/src/app/core/data/relationship-type.service.ts @@ -3,7 +3,7 @@ import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; import { combineLatest as observableCombineLatest } from 'rxjs'; import { Observable } from 'rxjs/internal/Observable'; -import { filter, find, map, switchMap } from 'rxjs/operators'; +import { filter, find, map, mergeMap, switchMap } from 'rxjs/operators'; import { AppState } from '../../app.reducer'; import { isNotUndefined } from '../../shared/empty.util'; import { NotificationsService } from '../../shared/notifications/notifications.service'; @@ -76,7 +76,7 @@ export class RelationshipTypeService extends DataService { getSucceededRemoteData(), /* Flatten the page so we can treat it like an observable */ switchMap((typeListRD: RemoteData>) => typeListRD.payload.page), - switchMap((type: RelationshipType) => { + mergeMap((type: RelationshipType) => { if (type.leftwardType === label) { return this.checkType(type, firstType, secondType); } else if (type.rightwardType === label) { @@ -92,7 +92,7 @@ export class RelationshipTypeService extends DataService { // returns a void observable if there's not match // returns an observable that emits the relationship type when there is a match private checkType(type: RelationshipType, firstType: string, secondType: string): Observable { - const entityTypes = observableCombineLatest(type.leftType.pipe(getSucceededRemoteData()), type.rightType.pipe(getSucceededRemoteData())); + const entityTypes = observableCombineLatest([type.leftType.pipe(getSucceededRemoteData()), type.rightType.pipe(getSucceededRemoteData())]); return entityTypes.pipe( find(([leftTypeRD, rightTypeRD]: [RemoteData, RemoteData]) => leftTypeRD.payload.label === firstType && rightTypeRD.payload.label === secondType), filter((types) => isNotUndefined(types)), diff --git a/src/app/core/data/relationship.service.spec.ts b/src/app/core/data/relationship.service.spec.ts index c8ce2c5c45..9253fb6730 100644 --- a/src/app/core/data/relationship.service.spec.ts +++ b/src/app/core/data/relationship.service.spec.ts @@ -1,11 +1,6 @@ import { Observable } from 'rxjs/internal/Observable'; import { of as observableOf } from 'rxjs/internal/observable/of'; import * as ItemRelationshipsUtils from '../../+item-page/simple/item-types/shared/item-relationships-utils'; -import { getMockRemoteDataBuildServiceHrefMap } from '../../shared/mocks/remote-data-build.service.mock'; -import { getMockRequestService } from '../../shared/mocks/request.service.mock'; -import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { spyOnOperator } from '../../shared/testing/utils.test'; import { followLink } from '../../shared/utils/follow-link-config.model'; import { ObjectCacheService } from '../cache/object-cache.service'; import { RelationshipType } from '../shared/item-relationships/relationship-type.model'; @@ -13,11 +8,16 @@ import { Relationship } from '../shared/item-relationships/relationship.model'; import { Item } from '../shared/item.model'; import { PageInfo } from '../shared/page-info.model'; import { PaginatedList } from './paginated-list'; -import { RelationshipService } from './relationship.service'; -import { RemoteData } from './remote-data'; import { DeleteRequest, FindListOptions } from './request.models'; -import { RequestEntry } from './request.reducer'; +import { RelationshipService } from './relationship.service'; import { RequestService } from './request.service'; +import { RemoteData } from './remote-data'; +import { RequestEntry } from './request.reducer'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { getMockRemoteDataBuildServiceHrefMap } from '../../shared/mocks/remote-data-build.service.mock'; +import { getMockRequestService } from '../../shared/mocks/request.service.mock'; +import { spyOnOperator } from '../../shared/testing/utils.test'; describe('RelationshipService', () => { let service: RelationshipService; @@ -159,8 +159,8 @@ describe('RelationshipService', () => { it('should clear the cache of the related items', () => { expect(objectCache.remove).toHaveBeenCalledWith(relatedItem1._links.self.href); expect(objectCache.remove).toHaveBeenCalledWith(item._links.self.href); - expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(relatedItem1.self); - expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(item.self); + expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(relatedItem1.uuid); + expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(item.uuid); }); }); @@ -175,37 +175,6 @@ describe('RelationshipService', () => { }); }); - describe('getRelatedItems', () => { - let mockItem; - - beforeEach(() => { - mockItem = { uuid: 'someid' } as Item; - - spyOn(service, 'getItemRelationshipsArray').and.returnValue(observableOf(relationships)); - - spyOnOperator(ItemRelationshipsUtils, 'relationsToItems').and.returnValue((v) => v); - }); - - it('should call getItemRelationshipsArray with the correct params', (done) => { - service.getRelatedItems(mockItem).subscribe(() => { - expect(service.getItemRelationshipsArray).toHaveBeenCalledWith( - mockItem, - followLink('leftItem'), - followLink('rightItem'), - followLink('relationshipType') - ); - done(); - }); - }); - - it('should use the relationsToItems operator', (done) => { - service.getRelatedItems(mockItem).subscribe(() => { - expect(ItemRelationshipsUtils.relationsToItems).toHaveBeenCalledWith(mockItem.uuid); - done(); - }); - }); - }); - describe('getRelatedItemsByLabel', () => { let relationsList; let mockItem; @@ -258,7 +227,6 @@ describe('RelationshipService', () => { }); }); }) - }); function getRemotedataObservable(obj: any): Observable> { diff --git a/src/app/core/data/relationship.service.ts b/src/app/core/data/relationship.service.ts index 3d68e70206..6d9b237db0 100644 --- a/src/app/core/data/relationship.service.ts +++ b/src/app/core/data/relationship.service.ts @@ -1,21 +1,14 @@ import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { MemoizedSelector, select, Store } from '@ngrx/store'; -import { combineLatest, combineLatest as observableCombineLatest } from 'rxjs'; +import { combineLatest as observableCombineLatest } from 'rxjs'; import { Observable } from 'rxjs/internal/Observable'; import { distinctUntilChanged, filter, map, mergeMap, startWith, switchMap, take, tap } from 'rxjs/operators'; -import { - compareArraysUsingIds, - paginatedRelationsToItems, - relationsToItems -} from '../../+item-page/simple/item-types/shared/item-relationships-utils'; +import { compareArraysUsingIds, paginatedRelationsToItems, relationsToItems } from '../../+item-page/simple/item-types/shared/item-relationships-utils'; import { AppState, keySelector } from '../../app.reducer'; import { hasValue, hasValueOperator, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; import { ReorderableRelationship } from '../../shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component'; -import { - RemoveNameVariantAction, - SetNameVariantAction -} from '../../shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.actions'; +import { RemoveNameVariantAction, SetNameVariantAction } from '../../shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.actions'; import { NameVariantListState } from '../../shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.reducer'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; @@ -31,12 +24,7 @@ import { RelationshipType } from '../shared/item-relationships/relationship-type import { Relationship } from '../shared/item-relationships/relationship.model'; import { RELATIONSHIP } from '../shared/item-relationships/relationship.resource-type'; import { Item } from '../shared/item.model'; -import { - configureRequest, - getRemoteDataPayload, - getResponseFromEntry, - getSucceededRemoteData -} from '../shared/operators'; +import { configureRequest, getFirstSucceededRemoteDataPayload, getRemoteDataPayload, getResponseFromEntry, getSucceededRemoteData } from '../shared/operators'; import { DataService } from './data.service'; import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { ItemDataService } from './item-data.service'; @@ -55,6 +43,19 @@ const relationshipStateSelector = (listID: string, itemID: string): MemoizedSele return keySelector(itemID, relationshipListStateSelector(listID)); }; +/** + * Return true if the Item in the payload of the source observable matches + * the given Item by UUID + * + * @param itemCheck the Item to compare with + */ +const compareItemsByUUID = (itemCheck: Item) => + (source: Observable>): Observable => + source.pipe( + getFirstSucceededRemoteDataPayload(), + map((item: Item) => item.uuid === itemCheck.uuid) + ); + /** * The service handling all relationship requests */ @@ -62,6 +63,7 @@ const relationshipStateSelector = (listID: string, itemID: string): MemoizedSele @dataService(RELATIONSHIP) export class RelationshipService extends DataService { protected linkPath = 'relationships'; + protected responseMsToLive = 15 * 60 * 1000; constructor(protected itemService: ItemDataService, protected requestService: RequestService, @@ -101,11 +103,7 @@ export class RelationshipService extends DataService { configureRequest(this.requestService), switchMap((restRequest: RestRequest) => this.requestService.getByUUID(restRequest.uuid)), getResponseFromEntry(), - switchMap((response) => - this.clearRelatedCache(id).pipe( - map(() => response), - ) - ), + tap(() => this.refreshRelationshipItemsInCacheByRelationship(id)), ); } @@ -132,8 +130,8 @@ export class RelationshipService extends DataService { configureRequest(this.requestService), switchMap((restRequest: RestRequest) => this.requestService.getByUUID(restRequest.uuid)), getResponseFromEntry(), - tap(() => this.removeRelationshipItemsFromCache(item1)), - tap(() => this.removeRelationshipItemsFromCache(item2)) + tap(() => this.refreshRelationshipItemsInCache(item1)), + tap(() => this.refreshRelationshipItemsInCache(item2)) ) as Observable; } @@ -141,19 +139,19 @@ export class RelationshipService extends DataService { * Method to remove two items of a relationship from the cache using the identifier of the relationship * @param relationshipId The identifier of the relationship */ - private removeRelationshipItemsFromCacheByRelationship(relationshipId: string) { - this.findById(relationshipId).pipe( + private refreshRelationshipItemsInCacheByRelationship(relationshipId: string) { + this.findById(relationshipId, followLink('leftItem'), followLink('rightItem')).pipe( getSucceededRemoteData(), getRemoteDataPayload(), - switchMap((rel: Relationship) => combineLatest( + switchMap((rel: Relationship) => observableCombineLatest( rel.leftItem.pipe(getSucceededRemoteData(), getRemoteDataPayload()), rel.rightItem.pipe(getSucceededRemoteData(), getRemoteDataPayload()) ) ), take(1) ).subscribe(([item1, item2]) => { - this.removeRelationshipItemsFromCache(item1); - this.removeRelationshipItemsFromCache(item2); + this.refreshRelationshipItemsInCache(item1); + this.refreshRelationshipItemsInCache(item2); }) } @@ -161,13 +159,13 @@ export class RelationshipService extends DataService { * Method to remove an item that's part of a relationship from the cache * @param item The item to remove from the cache */ - private removeRelationshipItemsFromCache(item) { + public refreshRelationshipItemsInCache(item) { this.objectCache.remove(item._links.self.href); this.requestService.removeByHrefSubstring(item.uuid); - combineLatest( + observableCombineLatest([ this.objectCache.hasBySelfLinkObservable(item._links.self.href), - this.requestService.hasByHrefObservable(item.uuid) - ).pipe( + this.requestService.hasByHrefObservable(item.self) + ]).pipe( filter(([existsInOC, existsInRC]) => !existsInOC && !existsInRC), take(1), switchMap(() => this.itemService.findByHref(item._links.self.href).pipe(take(1))) @@ -176,7 +174,10 @@ export class RelationshipService extends DataService { /** * Get an item's relationships in the form of an array - * @param item + * + * @param item The {@link Item} to get {@link Relationship}s for + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s + * should be automatically resolved */ getItemRelationshipsArray(item: Item, ...linksToFollow: Array>): Observable { return this.findAllByHref(item._links.relationships.href, undefined, ...linksToFollow).pipe( @@ -275,10 +276,10 @@ export class RelationshipService extends DataService { getRelationshipsByRelatedItemIds(item: Item, uuids: string[]): Observable { return this.getItemRelationshipsArray(item, followLink('leftItem'), followLink('rightItem')).pipe( switchMap((relationships: Relationship[]) => { - return observableCombineLatest(...relationships.map((relationship: Relationship) => { + return observableCombineLatest(relationships.map((relationship: Relationship) => { const isLeftItem$ = this.isItemInUUIDArray(relationship.leftItem, uuids); const isRightItem$ = this.isItemInUUIDArray(relationship.rightItem, uuids); - return observableCombineLatest(isLeftItem$, isRightItem$).pipe( + return observableCombineLatest([isLeftItem$, isRightItem$]).pipe( filter(([isLeftItem, isRightItem]) => isLeftItem || isRightItem), map(() => relationship), startWith(undefined) @@ -304,34 +305,31 @@ export class RelationshipService extends DataService { * @param label The rightward or leftward type of the relationship */ getRelationshipByItemsAndLabel(item1: Item, item2: Item, label: string, options?: FindListOptions): Observable { - return this.getItemRelationshipsByLabel(item1, label, options, followLink('relationshipType'), followLink('leftItem'), followLink('rightItem')) - .pipe( + return this.getItemRelationshipsByLabel( + item1, + label, + options, + followLink('relationshipType'), + followLink('leftItem'), + followLink('rightItem') + ).pipe( getSucceededRemoteData(), - isNotEmptyOperator(), - map((relationshipListRD: RemoteData>) => relationshipListRD.payload.page), - mergeMap((relationships: Relationship[]) => { - return observableCombineLatest(...relationships.map((relationship: Relationship) => { - return observableCombineLatest( - this.isItemMatchWithItemRD(relationship.leftItem, item2), - this.isItemMatchWithItemRD(relationship.rightItem, item2) - ).pipe( - map(([isLeftItem, isRightItem]) => isLeftItem || isRightItem), - map((isMatch) => isMatch ? relationship : undefined) - ); - })) + // the mergemap below will emit all elements of the list as separate events + mergeMap((relationshipListRD: RemoteData>) => relationshipListRD.payload.page), + mergeMap((relationship: Relationship) => { + return observableCombineLatest([ + this.itemService.findByHref(relationship._links.leftItem.href).pipe(compareItemsByUUID(item2)), + this.itemService.findByHref(relationship._links.rightItem.href).pipe(compareItemsByUUID(item2)) + ]).pipe( + map(([isLeftItem, isRightItem]) => isLeftItem || isRightItem), + map((isMatch) => isMatch ? relationship : undefined) + ); }), - map((relationships: Relationship[]) => relationships.find(((relationship) => hasValue(relationship)))) + filter((relationship) => hasValue(relationship)), + take(1) ) } - private isItemMatchWithItemRD(itemRD$: Observable>, itemCheck: Item): Observable { - return itemRD$.pipe( - getSucceededRemoteData(), - map((itemRD: RemoteData) => itemRD.payload), - map((item: Item) => item.uuid === itemCheck.uuid) - ); - } - /** * Method to set the name variant for specific list and item * @param listID The list for which to save the name variant @@ -378,7 +376,7 @@ export class RelationshipService extends DataService { * @param nameVariant The name variant to set for the matching relationship */ public updateNameVariant(item1: Item, item2: Item, relationshipLabel: string, nameVariant: string): Observable> { - const update$: Observable> = this.getRelationshipByItemsAndLabel(item1, item2, relationshipLabel) + return this.getRelationshipByItemsAndLabel(item1, item2, relationshipLabel) .pipe( switchMap((relation: Relationship) => relation.relationshipType.pipe( @@ -400,16 +398,6 @@ export class RelationshipService extends DataService { return this.update(updatedRelationship); }), ); - - update$.pipe( - filter((relationshipRD: RemoteData) => relationshipRD.state === RemoteDataState.RequestPending), - take(1), - ).subscribe(() => { - this.removeRelationshipItemsFromCache(item1); - this.removeRelationshipItemsFromCache(item2); - }); - - return update$ } /** @@ -432,7 +420,7 @@ export class RelationshipService extends DataService { take(1), ).subscribe((relationshipRD: RemoteData) => { if (relationshipRD.state === RemoteDataState.ResponsePending) { - this.removeRelationshipItemsFromCacheByRelationship(reoRel.relationship.id); + this.refreshRelationshipItemsInCacheByRelationship(reoRel.relationship.id); } }); @@ -440,18 +428,11 @@ export class RelationshipService extends DataService { } /** - * Clear object and request caches of the items related to a relationship (left and right items) - * @param uuid The uuid of the relationship for which to clear the related items from the cache + * Patch isn't supported on the relationship endpoint, so use put instead. + * + * @param object the {@link Relationship} to update */ - clearRelatedCache(uuid: string): Observable { - return this.findById(uuid).pipe( - getSucceededRemoteData(), - map((rd: RemoteData) => { - this.objectCache.remove(rd.payload._links.leftItem.href); - this.objectCache.remove(rd.payload._links.rightItem.href); - this.requestService.removeByHrefSubstring(rd.payload._links.leftItem.href); - this.requestService.removeByHrefSubstring(rd.payload._links.rightItem.href); - }) - ); + update(object: Relationship): Observable> { + return this.put(object); } } diff --git a/src/app/core/eperson/eperson-data.service.ts b/src/app/core/eperson/eperson-data.service.ts index 379daf5dcd..415977a46f 100644 --- a/src/app/core/eperson/eperson-data.service.ts +++ b/src/app/core/eperson/eperson-data.service.ts @@ -224,7 +224,7 @@ export class EPersonDataService extends DataService { * @param ePerson The EPerson to delete */ public deleteEPerson(ePerson: EPerson): Observable { - return this.delete(ePerson.id); + return this.delete(ePerson.id).pipe(map((response: RestResponse) => response.isSuccessful)); } /** diff --git a/src/app/core/eperson/group-data.service.ts b/src/app/core/eperson/group-data.service.ts index 75f00310ec..a10b46c3d0 100644 --- a/src/app/core/eperson/group-data.service.ts +++ b/src/app/core/eperson/group-data.service.ts @@ -135,7 +135,7 @@ export class GroupDataService extends DataService { * @param id The group id to delete */ public deleteGroup(group: Group): Observable { - return this.delete(group.id); + return this.delete(group.id).pipe(map((response: RestResponse) => response.isSuccessful)); } /** diff --git a/src/app/core/forward-client-ip/forward-client-ip.interceptor.spec.ts b/src/app/core/forward-client-ip/forward-client-ip.interceptor.spec.ts new file mode 100644 index 0000000000..49acd5b46d --- /dev/null +++ b/src/app/core/forward-client-ip/forward-client-ip.interceptor.spec.ts @@ -0,0 +1,44 @@ +import { ForwardClientIpInterceptor } from './forward-client-ip.interceptor'; +import { DSpaceRESTv2Service } from '../dspace-rest-v2/dspace-rest-v2.service'; +import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { HTTP_INTERCEPTORS, HttpRequest } from '@angular/common/http'; +import { REQUEST } from '@nguniversal/express-engine/tokens'; + +describe('ForwardClientIpInterceptor', () => { + let service: DSpaceRESTv2Service; + let httpMock: HttpTestingController; + + let requestUrl; + let clientIp; + + beforeEach(() => { + requestUrl = 'test-url'; + clientIp = '1.2.3.4'; + + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + DSpaceRESTv2Service, + { + provide: HTTP_INTERCEPTORS, + useClass: ForwardClientIpInterceptor, + multi: true, + }, + { provide: REQUEST, useValue: { get: () => undefined, connection: { remoteAddress: clientIp } }} + ], + }); + + service = TestBed.get(DSpaceRESTv2Service); + httpMock = TestBed.get(HttpTestingController); + }); + + it('should add an X-Forwarded-For header matching the client\'s IP', () => { + service.get(requestUrl).subscribe((response) => { + expect(response).toBeTruthy(); + }); + + const httpRequest = httpMock.expectOne(requestUrl); + expect(httpRequest.request.headers.get('X-Forwarded-For')).toEqual(clientIp); + }); +}); diff --git a/src/app/core/forward-client-ip/forward-client-ip.interceptor.ts b/src/app/core/forward-client-ip/forward-client-ip.interceptor.ts new file mode 100644 index 0000000000..2e1be09c96 --- /dev/null +++ b/src/app/core/forward-client-ip/forward-client-ip.interceptor.ts @@ -0,0 +1,23 @@ +import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; +import { Inject, Injectable } from '@angular/core'; +import { Observable } from 'rxjs/internal/Observable'; +import { REQUEST } from '@nguniversal/express-engine/tokens'; + +@Injectable() +/** + * Http Interceptor intercepting Http Requests, adding the client's IP to their X-Forwarded-For header + */ +export class ForwardClientIpInterceptor implements HttpInterceptor { + constructor(@Inject(REQUEST) protected req: any) { + } + + /** + * Intercept http requests and add the client's IP to the X-Forwarded-For header + * @param httpRequest + * @param next + */ + intercept(httpRequest: HttpRequest, next: HttpHandler): Observable> { + const clientIp = this.req.get('x-forwarded-for') || this.req.connection.remoteAddress; + return next.handle(httpRequest.clone({ setHeaders: { 'X-Forwarded-For': clientIp } })); + } +} diff --git a/src/app/core/json-patch/builder/json-patch-operations-builder.ts b/src/app/core/json-patch/builder/json-patch-operations-builder.ts index c45183b4ef..eb54265318 100644 --- a/src/app/core/json-patch/builder/json-patch-operations-builder.ts +++ b/src/app/core/json-patch/builder/json-patch-operations-builder.ts @@ -1,13 +1,9 @@ import { Store } from '@ngrx/store'; import { CoreState } from '../../core.reducers'; -import { - NewPatchAddOperationAction, - NewPatchRemoveOperationAction, - NewPatchReplaceOperationAction -} from '../json-patch-operations.actions'; +import { NewPatchAddOperationAction, NewPatchMoveOperationAction, 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 { hasNoValue, isEmpty, isNotEmpty } from '../../../shared/empty.util'; import { dateToISOFormat } from '../../../shared/date.util'; import { AuthorityValue } from '../../integration/models/authority.value'; import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model'; @@ -53,12 +49,35 @@ export class JsonPatchOperationsBuilder { * a boolean representing if the value to be added is a plain text value */ replace(path: JsonPatchOperationPathObject, value, plain = false) { + if (hasNoValue(value) || (typeof value === 'object' && hasNoValue(value.value))) { + this.remove(path); + } else { + this.store.dispatch( + new NewPatchReplaceOperationAction( + path.rootElement, + path.subRootElement, + path.path, + this.prepareValue(value, plain, false))); + } + } + + /** + * Dispatch a new NewPatchMoveOperationAction + * + * @param path + * the new path tho move to + * @param prevPath + * the original path to move from + */ + move(path: JsonPatchOperationPathObject, prevPath: string) { this.store.dispatch( - new NewPatchReplaceOperationAction( + new NewPatchMoveOperationAction( path.rootElement, path.subRootElement, - path.path, - this.prepareValue(value, plain, false))); + prevPath, + path.path + ) + ); } /** diff --git a/src/app/core/json-patch/json-patch-operations.reducer.ts b/src/app/core/json-patch/json-patch-operations.reducer.ts index 906d5e0331..648221f512 100644 --- a/src/app/core/json-patch/json-patch-operations.reducer.ts +++ b/src/app/core/json-patch/json-patch-operations.reducer.ts @@ -196,7 +196,8 @@ function newOperation(state: JsonPatchOperationsState, action): JsonPatchOperati body, action.type, action.payload.path, - hasValue(action.payload.value) ? action.payload.value : null); + hasValue(action.payload.value) ? action.payload.value : null, + hasValue(action.payload.from) ? action.payload.from : null); if (hasValue(newState[ action.payload.resourceType ]) && hasValue(newState[ action.payload.resourceType ].children)) { @@ -293,7 +294,21 @@ function flushOperation(state: JsonPatchOperationsState, action: FlushPatchOpera } } -function addOperationToList(body: JsonPatchOperationObject[], actionType, targetPath, value?) { +/** + * Add a new operation to a patch + * + * @param body + * The current patch + * @param actionType + * The type of operation to add + * @param targetPath + * The path for the operation + * @param value + * The new value + * @param fromPath + * The previous path (in case of a move operation) + */ +function addOperationToList(body: JsonPatchOperationObject[], actionType, targetPath, value?, fromPath?) { const newBody = Array.from(body); switch (actionType) { case JsonPatchOperationsActionTypes.NEW_JSON_PATCH_ADD_OPERATION: @@ -313,6 +328,9 @@ function addOperationToList(body: JsonPatchOperationObject[], actionType, target case JsonPatchOperationsActionTypes.NEW_JSON_PATCH_REMOVE_OPERATION: newBody.push(makeOperationEntry({ op: JsonPatchOperationType.remove, path: targetPath })); break; + case JsonPatchOperationsActionTypes.NEW_JSON_PATCH_MOVE_OPERATION: + newBody.push(makeOperationEntry({ op: JsonPatchOperationType.move, from: fromPath, path: targetPath })); + break; } return newBody; } diff --git a/src/app/core/json-patch/json-patch-operations.service.spec.ts b/src/app/core/json-patch/json-patch-operations.service.spec.ts index 583b90a01f..fb9e641441 100644 --- a/src/app/core/json-patch/json-patch-operations.service.spec.ts +++ b/src/app/core/json-patch/json-patch-operations.service.spec.ts @@ -21,10 +21,8 @@ import { RollbacktPatchOperationsAction, StartTransactionPatchOperationsAction } from './json-patch-operations.actions'; -import { StoreMock } from '../../shared/testing/store.mock'; import { RequestEntry } from '../data/request.reducer'; import { catchError } from 'rxjs/operators'; -import { storeModuleConfig } from '../../app.reducer'; class TestService extends JsonPatchOperationsService { protected linkPath = ''; @@ -99,27 +97,22 @@ describe('JsonPatchOperationsService test suite', () => { } - beforeEach(async(() => { - TestBed.configureTestingModule({ - imports: [ - StoreModule.forRoot({}, storeModuleConfig), - ], - providers: [ - { provide: Store, useClass: StoreMock } - ] - }).compileComponents(); - })); + function getStore() { + return jasmine.createSpyObj('store', { + dispatch: {}, + select: observableOf(mockState['json/patch'][testJsonPatchResourceType]), + pipe: observableOf(true) + }); + } beforeEach(() => { - store = TestBed.get(Store); + store = getStore(); requestService = getMockRequestService(getRequestEntry$(true)); rdbService = getMockRemoteDataBuildService(); scheduler = getTestScheduler(); halService = new HALEndpointServiceStub(resourceEndpointURL); service = initTestService(); - spyOn(store, 'select').and.returnValue(observableOf(mockState['json/patch'][testJsonPatchResourceType])); - spyOn(store, 'dispatch').and.callThrough(); spyOn(Date.prototype, 'getTime').and.callFake(() => { return timestamp; }); @@ -164,7 +157,7 @@ describe('JsonPatchOperationsService test suite', () => { describe('when request is not successful', () => { beforeEach(() => { - store = TestBed.get(Store); + store = getStore(); requestService = getMockRequestService(getRequestEntry$(false)); rdbService = getMockRemoteDataBuildService(); scheduler = getTestScheduler(); @@ -227,7 +220,7 @@ describe('JsonPatchOperationsService test suite', () => { describe('when request is not successful', () => { beforeEach(() => { - store = TestBed.get(Store); + store = getStore(); requestService = getMockRequestService(getRequestEntry$(false)); rdbService = getMockRemoteDataBuildService(); scheduler = getTestScheduler(); diff --git a/src/app/core/locale/locale.interceptor.spec.ts b/src/app/core/locale/locale.interceptor.spec.ts new file mode 100644 index 0000000000..4f45c4765a --- /dev/null +++ b/src/app/core/locale/locale.interceptor.spec.ts @@ -0,0 +1,74 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController, } from '@angular/common/http/testing'; +import { HTTP_INTERCEPTORS } from '@angular/common/http'; + +import { DSpaceRESTv2Service } from '../dspace-rest-v2/dspace-rest-v2.service'; +import { RestRequestMethod } from '../data/rest-request-method'; +import { LocaleService } from './locale.service'; +import { LocaleInterceptor } from './locale.interceptor'; +import { of } from 'rxjs'; + +describe(`LocaleInterceptor`, () => { + let service: DSpaceRESTv2Service; + let httpMock: HttpTestingController; + let localeService: any; + + const languageList = ['en;q=1', 'it;q=0.9', 'de;q=0.8', 'fr;q=0.7']; + + const mockLocaleService = jasmine.createSpyObj('LocaleService', { + getCurrentLanguageCode: jasmine.createSpy('getCurrentLanguageCode'), + getLanguageCodeList: of(languageList) + }) + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + DSpaceRESTv2Service, + { + provide: HTTP_INTERCEPTORS, + useClass: LocaleInterceptor, + multi: true, + }, + {provide: LocaleService, useValue: mockLocaleService}, + ], + }); + + service = TestBed.get(DSpaceRESTv2Service); + httpMock = TestBed.get(HttpTestingController); + localeService = TestBed.get(LocaleService); + + localeService.getCurrentLanguageCode.and.returnValue('en') + }); + + describe('', () => { + + it('should add an Accept-Language header when we’re sending an HTTP POST request', () => { + service.request(RestRequestMethod.POST, 'server/api/submission/workspaceitems', 'test').subscribe((response) => { + expect(response).toBeTruthy(); + }); + + const httpRequest = httpMock.expectOne(`server/api/submission/workspaceitems`); + + expect(httpRequest.request.headers.has('Accept-Language')); + const lang = httpRequest.request.headers.get('Accept-Language'); + expect(lang).toBeDefined(); + expect(lang).toBe(languageList.toString()); + }); + + it('should add an Accept-Language header when we’re sending an HTTP GET request', () => { + service.request(RestRequestMethod.GET, 'server/api/submission/workspaceitems/123').subscribe((response) => { + expect(response).toBeTruthy(); + }); + + const httpRequest = httpMock.expectOne(`server/api/submission/workspaceitems/123`); + + expect(httpRequest.request.headers.has('Accept-Language')); + const lang = httpRequest.request.headers.get('Accept-Language'); + expect(lang).toBeDefined(); + expect(lang).toBe(languageList.toString()); + }); + + }); + +}); diff --git a/src/app/core/locale/locale.interceptor.ts b/src/app/core/locale/locale.interceptor.ts new file mode 100644 index 0000000000..9327db5d38 --- /dev/null +++ b/src/app/core/locale/locale.interceptor.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@angular/core'; +import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; + +import { Observable } from 'rxjs'; + +import { LocaleService } from './locale.service'; +import { mergeMap, scan } from 'rxjs/operators'; + +@Injectable() +export class LocaleInterceptor implements HttpInterceptor { + + constructor(private localeService: LocaleService) { + } + + /** + * Intercept method + * @param req + * @param next + */ + intercept(req: HttpRequest, next: HttpHandler): Observable> { + let newReq: HttpRequest; + return this.localeService.getLanguageCodeList() + .pipe( + scan((acc: any, value: any) => [...acc, ...value], []), + mergeMap((languages) => { + // Clone the request to add the new header. + newReq = req.clone({ + headers: req.headers + .set('Accept-Language', languages.toString()) + }); + // Pass on the new request instead of the original request. + return next.handle(newReq); + })) + } +} diff --git a/src/app/core/locale/locale.service.spec.ts b/src/app/core/locale/locale.service.spec.ts new file mode 100644 index 0000000000..67a4d61bf3 --- /dev/null +++ b/src/app/core/locale/locale.service.spec.ts @@ -0,0 +1,125 @@ +import { async, TestBed } from '@angular/core/testing'; + +import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; + +import { CookieService } from '../services/cookie.service'; +import { CookieServiceMock } from '../../shared/mocks/cookie.service.mock'; +import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; +import { LANG_COOKIE, LocaleService, LANG_ORIGIN } from './locale.service'; +import { AuthService } from '../auth/auth.service'; +import { AuthServiceMock } from 'src/app/shared/mocks/auth.service.mock'; +import { NativeWindowRef } from '../services/window.service'; + +describe('LocaleService test suite', () => { + let service: LocaleService; + let serviceAsAny: any; + let cookieService: CookieService; + let translateService: TranslateService; + let authService: AuthService; + let window; + let spyOnGet; + let spyOnSet; + + const langList = ['en', 'it', 'de']; + + beforeEach(async(() => { + return TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), + ], + providers: [ + { provide: CookieService, useValue: new CookieServiceMock() }, + { provide: AuthService, userValue: AuthServiceMock } + ] + }); + })); + + beforeEach(() => { + cookieService = TestBed.get(CookieService); + translateService = TestBed.get(TranslateService); + authService = TestBed.get(TranslateService); + window = new NativeWindowRef(); + service = new LocaleService(window, cookieService, translateService, authService); + serviceAsAny = service; + spyOnGet = spyOn(cookieService, 'get'); + spyOnSet = spyOn(cookieService, 'set'); + }); + + describe('getCurrentLanguageCode', () => { + it('should return language saved on cookie', () => { + spyOnGet.and.returnValue('de'); + expect(service.getCurrentLanguageCode()).toBe('de'); + }); + + describe('', () => { + beforeEach(() => { + spyOn(translateService, 'getLangs').and.returnValue(langList); + }); + + it('should return language from browser setting', () => { + spyOn(translateService, 'getBrowserLang').and.returnValue('it'); + expect(service.getCurrentLanguageCode()).toBe('it'); + }); + + it('should return default language from config', () => { + spyOn(translateService, 'getBrowserLang').and.returnValue('fr'); + expect(service.getCurrentLanguageCode()).toBe('en'); + }); + }); + }); + + describe('getLanguageCodeFromCookie', () => { + it('should return language from cookie', () => { + spyOnGet.and.returnValue('de'); + expect(service.getLanguageCodeFromCookie()).toBe('de'); + }); + + }); + + describe('saveLanguageCodeToCookie', () => { + it('should save language to cookie', () => { + service.saveLanguageCodeToCookie('en'); + expect(spyOnSet).toHaveBeenCalledWith(LANG_COOKIE, 'en'); + }); + }); + + describe('setCurrentLanguageCode', () => { + beforeEach(() => { + spyOn(service, 'saveLanguageCodeToCookie'); + spyOn(translateService, 'use'); + }); + + it('should set the given language', () => { + service.setCurrentLanguageCode('it'); + expect(translateService.use).toHaveBeenCalledWith( 'it'); + expect(service.saveLanguageCodeToCookie).toHaveBeenCalledWith('it'); + }); + + it('should set the current language', () => { + spyOn(service, 'getCurrentLanguageCode').and.returnValue('es'); + service.setCurrentLanguageCode(); + expect(translateService.use).toHaveBeenCalledWith( 'es'); + expect(service.saveLanguageCodeToCookie).toHaveBeenCalledWith('es'); + }); + }); + + describe('', () => { + it('should set quality to current language list', () => { + const langListWithQuality = ['en;q=1', 'it;q=0.9', 'de;q=0.8']; + spyOn(service, 'setQuality').and.returnValue(langListWithQuality); + service.setQuality(langList, LANG_ORIGIN.BROWSER, false); + expect(service.setQuality).toHaveBeenCalledWith(langList, LANG_ORIGIN.BROWSER, false); + }); + + it('should return the list of language with quality factor', () => { + spyOn(service, 'getLanguageCodeList'); + service.getLanguageCodeList(); + expect(service.getLanguageCodeList).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/core/locale/locale.service.ts b/src/app/core/locale/locale.service.ts new file mode 100644 index 0000000000..b7f9314a33 --- /dev/null +++ b/src/app/core/locale/locale.service.ts @@ -0,0 +1,192 @@ +import { Injectable, Inject } from '@angular/core'; + +import { TranslateService } from '@ngx-translate/core'; + +import { isEmpty, isNotEmpty } from '../../shared/empty.util'; +import { CookieService } from '../services/cookie.service'; +import { environment } from '../../../environments/environment'; +import { AuthService } from '../auth/auth.service'; +import { Observable, of as observableOf, combineLatest } from 'rxjs'; +import { map, take, flatMap } from 'rxjs/operators'; +import { NativeWindowService, NativeWindowRef } from '../services/window.service'; + +export const LANG_COOKIE = 'language_cookie'; + +/** + * This enum defines the possible origin of the languages + */ +export enum LANG_ORIGIN { + UI, + EPERSON, + BROWSER +}; + +/** + * Service to provide localization handler + */ +@Injectable({ + providedIn: 'root' +}) +export class LocaleService { + + /** + * Eperson language metadata + */ + EPERSON_LANG_METADATA = 'eperson.language'; + + constructor( + @Inject(NativeWindowService) protected _window: NativeWindowRef, + protected cookie: CookieService, + protected translate: TranslateService, + protected authService: AuthService) { + } + + /** + * Get the language currently used + * + * @returns {string} The language code + */ + getCurrentLanguageCode(): string { + // Attempt to get the language from a cookie + let lang = this.getLanguageCodeFromCookie(); + if (isEmpty(lang)) { + // Cookie not found + // Attempt to get the browser language from the user + if (this.translate.getLangs().includes(this.translate.getBrowserLang())) { + lang = this.translate.getBrowserLang(); + } else { + lang = environment.defaultLanguage; + } + } + return lang; + } + + /** + * Get the languages list of the user in Accept-Language format + * + * @returns {Observable} + */ + getLanguageCodeList(): Observable { + const obs$ = combineLatest([ + this.authService.isAuthenticated(), + this.authService.isAuthenticationLoaded() + ]); + + return obs$.pipe( + take(1), + flatMap(([isAuthenticated, isLoaded]) => { + let epersonLang$: Observable = observableOf([]); + if (isAuthenticated && isLoaded) { + epersonLang$ = this.authService.getAuthenticatedUserFromStore().pipe( + take(1), + map((eperson) => { + const languages: string[] = []; + const ePersonLang = eperson.firstMetadataValue(this.EPERSON_LANG_METADATA); + if (ePersonLang) { + languages.push(...this.setQuality( + [ePersonLang], + LANG_ORIGIN.EPERSON, + !isEmpty(this.translate.currentLang))); + } + return languages; + }) + ); + } + return epersonLang$.pipe( + map((epersonLang: string[]) => { + const languages: string[] = []; + if (this.translate.currentLang) { + languages.push(...this.setQuality( + [this.translate.currentLang], + LANG_ORIGIN.UI, + false)); + } + if (isNotEmpty(epersonLang)) { + languages.push(...epersonLang); + } + if (navigator.languages) { + languages.push(...this.setQuality( + Object.assign([], navigator.languages), + LANG_ORIGIN.BROWSER, + !isEmpty(this.translate.currentLang)) + ); + } + return languages; + }) + ) + }) + ); + } + + /** + * Retrieve the language from a cookie + */ + getLanguageCodeFromCookie(): string { + return this.cookie.get(LANG_COOKIE); + } + + /** + * Set the language currently used + * + * @param lang + * The language to save + */ + saveLanguageCodeToCookie(lang: string): void { + this.cookie.set(LANG_COOKIE, lang); + } + + /** + * Set the language currently used + * + * @param lang + * The language to set, if it's not provided retrieve default one + */ + setCurrentLanguageCode(lang?: string): void { + if (isEmpty(lang)) { + lang = this.getCurrentLanguageCode() + } + this.translate.use(lang); + this.saveLanguageCodeToCookie(lang); + } + + /** + * Set the quality factor for all element of input array. + * Returns a new array that contains the languages list with the quality value. + * The quality factor indicate the relative degree of preference for the language + * @param languages the languages list + * @param origin origin of language list (UI, EPERSON, BROWSER) + * @param hasOther true if contains other language, false otherwise + */ + setQuality(languages: string[], origin: LANG_ORIGIN, hasOther: boolean): string[] { + const langWithPrior = []; + let idx = 0; + const v = languages.length > 10 ? languages.length : 10; + let divisor: number; + switch (origin) { + case LANG_ORIGIN.EPERSON: + divisor = 2; break; + case LANG_ORIGIN.BROWSER: + divisor = (hasOther ? 10 : 1); break; + default: + divisor = 1; + } + languages.forEach( (lang) => { + let value = lang + ';q='; + let quality = (v - idx++) / v; + quality = ((languages.length > 10) ? quality.toFixed(2) : quality) as number; + value += quality / divisor; + langWithPrior.push(value); + }); + return langWithPrior; + } + + /** + * Refresh route navigated + */ + public refreshAfterChangeLanguage() { + // Hard redirect to the reload page with a unique number behind it + // so that all state is definitely lost + this._window.nativeWindow.location.href = `/reload/${new Date().getTime()}`; + } + +} diff --git a/src/app/core/locale/server-locale.service.ts b/src/app/core/locale/server-locale.service.ts new file mode 100644 index 0000000000..b33338240f --- /dev/null +++ b/src/app/core/locale/server-locale.service.ts @@ -0,0 +1,60 @@ +import { LocaleService, LANG_ORIGIN } from './locale.service'; +import { Injectable } from '@angular/core'; +import { Observable, combineLatest, of as observableOf } from 'rxjs'; +import { take, flatMap, map } from 'rxjs/operators'; +import { isNotEmpty, isEmpty } from 'src/app/shared/empty.util'; + +@Injectable() +export class ServerLocaleService extends LocaleService { + + /** + * Get the languages list of the user in Accept-Language format + * + * @returns {Observable} + */ + getLanguageCodeList(): Observable { + const obs$ = combineLatest([ + this.authService.isAuthenticated(), + this.authService.isAuthenticationLoaded() + ]); + + return obs$.pipe( + take(1), + flatMap(([isAuthenticated, isLoaded]) => { + let epersonLang$: Observable = observableOf([]); + if (isAuthenticated && isLoaded) { + epersonLang$ = this.authService.getAuthenticatedUserFromStore().pipe( + take(1), + map((eperson) => { + const languages: string[] = []; + const ePersonLang = eperson.firstMetadataValue(this.EPERSON_LANG_METADATA); + if (ePersonLang) { + languages.push(...this.setQuality( + [ePersonLang], + LANG_ORIGIN.EPERSON, + !isEmpty(this.translate.currentLang))); + } + return languages; + }) + ); + } + return epersonLang$.pipe( + map((epersonLang: string[]) => { + const languages: string[] = []; + if (this.translate.currentLang) { + languages.push(...this.setQuality( + [this.translate.currentLang], + LANG_ORIGIN.UI, + false)); + } + if (isNotEmpty(epersonLang)) { + languages.push(...epersonLang); + } + return languages; + }) + ) + }) + ); + } + +} diff --git a/src/app/core/registry/registry.service.spec.ts b/src/app/core/registry/registry.service.spec.ts index 8e4e191e0f..43725a2d33 100644 --- a/src/app/core/registry/registry.service.spec.ts +++ b/src/app/core/registry/registry.service.spec.ts @@ -127,7 +127,7 @@ describe('RegistryService', () => { findAll: createSuccessfulRemoteDataObject$(createPaginatedList(mockSchemasList)), findById: createSuccessfulRemoteDataObject$(mockSchemasList[0]), createOrUpdateMetadataSchema: createSuccessfulRemoteDataObject$(mockSchemasList[0]), - deleteAndReturnResponse: observableOf(new RestResponse(true, 200, 'OK')), + delete: observableOf(new RestResponse(true, 200, 'OK')), clearRequests: observableOf('href') }); @@ -136,7 +136,7 @@ describe('RegistryService', () => { findById: createSuccessfulRemoteDataObject$(mockFieldsList[0]), create: createSuccessfulRemoteDataObject$(mockFieldsList[0]), put: createSuccessfulRemoteDataObject$(mockFieldsList[0]), - deleteAndReturnResponse: observableOf(new RestResponse(true, 200, 'OK')), + delete: observableOf(new RestResponse(true, 200, 'OK')), clearRequests: observableOf('href') }); } diff --git a/src/app/core/registry/registry.service.ts b/src/app/core/registry/registry.service.ts index cad5478f7a..72de6ec793 100644 --- a/src/app/core/registry/registry.service.ts +++ b/src/app/core/registry/registry.service.ts @@ -223,7 +223,7 @@ export class RegistryService { * @param id The id of the metadata schema to delete */ public deleteMetadataSchema(id: number): Observable { - return this.metadataSchemaService.deleteAndReturnResponse(`${id}`); + return this.metadataSchemaService.delete(`${id}`); } /** @@ -269,7 +269,7 @@ export class RegistryService { * @param id The id of the metadata field to delete */ public deleteMetadataField(id: number): Observable { - return this.metadataFieldService.deleteAndReturnResponse(`${id}`); + return this.metadataFieldService.delete(`${id}`); } /** * Method that clears a cached metadata field request and returns its REST url diff --git a/src/app/core/resource-policy/resource-policy.service.spec.ts b/src/app/core/resource-policy/resource-policy.service.spec.ts index 1c6ac47405..d2823b2dd0 100644 --- a/src/app/core/resource-policy/resource-policy.service.spec.ts +++ b/src/app/core/resource-policy/resource-policy.service.spec.ts @@ -96,6 +96,8 @@ describe('ResourcePolicyService', () => { }); responseCacheEntry = new RequestEntry(); + responseCacheEntry.request = { href: 'https://rest.api/' } as any; + responseCacheEntry.completed = true; responseCacheEntry.response = new RestResponse(true, 200, 'Success'); requestService = jasmine.createSpyObj('requestService', { diff --git a/src/app/core/resource-policy/resource-policy.service.ts b/src/app/core/resource-policy/resource-policy.service.ts index 291920c35a..47b188b5c5 100644 --- a/src/app/core/resource-policy/resource-policy.service.ts +++ b/src/app/core/resource-policy/resource-policy.service.ts @@ -24,6 +24,8 @@ import { PaginatedList } from '../data/paginated-list'; import { ActionType } from './models/action-type.model'; import { RequestParam } from '../cache/models/request-param.model'; import { isNotEmpty } from '../../shared/empty.util'; +import { map } from 'rxjs/operators'; +import { RestResponse } from '../cache/response.models'; /* tslint:disable:max-classes-per-file */ @@ -100,7 +102,7 @@ export class ResourcePolicyService { * @return an observable that emits true when the deletion was successful, false when it failed */ delete(resourcePolicyID: string): Observable { - return this.dataService.delete(resourcePolicyID); + return this.dataService.delete(resourcePolicyID).pipe(map((response: RestResponse) => response.isSuccessful)); } /** diff --git a/src/app/core/shared/config-property.resource-type.ts b/src/app/core/shared/config-property.resource-type.ts new file mode 100644 index 0000000000..b93c29dd66 --- /dev/null +++ b/src/app/core/shared/config-property.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from './resource-type'; + +/** + * The resource type for ConfigurationProperty + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const CONFIG_PROPERTY = new ResourceType('property'); diff --git a/src/app/core/shared/configuration-property.model.ts b/src/app/core/shared/configuration-property.model.ts new file mode 100644 index 0000000000..465523c29f --- /dev/null +++ b/src/app/core/shared/configuration-property.model.ts @@ -0,0 +1,48 @@ +import { autoserialize, autoserializeAs, deserialize } from 'cerialize'; +import { typedObject } from '../cache/builders/build-decorators'; +import { CacheableObject } from '../cache/object-cache.reducer'; +import { excludeFromEquals } from '../utilities/equals.decorators'; +import { HALLink } from './hal-link.model'; +import { ResourceType } from './resource-type'; +import { CONFIG_PROPERTY } from './config-property.resource-type'; + +/** + * Model class for a Configuration Property + */ +@typedObject +export class ConfigurationProperty implements CacheableObject { + static type = CONFIG_PROPERTY; + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The uuid of the configuration property + * The name is used as id for configuration properties + */ + @autoserializeAs(String, 'name') + uuid: string; + + /** + * The name of the configuration property + */ + @autoserialize + name: string; + + /** + * The values of the configuration property + */ + @autoserialize + values: string[]; + + /** + * The links of the configuration property + */ + @deserialize + _links: { self: HALLink }; + +} diff --git a/src/app/core/shared/context.model.ts b/src/app/core/shared/context.model.ts index ff24b7d090..4699a7977b 100644 --- a/src/app/core/shared/context.model.ts +++ b/src/app/core/shared/context.model.ts @@ -9,7 +9,8 @@ export enum Context { Workflow = 'workflow', Workspace = 'workspace', AdminMenu = 'adminMenu', - SubmissionModal = 'submissionModal', + EntitySearchModalWithNameVariants = 'EntitySearchModalWithNameVariants', + EntitySearchModal = 'EntitySearchModal', AdminSearch = 'adminSearch', AdminWorkflowSearch = 'adminWorkflowSearch', } diff --git a/src/app/core/shared/metadata.models.ts b/src/app/core/shared/metadata.models.ts index 085cdb4504..c29ac3bd2b 100644 --- a/src/app/core/shared/metadata.models.ts +++ b/src/app/core/shared/metadata.models.ts @@ -3,7 +3,7 @@ import { autoserialize, Serialize, Deserialize } from 'cerialize'; import { hasValue } from '../../shared/empty.util'; /* tslint:disable:max-classes-per-file */ -const VIRTUAL_METADATA_PREFIX = 'virtual::'; +export const VIRTUAL_METADATA_PREFIX = 'virtual::'; /** A single metadata value and its properties. */ export interface MetadataValueInterface { diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts index 4abb71350b..7516cc6532 100644 --- a/src/app/core/shared/operators.ts +++ b/src/app/core/shared/operators.ts @@ -66,7 +66,7 @@ export const getPaginatedListPayload = () => export const getSucceededRemoteData = () => (source: Observable>): Observable> => - source.pipe(find((rd: RemoteData) => rd.hasSucceeded)); + source.pipe(filter((rd: RemoteData) => rd.hasSucceeded), take(1)); export const getSucceededRemoteWithNotEmptyData = () => (source: Observable>): Observable> => diff --git a/src/app/core/submission/submission-object-data.service.spec.ts b/src/app/core/submission/submission-object-data.service.spec.ts index f46a465edb..931a7ae7d5 100644 --- a/src/app/core/submission/submission-object-data.service.spec.ts +++ b/src/app/core/submission/submission-object-data.service.spec.ts @@ -7,12 +7,14 @@ import { SubmissionObjectDataService } from './submission-object-data.service'; import { SubmissionScopeType } from './submission-scope-type'; import { WorkflowItemDataService } from './workflowitem-data.service'; import { WorkspaceitemDataService } from './workspaceitem-data.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; describe('SubmissionObjectDataService', () => { let service: SubmissionObjectDataService; let submissionService: SubmissionService; let workspaceitemDataService: WorkspaceitemDataService; let workflowItemDataService: WorkflowItemDataService; + let halService: HALEndpointService; const submissionId = '1234'; const wsiResult = 'wsiResult' as any; @@ -25,6 +27,9 @@ describe('SubmissionObjectDataService', () => { workflowItemDataService = jasmine.createSpyObj('WorkflowItemDataService', { findById: wfiResult }); + halService = jasmine.createSpyObj('HALEndpointService', { + getEndpoint: '/workspaceItem' + }); }); describe('findById', () => { @@ -32,7 +37,7 @@ describe('SubmissionObjectDataService', () => { submissionService = jasmine.createSpyObj('SubmissionService', { getSubmissionScope: {} }); - service = new SubmissionObjectDataService(workspaceitemDataService, workflowItemDataService, submissionService); + service = new SubmissionObjectDataService(workspaceitemDataService, workflowItemDataService, submissionService, halService); service.findById(submissionId); expect(submissionService.getSubmissionScope).toHaveBeenCalled(); }); @@ -42,7 +47,7 @@ describe('SubmissionObjectDataService', () => { submissionService = jasmine.createSpyObj('SubmissionService', { getSubmissionScope: SubmissionScopeType.WorkspaceItem }); - service = new SubmissionObjectDataService(workspaceitemDataService, workflowItemDataService, submissionService); + service = new SubmissionObjectDataService(workspaceitemDataService, workflowItemDataService, submissionService, halService); }); it('should forward the result of WorkspaceitemDataService.findByIdAndIDType()', () => { @@ -57,7 +62,7 @@ describe('SubmissionObjectDataService', () => { submissionService = jasmine.createSpyObj('SubmissionService', { getSubmissionScope: SubmissionScopeType.WorkflowItem }); - service = new SubmissionObjectDataService(workspaceitemDataService, workflowItemDataService, submissionService); + service = new SubmissionObjectDataService(workspaceitemDataService, workflowItemDataService, submissionService, halService); }); it('should forward the result of WorkflowItemDataService.findByIdAndIDType()', () => { @@ -72,7 +77,7 @@ describe('SubmissionObjectDataService', () => { submissionService = jasmine.createSpyObj('SubmissionService', { getSubmissionScope: 'Something else' }); - service = new SubmissionObjectDataService(workspaceitemDataService, workflowItemDataService, submissionService); + service = new SubmissionObjectDataService(workspaceitemDataService, workflowItemDataService, submissionService, halService); }); it('shouldn\'t call any data service methods', () => { diff --git a/src/app/core/submission/submission-object-data.service.ts b/src/app/core/submission/submission-object-data.service.ts index 0b6d65c758..502609a032 100644 --- a/src/app/core/submission/submission-object-data.service.ts +++ b/src/app/core/submission/submission-object-data.service.ts @@ -8,6 +8,9 @@ import { SubmissionObject } from './models/submission-object.model'; import { SubmissionScopeType } from './submission-scope-type'; import { WorkflowItemDataService } from './workflowitem-data.service'; import { WorkspaceitemDataService } from './workspaceitem-data.service'; +import { DataService } from '../data/data.service'; +import { map } from 'rxjs/operators'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; /** * A service to retrieve submission objects (WorkspaceItem/WorkflowItem) @@ -20,10 +23,22 @@ export class SubmissionObjectDataService { constructor( private workspaceitemDataService: WorkspaceitemDataService, private workflowItemDataService: WorkflowItemDataService, - private submissionService: SubmissionService + private submissionService: SubmissionService, + private halService: HALEndpointService ) { } + /** + * Create the HREF for a specific object based on its identifier + * @param id The identifier for the object + */ + getHrefByID(id): Observable { + const dataService: DataService = this.submissionService.getSubmissionScope() === SubmissionScopeType.WorkspaceItem ? this.workspaceitemDataService : this.workflowItemDataService; + + return this.halService.getEndpoint(dataService.getLinkPath()).pipe( + map((endpoint: string) => dataService.getIDHref(endpoint, encodeURIComponent(id)))); + } + /** * Retrieve a submission object based on its ID. * diff --git a/src/app/core/submission/submission-response-parsing.service.ts b/src/app/core/submission/submission-response-parsing.service.ts index afabde831a..4bbd93b18d 100644 --- a/src/app/core/submission/submission-response-parsing.service.ts +++ b/src/app/core/submission/submission-response-parsing.service.ts @@ -176,5 +176,4 @@ export class SubmissionResponseParsingService extends BaseResponseParsingService return definition; } - } diff --git a/src/app/core/submission/workflowitem-data.service.ts b/src/app/core/submission/workflowitem-data.service.ts index c82f7bf0b5..9b7555808d 100644 --- a/src/app/core/submission/workflowitem-data.service.ts +++ b/src/app/core/submission/workflowitem-data.service.ts @@ -17,6 +17,7 @@ import { Observable } from 'rxjs'; import { find, map } from 'rxjs/operators'; import { hasValue } from '../../shared/empty.util'; import { RequestEntry } from '../data/request.reducer'; +import { RestResponse } from '../cache/response.models'; /** * A service that provides methods to make REST requests with workflow items endpoint. @@ -44,7 +45,7 @@ export class WorkflowItemDataService extends DataService { * @param id The Workflow Item's id to be removed * @return an observable that emits true when the deletion was successful, false when it failed */ - delete(id: string): Observable { + delete(id: string): Observable { return this.deleteWFI(id, true) } @@ -54,7 +55,7 @@ export class WorkflowItemDataService extends DataService { * @return an observable that emits true when sending back the item was successful, false when it failed */ sendBack(id: string): Observable { - return this.deleteWFI(id, false) + return this.deleteWFI(id, false).pipe(map((response: RestResponse) => response.isSuccessful)); } /** @@ -64,7 +65,7 @@ export class WorkflowItemDataService extends DataService { * When true, the workflow item and its item will be permanently expunged on the server * When false, the workflow item will be removed, but the item will still be available as a workspace item */ - private deleteWFI(id: string, expunge: boolean): Observable { + private deleteWFI(id: string, expunge: boolean): Observable { const requestId = this.requestService.generateRequestId(); const hrefObs = this.halService.getEndpoint(this.linkPath).pipe( @@ -82,7 +83,7 @@ export class WorkflowItemDataService extends DataService { return this.requestService.getByUUID(requestId).pipe( find((request: RequestEntry) => request.completed), - map((request: RequestEntry) => request.response.isSuccessful) + map((request: RequestEntry) => request.response) ); } } diff --git a/src/app/core/submission/workspaceitem-data.service.ts b/src/app/core/submission/workspaceitem-data.service.ts index fcb85cc8b4..224bb64706 100644 --- a/src/app/core/submission/workspaceitem-data.service.ts +++ b/src/app/core/submission/workspaceitem-data.service.ts @@ -21,7 +21,6 @@ import { WorkspaceItem } from './models/workspaceitem.model'; @dataService(WorkspaceItem.type) export class WorkspaceitemDataService extends DataService { protected linkPath = 'workspaceitems'; - protected responseMsToLive = 10 * 1000; constructor( protected comparator: DSOChangeAnalyzer, diff --git a/src/app/curation-form/curation-form.component.html b/src/app/curation-form/curation-form.component.html new file mode 100644 index 0000000000..c940494016 --- /dev/null +++ b/src/app/curation-form/curation-form.component.html @@ -0,0 +1,20 @@ +
+
+
+
+ + +
+
+ + + {{'curation.form.handle.hint' |translate }} +
+
+ +
+
diff --git a/src/app/curation-form/curation-form.component.spec.ts b/src/app/curation-form/curation-form.component.spec.ts new file mode 100644 index 0000000000..93bad4fef2 --- /dev/null +++ b/src/app/curation-form/curation-form.component.spec.ts @@ -0,0 +1,165 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { CurationFormComponent } from './curation-form.component'; +import { ScriptDataService } from '../core/data/processes/script-data.service'; +import { ProcessDataService } from '../core/data/processes/process-data.service'; +import { AuthService } from '../core/auth/auth.service'; +import { of as observableOf } from 'rxjs'; +import { RequestEntry } from '../core/data/request.reducer'; +import { DSOSuccessResponse, RestResponse } from '../core/cache/response.models'; +import { Process } from '../process-page/processes/process.model'; +import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; +import { EPerson } from '../core/eperson/models/eperson.model'; +import { NotificationsServiceStub } from '../shared/testing/notifications-service.stub'; +import { RouterStub } from '../shared/testing/router.stub'; +import { NotificationsService } from '../shared/notifications/notifications.service'; +import { Router } from '@angular/router'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { ConfigurationDataService } from '../core/data/configuration-data.service'; +import { ConfigurationProperty } from '../core/shared/configuration-property.model'; + +describe('CurationFormComponent', () => { + let comp: CurationFormComponent; + let fixture: ComponentFixture; + + let scriptDataService: ScriptDataService; + let processDataService: ProcessDataService; + let configurationDataService: ConfigurationDataService; + let authService: AuthService; + let notificationsService; + let router; + + const requestEntry = Object.assign(new RequestEntry(), + {response: new DSOSuccessResponse(['process-link'], 200, 'success')}); + const failedRequestEntry = Object.assign(new RequestEntry(), + {response: new RestResponse(false, 400, 'Bad Request')}); + + const process = Object.assign(new Process(), {processId: 'process-id'}); + + beforeEach(async(() => { + + scriptDataService = jasmine.createSpyObj('scriptDataService', { + invoke: observableOf(requestEntry) + }); + + processDataService = jasmine.createSpyObj('processDataService', { + findByHref: createSuccessfulRemoteDataObject$(process) + }); + + authService = jasmine.createSpyObj('authService', { + getAuthenticatedUserFromStore: observableOf(Object.assign(new EPerson(), {email: 'test@mail'})) + }); + + configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'plugin.named.org.dspace.curate.CurationTask', + values: [ + 'org.dspace.ctask.general.ProfileFormats = profileformats', + '', + 'org.dspace.ctask.general.RequiredMetadata = requiredmetadata', + 'org.dspace.ctask.general.MetadataValueLinkChecker = checklinks', + 'value-to-be-skipped' + ] + })) + }); + + notificationsService = new NotificationsServiceStub(); + router = new RouterStub(); + + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), FormsModule, ReactiveFormsModule], + declarations: [CurationFormComponent], + providers: [ + {provide: ScriptDataService, useValue: scriptDataService}, + {provide: ProcessDataService, useValue: processDataService}, + {provide: AuthService, useValue: authService}, + {provide: NotificationsService, useValue: notificationsService}, + {provide: Router, useValue: router}, + {provide: ConfigurationDataService, useValue: configurationDataService}, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(CurationFormComponent); + comp = fixture.componentInstance; + + fixture.detectChanges(); + }); + describe('init', () => { + it('should initialise the comp and contain the different tasks', () => { + expect(comp).toBeDefined(); + + const elements = fixture.debugElement.queryAll(By.css('option')); + expect(elements.length).toEqual(3); + expect(elements[0].nativeElement.innerHTML).toContain('curation-task.task.profileformats.label'); + expect(elements[1].nativeElement.innerHTML).toContain('curation-task.task.requiredmetadata.label'); + expect(elements[2].nativeElement.innerHTML).toContain('curation-task.task.checklinks.label'); + }); + }); + describe('hasHandleValue', () => { + it('should return true when a dsoHandle value was provided', () => { + comp.dsoHandle = 'some-handle'; + fixture.detectChanges(); + + expect(comp.hasHandleValue()).toBeTrue(); + }); + it('should return false when no dsoHandle value was provided', () => { + expect(comp.hasHandleValue()).toBeFalse(); + }); + }); + describe('submit', () => { + it('should submit the selected process and handle to the scriptservice and navigate to the corresponding process page', () => { + comp.dsoHandle = 'test-handle'; + comp.submit(); + + expect(scriptDataService.invoke).toHaveBeenCalledWith('curate', [ + {name: '-t', value: 'profileformats'}, + {name: '-i', value: 'test-handle'}, + {name: '-e', value: 'test@mail'}, + ], []); + expect(notificationsService.success).toHaveBeenCalled(); + expect(processDataService.findByHref).toHaveBeenCalledWith('process-link'); + expect(router.navigate).toHaveBeenCalledWith(['/processes', 'process-id']); + }); + it('should the selected process and handle to the scriptservice and stay on the page on error', () => { + (scriptDataService.invoke as jasmine.Spy).and.returnValue(observableOf(failedRequestEntry)); + + comp.dsoHandle = 'test-handle'; + comp.submit(); + + expect(scriptDataService.invoke).toHaveBeenCalledWith('curate', [ + {name: '-t', value: 'profileformats'}, + {name: '-i', value: 'test-handle'}, + {name: '-e', value: 'test@mail'}, + ], []); + expect(notificationsService.error).toHaveBeenCalled(); + expect(processDataService.findByHref).not.toHaveBeenCalled(); + expect(router.navigate).not.toHaveBeenCalled(); + }); + }); + it('should use the handle provided by the form when no dsoHandle is provided', () => { + comp.form.get('handle').patchValue('form-handle'); + + comp.submit(); + + expect(scriptDataService.invoke).toHaveBeenCalledWith('curate', [ + {name: '-t', value: 'profileformats'}, + {name: '-i', value: 'form-handle'}, + {name: '-e', value: 'test@mail'}, + ], []); + }); + it('should use "all" when the handle provided by the form is empty and when no dsoHandle is provided', () => { + + comp.submit(); + + expect(scriptDataService.invoke).toHaveBeenCalledWith('curate', [ + {name: '-t', value: 'profileformats'}, + {name: '-i', value: 'all'}, + {name: '-e', value: 'test@mail'}, + ], []); + }); +}); diff --git a/src/app/curation-form/curation-form.component.ts b/src/app/curation-form/curation-form.component.ts new file mode 100644 index 0000000000..95512d1129 --- /dev/null +++ b/src/app/curation-form/curation-form.component.ts @@ -0,0 +1,118 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { ScriptDataService } from '../core/data/processes/script-data.service'; +import { FormControl, FormGroup } from '@angular/forms'; +import { getResponseFromEntry } from '../core/shared/operators'; +import { DSOSuccessResponse } from '../core/cache/response.models'; +import { AuthService } from '../core/auth/auth.service'; +import { filter, map, switchMap, take } from 'rxjs/operators'; +import { EPerson } from '../core/eperson/models/eperson.model'; +import { NotificationsService } from '../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { hasValue, isEmpty, isNotEmpty } from '../shared/empty.util'; +import { RemoteData } from '../core/data/remote-data'; +import { Router } from '@angular/router'; +import { ProcessDataService } from '../core/data/processes/process-data.service'; +import { Process } from '../process-page/processes/process.model'; +import { ConfigurationDataService } from '../core/data/configuration-data.service'; +import { ConfigurationProperty } from '../core/shared/configuration-property.model'; +import { Observable } from 'rxjs'; +import { find } from 'rxjs/internal/operators/find'; + +export const CURATION_CFG = 'plugin.named.org.dspace.curate.CurationTask'; + +/** + * Component responsible for rendering the Curation Task form + */ +@Component({ + selector: 'ds-curation-form', + templateUrl: './curation-form.component.html' +}) +export class CurationFormComponent implements OnInit { + + config: Observable>; + tasks: string[]; + form: FormGroup; + + @Input() + dsoHandle: string; + + constructor( + private scriptDataService: ScriptDataService, + private configurationDataService: ConfigurationDataService, + private processDataService: ProcessDataService, + private authService: AuthService, + private notificationsService: NotificationsService, + private translateService: TranslateService, + private router: Router + ) { + } + + ngOnInit(): void { + this.form = new FormGroup({ + task: new FormControl(''), + handle: new FormControl('') + }); + + this.config = this.configurationDataService.findByPropertyName(CURATION_CFG); + this.config.pipe( + find((rd: RemoteData) => rd.hasSucceeded), + map((rd: RemoteData) => rd.payload) + ).subscribe((configProperties) => { + this.tasks = configProperties.values + .filter((value) => isNotEmpty(value) && value.includes('=')) + .map((value) => value.split('=')[1].trim()); + this.form.get('task').patchValue(this.tasks[0]); + }); + } + + /** + * Determines whether the inputted dsoHandle has a value + */ + hasHandleValue() { + if (hasValue(this.dsoHandle)) { + return true; + } + return false; + } + + /** + * Submit the selected taskName and handle to the script data service to run the corresponding curation script + * Navigate to the process page on success + */ + submit() { + const taskName = this.form.get('task').value; + let handle; + if (this.hasHandleValue()) { + handle = this.dsoHandle; + } else { + handle = this.form.get('handle').value; + if (isEmpty(handle)) { + handle = 'all'; + } + } + this.authService.getAuthenticatedUserFromStore().pipe( + take(1), + switchMap((eperson: EPerson) => { + return this.scriptDataService.invoke('curate', [ + {name: '-t', value: taskName}, + {name: '-i', value: handle}, + {name: '-e', value: eperson.email}, + ], []).pipe(getResponseFromEntry()); + }) + ).subscribe((response: DSOSuccessResponse) => { + if (response.isSuccessful) { + this.notificationsService.success(this.translateService.get('curation.form.submit.success.head'), + this.translateService.get('curation.form.submit.success.content')); + this.processDataService.findByHref(response.resourceSelfLinks[0]).pipe( + filter((processRD: RemoteData) => hasValue(processRD) && hasValue(processRD.payload)), + take(1)) + .subscribe((processRD: RemoteData) => { + this.router.navigate(['/processes', processRD.payload.processId]); + }); + } else { + this.notificationsService.error(this.translateService.get('curation.form.submit.error.head'), + this.translateService.get('curation.form.submit.error.content')); + } + }); + } +} diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/journal-issue/journal-issue-grid-element.component.html b/src/app/entity-groups/journal-entities/item-grid-elements/journal-issue/journal-issue-grid-element.component.html index fb69ed92f5..636eac2309 100644 --- a/src/app/entity-groups/journal-entities/item-grid-elements/journal-issue/journal-issue-grid-element.component.html +++ b/src/app/entity-groups/journal-entities/item-grid-elements/journal-issue/journal-issue-grid-element.component.html @@ -1 +1 @@ - \ No newline at end of file + diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/journal-volume/journal-volume-grid-element.component.html b/src/app/entity-groups/journal-entities/item-grid-elements/journal-volume/journal-volume-grid-element.component.html index 53713b47ee..1faa4ed5db 100644 --- a/src/app/entity-groups/journal-entities/item-grid-elements/journal-volume/journal-volume-grid-element.component.html +++ b/src/app/entity-groups/journal-entities/item-grid-elements/journal-volume/journal-volume-grid-element.component.html @@ -1 +1 @@ - \ No newline at end of file + diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/journal/journal-grid-element.component.html b/src/app/entity-groups/journal-entities/item-grid-elements/journal/journal-grid-element.component.html index 594a0e0dc1..cb3220bd8e 100644 --- a/src/app/entity-groups/journal-entities/item-grid-elements/journal/journal-grid-element.component.html +++ b/src/app/entity-groups/journal-entities/item-grid-elements/journal/journal-grid-element.component.html @@ -1 +1 @@ - \ No newline at end of file + diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-issue/journal-issue-search-result-grid-element.component.html b/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-issue/journal-issue-search-result-grid-element.component.html index 0f9b5894f9..1ae772a3c3 100644 --- a/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-issue/journal-issue-search-result-grid-element.component.html +++ b/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-issue/journal-issue-search-result-grid-element.component.html @@ -17,7 +17,7 @@
- +

diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-volume/journal-volume-search-result-grid-element.component.html b/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-volume/journal-volume-search-result-grid-element.component.html index 16e2a8b847..75d29781b7 100644 --- a/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-volume/journal-volume-search-result-grid-element.component.html +++ b/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-volume/journal-volume-search-result-grid-element.component.html @@ -17,7 +17,7 @@
- +

diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal/journal-search-result-grid-element.component.html b/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal/journal-search-result-grid-element.component.html index 4902eec71e..8c7e5c2f44 100644 --- a/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal/journal-search-result-grid-element.component.html +++ b/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal/journal-search-result-grid-element.component.html @@ -17,7 +17,7 @@
- +

diff --git a/src/app/entity-groups/journal-entities/item-list-elements/journal-issue/journal-issue-list-element.component.html b/src/app/entity-groups/journal-entities/item-list-elements/journal-issue/journal-issue-list-element.component.html index 398feea260..395d6eba20 100644 --- a/src/app/entity-groups/journal-entities/item-list-elements/journal-issue/journal-issue-list-element.component.html +++ b/src/app/entity-groups/journal-entities/item-list-elements/journal-issue/journal-issue-list-element.component.html @@ -1 +1 @@ - \ No newline at end of file + diff --git a/src/app/entity-groups/journal-entities/item-list-elements/journal-volume/journal-volume-list-element.component.html b/src/app/entity-groups/journal-entities/item-list-elements/journal-volume/journal-volume-list-element.component.html index bf967e6e78..a3f604900e 100644 --- a/src/app/entity-groups/journal-entities/item-list-elements/journal-volume/journal-volume-list-element.component.html +++ b/src/app/entity-groups/journal-entities/item-list-elements/journal-volume/journal-volume-list-element.component.html @@ -1 +1 @@ - \ No newline at end of file + diff --git a/src/app/entity-groups/journal-entities/item-list-elements/journal/journal-list-element.component.html b/src/app/entity-groups/journal-entities/item-list-elements/journal/journal-list-element.component.html index 3e4dfb0b48..ca028c1659 100644 --- a/src/app/entity-groups/journal-entities/item-list-elements/journal/journal-list-element.component.html +++ b/src/app/entity-groups/journal-entities/item-list-elements/journal/journal-list-element.component.html @@ -1 +1 @@ - \ No newline at end of file + diff --git a/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-issue/journal-issue-search-result-list-element.component.html b/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-issue/journal-issue-search-result-list-element.component.html index 38094c5c79..45482972ec 100644 --- a/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-issue/journal-issue-search-result-list-element.component.html +++ b/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-issue/journal-issue-search-result-list-element.component.html @@ -1,4 +1,4 @@ - + + + + diff --git a/src/app/entity-groups/research-entities/item-grid-elements/person/person-grid-element.component.html b/src/app/entity-groups/research-entities/item-grid-elements/person/person-grid-element.component.html index a431f5979f..8a53609c55 100644 --- a/src/app/entity-groups/research-entities/item-grid-elements/person/person-grid-element.component.html +++ b/src/app/entity-groups/research-entities/item-grid-elements/person/person-grid-element.component.html @@ -1 +1 @@ - \ No newline at end of file + diff --git a/src/app/entity-groups/research-entities/item-grid-elements/project/project-grid-element.component.html b/src/app/entity-groups/research-entities/item-grid-elements/project/project-grid-element.component.html index 0c87599399..4a240baca9 100644 --- a/src/app/entity-groups/research-entities/item-grid-elements/project/project-grid-element.component.html +++ b/src/app/entity-groups/research-entities/item-grid-elements/project/project-grid-element.component.html @@ -1 +1 @@ - \ No newline at end of file + diff --git a/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/org-unit/org-unit-search-result-grid-element.component.html b/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/org-unit/org-unit-search-result-grid-element.component.html index 1f64856583..cf4e4a6b52 100644 --- a/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/org-unit/org-unit-search-result-grid-element.component.html +++ b/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/org-unit/org-unit-search-result-grid-element.component.html @@ -17,7 +17,7 @@
- +

diff --git a/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/person/person-search-result-grid-element.component.html b/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/person/person-search-result-grid-element.component.html index cbe93b2545..1b45c7c4f9 100644 --- a/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/person/person-search-result-grid-element.component.html +++ b/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/person/person-search-result-grid-element.component.html @@ -17,7 +17,7 @@
- +

diff --git a/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/project/project-search-result-grid-element.component.html b/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/project/project-search-result-grid-element.component.html index 22182d50be..ac3c3ea453 100644 --- a/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/project/project-search-result-grid-element.component.html +++ b/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/project/project-search-result-grid-element.component.html @@ -17,7 +17,7 @@
- +

diff --git a/src/app/entity-groups/research-entities/item-list-elements/org-unit/org-unit-list-element.component.html b/src/app/entity-groups/research-entities/item-list-elements/org-unit/org-unit-list-element.component.html index 03ef45c7a4..cf2dc3e61c 100644 --- a/src/app/entity-groups/research-entities/item-list-elements/org-unit/org-unit-list-element.component.html +++ b/src/app/entity-groups/research-entities/item-list-elements/org-unit/org-unit-list-element.component.html @@ -1 +1 @@ - \ No newline at end of file + diff --git a/src/app/entity-groups/research-entities/item-list-elements/person/person-list-element.component.html b/src/app/entity-groups/research-entities/item-list-elements/person/person-list-element.component.html index dbc3a42a05..290635ea27 100644 --- a/src/app/entity-groups/research-entities/item-list-elements/person/person-list-element.component.html +++ b/src/app/entity-groups/research-entities/item-list-elements/person/person-list-element.component.html @@ -1 +1 @@ - \ No newline at end of file + diff --git a/src/app/entity-groups/research-entities/item-list-elements/project/project-list-element.component.html b/src/app/entity-groups/research-entities/item-list-elements/project/project-list-element.component.html index 8f74452eaa..6f229c00e0 100644 --- a/src/app/entity-groups/research-entities/item-list-elements/project/project-list-element.component.html +++ b/src/app/entity-groups/research-entities/item-list-elements/project/project-list-element.component.html @@ -1 +1 @@ - \ No newline at end of file + diff --git a/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/org-unit/org-unit-search-result-list-element.component.html b/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/org-unit/org-unit-search-result-list-element.component.html index f08d0fdc11..5f570cb021 100644 --- a/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/org-unit/org-unit-search-result-list-element.component.html +++ b/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/org-unit/org-unit-search-result-list-element.component.html @@ -1,4 +1,4 @@ - +
+ - + diff --git a/src/app/entity-groups/research-entities/metadata-representations/org-unit/org-unit-item-metadata-list-element.component.html b/src/app/entity-groups/research-entities/metadata-representations/org-unit/org-unit-item-metadata-list-element.component.html index d8a4e744e4..de805a64b3 100644 --- a/src/app/entity-groups/research-entities/metadata-representations/org-unit/org-unit-item-metadata-list-element.component.html +++ b/src/app/entity-groups/research-entities/metadata-representations/org-unit/org-unit-item-metadata-list-element.component.html @@ -1,7 +1,6 @@ - + @@ -9,5 +8,5 @@ + [tooltip]="metadataRepresentation.allMetadata(['dc.description']).length > 0 ? descTemplate : null"> diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component.ts b/src/app/entity-groups/research-entities/submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component.ts index 4612996e91..7d39d4d314 100644 --- a/src/app/entity-groups/research-entities/submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component.ts +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component.ts @@ -7,7 +7,7 @@ import { Component, Inject, OnInit } from '@angular/core'; import { Metadata } from '../../../../../core/shared/metadata.utils'; import { MetadataValue } from '../../../../../core/shared/metadata.models'; -@listableObjectComponent(ExternalSourceEntry, ViewMode.ListElement, Context.SubmissionModal) +@listableObjectComponent(ExternalSourceEntry, ViewMode.ListElement, Context.EntitySearchModalWithNameVariants) @Component({ selector: 'ds-external-source-entry-list-submission-element', styleUrls: ['./external-source-entry-list-submission-element.component.scss'], diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.html b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.html index 93165c24cd..063e1393cc 100644 --- a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.html +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.html @@ -1,10 +1,15 @@
-
- -
+ + +
- + +
+ diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.ts b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.ts index 96f28a799b..1ed9d6cead 100644 --- a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.ts +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.ts @@ -20,7 +20,8 @@ import { ItemDataService } from '../../../../../core/data/item-data.service'; import { SelectableListService } from '../../../../../shared/object-list/selectable-list/selectable-list.service'; import { NameVariantModalComponent } from '../../name-variant-modal/name-variant-modal.component'; -@listableObjectComponent('OrgUnitSearchResult', ViewMode.ListElement, Context.SubmissionModal) +@listableObjectComponent('OrgUnitSearchResult', ViewMode.ListElement, Context.EntitySearchModal) +@listableObjectComponent('OrgUnitSearchResult', ViewMode.ListElement, Context.EntitySearchModalWithNameVariants) @Component({ selector: 'ds-person-search-result-list-submission-element', styleUrls: ['./org-unit-search-result-list-submission-element.component.scss'], @@ -34,6 +35,7 @@ export class OrgUnitSearchResultListSubmissionElementComponent extends SearchRes allSuggestions: string[]; selectedName: string; alternativeField = 'dc.title.alternative'; + useNameVariants = false; constructor(protected truncatableService: TruncatableService, private relationshipService: RelationshipService, @@ -48,16 +50,21 @@ export class OrgUnitSearchResultListSubmissionElementComponent extends SearchRes ngOnInit() { super.ngOnInit(); - const defaultValue = this.firstMetadataValue('organization.legalName'); - const alternatives = this.allMetadataValues(this.alternativeField); - this.allSuggestions = [defaultValue, ...alternatives]; - this.relationshipService.getNameVariant(this.listID, this.dso.uuid) - .pipe(take(1)) - .subscribe((nameVariant: string) => { - this.selectedName = nameVariant || defaultValue; - } - ); + this.useNameVariants = this.context === Context.EntitySearchModalWithNameVariants; + + if (this.useNameVariants) { + const defaultValue = this.firstMetadataValue('organization.legalName'); + const alternatives = this.allMetadataValues(this.alternativeField); + this.allSuggestions = [defaultValue, ...alternatives]; + + this.relationshipService.getNameVariant(this.listID, this.dso.uuid) + .pipe(take(1)) + .subscribe((nameVariant: string) => { + this.selectedName = nameVariant || defaultValue; + } + ); + } } select(value) { @@ -75,7 +82,7 @@ export class OrgUnitSearchResultListSubmissionElementComponent extends SearchRes if (!this.allSuggestions.includes(value)) { this.openModal(value) .then(() => { - + // user clicked ok: store the name variant in the item const newName: MetadataValue = new MetadataValue(); newName.value = value; @@ -89,9 +96,12 @@ export class OrgUnitSearchResultListSubmissionElementComponent extends SearchRes }, }); this.itemDataService.update(updatedItem).pipe(take(1)).subscribe(); - }) + }).catch(() => { + // user clicked cancel: use the name variant only for this relation, no further action required + }).finally(() => { + this.select(value); + }) } - this.select(value); } openModal(value): Promise { diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.scss b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.scss index 8301e12c5f..20b48c805b 100644 --- a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.scss +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.scss @@ -1,6 +1,7 @@ form { z-index: 1; &:before { + pointer-events: none; // prevent the icon from ‘catching‘ the click position: absolute; font-weight: 900; font-family: "Font Awesome 5 Free"; @@ -15,4 +16,4 @@ form { input.suggestion_input { background: transparent; } -} \ No newline at end of file +} diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.html b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.html index 25c091d386..9fe9898c2b 100644 --- a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.html +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.html @@ -1,7 +1,4 @@
-
- -
diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.ts b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.ts index 83761c6c20..9541ff334c 100644 --- a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.ts +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.ts @@ -20,7 +20,7 @@ import { MetadataValue } from '../../../../../core/shared/metadata.models'; import { ItemDataService } from '../../../../../core/data/item-data.service'; import { SelectableListService } from '../../../../../shared/object-list/selectable-list/selectable-list.service'; -@listableObjectComponent('PersonSearchResult', ViewMode.ListElement, Context.SubmissionModal) +@listableObjectComponent('PersonSearchResult', ViewMode.ListElement, Context.EntitySearchModalWithNameVariants) @Component({ selector: 'ds-person-search-result-list-submission-element', styleUrls: ['./person-search-result-list-submission-element.component.scss'], @@ -55,7 +55,7 @@ export class PersonSearchResultListSubmissionElementComponent extends SearchResu this.relationshipService.getNameVariant(this.listID, this.dso.uuid) .pipe(take(1)) .subscribe((nameVariant: string) => { - this.selectedName = nameVariant || defaultValue; + this.selectedName = nameVariant || defaultValue; } ); } @@ -75,27 +75,32 @@ export class PersonSearchResultListSubmissionElementComponent extends SearchResu if (!this.allSuggestions.includes(value)) { this.openModal(value) .then(() => { + // user clicked ok: store the name variant in the item + const newName: MetadataValue = new MetadataValue(); + newName.value = value; - const newName: MetadataValue = new MetadataValue(); - newName.value = value; - - const existingNames: MetadataValue[] = this.dso.metadata[this.alternativeField] || []; - const alternativeNames = { [this.alternativeField]: [...existingNames, newName] }; - const updatedItem = - Object.assign({}, this.dso, { - metadata: { - ...this.dso.metadata, - ...alternativeNames - }, - }); - this.itemDataService.update(updatedItem).pipe(take(1)).subscribe(); - }) + const existingNames: MetadataValue[] = this.dso.metadata[this.alternativeField] || []; + const alternativeNames = { [this.alternativeField]: [...existingNames, newName] }; + const updatedItem = + Object.assign({}, this.dso, { + metadata: { + ...this.dso.metadata, + ...alternativeNames + }, + }); + this.itemDataService.update(updatedItem).pipe(take(1)).subscribe(); + this.itemDataService.commitUpdates(); + }).catch(() => { + // user clicked cancel: use the name variant only for this relation, no further action required + }).finally(() => { + this.select(value); + }) } - this.select(value); } openModal(value): Promise { const modalRef = this.modalService.open(NameVariantModalComponent, { centered: true }); + const modalComp = modalRef.componentInstance; modalComp.value = value; return modalRef.result; diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-suggestions/person-input-suggestions.component.html b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-suggestions/person-input-suggestions.component.html index e177b2b561..062e68da1f 100644 --- a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-suggestions/person-input-suggestions.component.html +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-suggestions/person-input-suggestions.component.html @@ -1,11 +1,12 @@ -
-
- \ No newline at end of file + diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-suggestions/person-input-suggestions.component.scss b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-suggestions/person-input-suggestions.component.scss index 8301e12c5f..86233c473a 100644 --- a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-suggestions/person-input-suggestions.component.scss +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-suggestions/person-input-suggestions.component.scss @@ -1,6 +1,6 @@ form { - z-index: 1; &:before { + pointer-events: none; // prevent the icon from ‘catching‘ the click position: absolute; font-weight: 900; font-family: "Font Awesome 5 Free"; @@ -9,10 +9,9 @@ form { right: 0; height: 20px; width: 20px; - z-index: -1; } input.suggestion_input { background: transparent; } -} \ No newline at end of file +} diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-suggestions/person-input-suggestions.component.ts b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-suggestions/person-input-suggestions.component.ts index a1802ce1a7..5b4ecd9d2e 100644 --- a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-suggestions/person-input-suggestions.component.ts +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-suggestions/person-input-suggestions.component.ts @@ -33,8 +33,10 @@ export class PersonInputSuggestionsComponent extends InputSuggestionsComponent i } onSubmit(data) { - this.value = data; - this.submitSuggestion.emit(data); + if (data !== this.value) { + this.value = data; + this.submitSuggestion.emit(data); + } } onClickSuggestion(data) { diff --git a/src/app/process-page/form/process-form.component.spec.ts b/src/app/process-page/form/process-form.component.spec.ts index 95f266ed6f..0afdf78201 100644 --- a/src/app/process-page/form/process-form.component.spec.ts +++ b/src/app/process-page/form/process-form.component.spec.ts @@ -19,6 +19,7 @@ describe('ProcessFormComponent', () => { let component: ProcessFormComponent; let fixture: ComponentFixture; let scriptService; + let router; let parameterValues; let script; @@ -41,7 +42,10 @@ describe('ProcessFormComponent', () => { } }) } - ) + ); + router = { + navigateByUrl: () => undefined, + }; } beforeEach(async(() => { diff --git a/src/app/shared/comcol-forms/delete-comcol-page/delete-comcol-page.component.spec.ts b/src/app/shared/comcol-forms/delete-comcol-page/delete-comcol-page.component.spec.ts index a3f6ac0216..e791f41d56 100644 --- a/src/app/shared/comcol-forms/delete-comcol-page/delete-comcol-page.component.spec.ts +++ b/src/app/shared/comcol-forms/delete-comcol-page/delete-comcol-page.component.spec.ts @@ -48,7 +48,7 @@ describe('DeleteComColPageComponent', () => { dsoDataService = jasmine.createSpyObj( 'dsoDataService', { - delete: observableOf(true) + delete: observableOf({ isSuccessful: true }) }); routerStub = { @@ -106,7 +106,7 @@ describe('DeleteComColPageComponent', () => { }); it('should show an error notification on failure', () => { - (dsoDataService.delete as any).and.returnValue(observableOf(false)); + (dsoDataService.delete as any).and.returnValue(observableOf({ isSuccessful: false })); spyOn(router, 'navigate'); comp.onConfirm(data2); fixture.detectChanges(); diff --git a/src/app/shared/comcol-forms/delete-comcol-page/delete-comcol-page.component.ts b/src/app/shared/comcol-forms/delete-comcol-page/delete-comcol-page.component.ts index f5a1a84af5..d07d7be032 100644 --- a/src/app/shared/comcol-forms/delete-comcol-page/delete-comcol-page.component.ts +++ b/src/app/shared/comcol-forms/delete-comcol-page/delete-comcol-page.component.ts @@ -7,6 +7,7 @@ import { DataService } from '../../../core/data/data.service'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { NotificationsService } from '../../notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; +import { RestResponse } from '../../../core/cache/response.models'; /** * Component representing the delete page for communities and collections @@ -45,8 +46,8 @@ export class DeleteComColPageComponent implements onConfirm(dso: TDomain) { this.dsoDataService.delete(dso.id) .pipe(first()) - .subscribe((success: boolean) => { - if (success) { + .subscribe((response: RestResponse) => { + if (response.isSuccessful) { const successMessage = this.translate.instant((dso as any).type + '.delete.notification.success'); this.notifications.success(successMessage) } else { diff --git a/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.html b/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.html index aa6290ea9f..ad270aab80 100644 --- a/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.html +++ b/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.html @@ -16,7 +16,9 @@
- {{ type + '.edit.return' | translate }} +
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html index a31171d7ef..028299f760 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html @@ -1,29 +1,25 @@ -
- - - -
+ +
- - -
- {{ message | translate:model.validators }} + {{ message | translate: model.validators }}
- -
+
- -
+
- - + + + + + + - - -
-
    - - -
-
+ + diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts index 4dee6905d2..7b95f2396e 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts @@ -71,6 +71,9 @@ import { Item } from '../../../../core/shared/item.model'; import { WorkspaceItem } from '../../../../core/submission/models/workspaceitem.model'; import { of as observableOf } from 'rxjs'; import { createSuccessfulRemoteDataObject } from '../../../remote-data.utils'; +import { FormService } from '../../form.service'; +import { SubmissionService } from '../../../../submission/submission.service'; +import { FormBuilderService } from '../form-builder.service'; describe('DsDynamicFormControlContainerComponent test suite', () => { @@ -101,15 +104,16 @@ describe('DsDynamicFormControlContainerComponent test suite', () => { new DynamicSwitchModel({ id: 'switch' }), new DynamicTextAreaModel({ id: 'textarea' }), new DynamicTimePickerModel({ id: 'timepicker' }), - new DynamicTypeaheadModel({ id: 'typeahead', metadataFields: [], repeatable: false, submissionId: '1234' }), + new DynamicTypeaheadModel({ id: 'typeahead', metadataFields: [], repeatable: false, submissionId: '1234', hasSelectableMetadata: false }), new DynamicScrollableDropdownModel({ id: 'scrollableDropdown', authorityOptions: authorityOptions, metadataFields: [], repeatable: false, - submissionId: '1234' + submissionId: '1234', + hasSelectableMetadata: false }), - new DynamicTagModel({ id: 'tag', metadataFields: [], repeatable: false, submissionId: '1234' }), + new DynamicTagModel({ id: 'tag', metadataFields: [], repeatable: false, submissionId: '1234', hasSelectableMetadata: false }), new DynamicListCheckboxGroupModel({ id: 'checkboxList', authorityOptions: authorityOptions, @@ -130,11 +134,12 @@ describe('DsDynamicFormControlContainerComponent test suite', () => { scopeUUID: '', submissionScope: '', repeatable: false, - metadataFields: [] + metadataFields: [], + hasSelectableMetadata: false }), new DynamicDsDatePickerModel({ id: 'datepicker' }), - new DynamicLookupModel({ id: 'lookup', metadataFields: [], repeatable: false, submissionId: '1234' }), - new DynamicLookupNameModel({ id: 'lookupName', metadataFields: [], repeatable: false, submissionId: '1234' }), + new DynamicLookupModel({ id: 'lookup', metadataFields: [], repeatable: false, submissionId: '1234', hasSelectableMetadata: false }), + new DynamicLookupNameModel({ id: 'lookupName', metadataFields: [], repeatable: false, submissionId: '1234', hasSelectableMetadata: false}), new DynamicQualdropModel({ id: 'combobox', readOnly: false, required: false }) ]; const testModel = formModel[8]; @@ -175,6 +180,9 @@ describe('DsDynamicFormControlContainerComponent test suite', () => { { provide: Store, useValue: {} }, { provide: RelationshipService, useValue: {} }, { provide: SelectableListService, useValue: {} }, + { provide: FormService, useValue: {} }, + { provide: FormBuilderService, useValue: {} }, + { provide: SubmissionService, useValue: {} }, { provide: SubmissionObjectDataService, useValue: { @@ -220,7 +228,6 @@ describe('DsDynamicFormControlContainerComponent test suite', () => { expect(component.group instanceof FormGroup).toBe(true); expect(component.model instanceof DynamicFormControlModel).toBe(true); expect(component.hasErrorMessaging).toBe(false); - expect(component.asBootstrapFormGroup).toBe(true); expect(component.onControlValueChanges).toBeDefined(); expect(component.onModelDisabledUpdates).toBeDefined(); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts index 2089ce8bca..0064c2e093 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts @@ -1,5 +1,6 @@ import { - ChangeDetectionStrategy, ChangeDetectorRef, + ChangeDetectionStrategy, + ChangeDetectorRef, Component, ComponentFactoryResolver, ContentChildren, @@ -16,7 +17,7 @@ import { ViewChild, ViewContainerRef } from '@angular/core'; -import { FormGroup } from '@angular/forms'; +import { FormArray, FormGroup } from '@angular/forms'; import { DYNAMIC_FORM_CONTROL_TYPE_ARRAY, @@ -29,13 +30,18 @@ import { DYNAMIC_FORM_CONTROL_TYPE_SELECT, DYNAMIC_FORM_CONTROL_TYPE_TEXTAREA, DYNAMIC_FORM_CONTROL_TYPE_TIMEPICKER, - DynamicDatePickerModel, DynamicFormComponentService, + DynamicDatePickerModel, + DynamicFormArrayGroupModel, + DynamicFormArrayModel, + DynamicFormComponentService, DynamicFormControl, DynamicFormControlContainerComponent, DynamicFormControlEvent, + DynamicFormControlEventType, DynamicFormControlModel, DynamicFormLayout, - DynamicFormLayoutService, DynamicFormRelationService, + DynamicFormLayoutService, + DynamicFormRelationService, DynamicFormValidationService, DynamicTemplateDirective, } from '@ng-dynamic-forms/core'; @@ -50,11 +56,7 @@ import { DynamicNGBootstrapTimePickerComponent } from '@ng-dynamic-forms/ui-ng-bootstrap'; import { TranslateService } from '@ngx-translate/core'; -import { followLink } from '../../../utils/follow-link-config.model'; -import { - Reorderable, - ReorderableRelationship -} from './existing-metadata-list-element/existing-metadata-list-element.component'; +import { ReorderableRelationship } from './existing-metadata-list-element/existing-metadata-list-element.component'; import { DYNAMIC_FORM_CONTROL_TYPE_TYPEAHEAD } from './models/typeahead/dynamic-typeahead.model'; import { DYNAMIC_FORM_CONTROL_TYPE_SCROLLABLE_DROPDOWN } from './models/scrollable-dropdown/dynamic-scrollable-dropdown.model'; @@ -63,7 +65,7 @@ import { DYNAMIC_FORM_CONTROL_TYPE_DSDATEPICKER } from './models/date-picker/dat import { DYNAMIC_FORM_CONTROL_TYPE_LOOKUP } from './models/lookup/dynamic-lookup.model'; import { DynamicListCheckboxGroupModel } from './models/list/dynamic-list-checkbox-group.model'; import { DynamicListRadioGroupModel } from './models/list/dynamic-list-radio-group.model'; -import { hasValue, isNotEmpty, isNotUndefined } from '../../../empty.util'; +import { hasNoValue, hasValue, isNotEmpty, isNotUndefined } from '../../../empty.util'; import { DYNAMIC_FORM_CONTROL_TYPE_LOOKUP_NAME } from './models/lookup/dynamic-lookup-name.model'; import { DsDynamicTagComponent } from './models/tag/dynamic-tag.component'; import { DsDatePickerComponent } from './models/date-picker/date-picker.component'; @@ -78,8 +80,8 @@ import { DYNAMIC_FORM_CONTROL_TYPE_RELATION_GROUP } from './models/relation-grou import { DsDatePickerInlineComponent } from './models/date-picker-inline/dynamic-date-picker-inline.component'; import { DYNAMIC_FORM_CONTROL_TYPE_CUSTOM_SWITCH } from './models/custom-switch/custom-switch.model'; import { CustomSwitchComponent } from './models/custom-switch/custom-switch.component'; -import { map, startWith, switchMap, find } from 'rxjs/operators'; -import { combineLatest as observableCombineLatest, Observable, of as observableOf, Subscription } from 'rxjs'; +import { find, map, startWith, switchMap, take } from 'rxjs/operators'; +import { combineLatest as observableCombineLatest, Observable, Subscription } from 'rxjs'; import { SearchResult } from '../../../search/search-result.model'; import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; @@ -88,7 +90,7 @@ import { SelectableListService } from '../../../object-list/selectable-list/sele import { DsDynamicDisabledComponent } from './models/disabled/dynamic-disabled.component'; import { DYNAMIC_FORM_CONTROL_TYPE_DISABLED } from './models/disabled/dynamic-disabled.model'; import { DsDynamicLookupRelationModalComponent } from './relation-lookup-modal/dynamic-lookup-relation-modal.component'; -import { getAllSucceededRemoteData, getRemoteDataPayload, getSucceededRemoteData } from '../../../../core/shared/operators'; +import { getAllSucceededRemoteData, getFirstSucceededRemoteDataPayload, getPaginatedListPayload, getRemoteDataPayload, getSucceededRemoteData } from '../../../../core/shared/operators'; import { RemoteData } from '../../../../core/data/remote-data'; import { Item } from '../../../../core/shared/item.model'; import { ItemDataService } from '../../../../core/data/item-data.service'; @@ -98,9 +100,16 @@ import { SubmissionObjectDataService } from '../../../../core/submission/submiss import { SubmissionObject } from '../../../../core/submission/models/submission-object.model'; import { PaginatedList } from '../../../../core/data/paginated-list'; import { ItemSearchResult } from '../../../object-collection/shared/item-search-result.model'; -import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; import { Relationship } from '../../../../core/shared/item-relationships/relationship.model'; import { Collection } from '../../../../core/shared/collection.model'; +import { MetadataValue, VIRTUAL_METADATA_PREFIX } from '../../../../core/shared/metadata.models'; +import { FormService } from '../../form.service'; +import { SelectableListState } from '../../../object-list/selectable-list/selectable-list.reducer'; +import { SubmissionService } from '../../../../submission/submission.service'; +import { followLink } from '../../../utils/follow-link-config.model'; +import { paginatedRelationsToItems } from '../../../../+item-page/simple/item-types/shared/item-relationships-utils'; +import { RelationshipOptions } from '../models/relationship-options.model'; +import { FormBuilderService } from '../form-builder.service'; export function dsDynamicFormControlMapFn(model: DynamicFormControlModel): Type | null { switch (model.type) { @@ -180,22 +189,22 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo @Input('templates') inputTemplateList: QueryList; @Input() formId: string; - @Input() asBootstrapFormGroup = true; + @Input() asBootstrapFormGroup = false; @Input() bindId = true; @Input() context: any | null = null; @Input() group: FormGroup; @Input() hasErrorMessaging = false; @Input() layout = null as DynamicFormLayout; @Input() model: any; - reorderables$: Observable; - reorderables: ReorderableRelationship[]; - hasRelationLookup: boolean; + relationshipValue$: Observable; + isRelationship: boolean; modalRef: NgbModalRef; item: Item; + item$: Observable; collection: Collection; listId: string; searchConfig: string; - + value: MetadataValue; /** * List of subscriptions to unsubscribe from */ @@ -207,7 +216,7 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo @Output('dfFocus') focus: EventEmitter = new EventEmitter(); @Output('ngbEvent') customEvent: EventEmitter = new EventEmitter(); /* tslint:enable:no-output-rename */ - @ViewChild('componentViewContainer', { read: ViewContainerRef, static: true}) componentViewContainerRef: ViewContainerRef; + @ViewChild('componentViewContainer', { read: ViewContainerRef, static: true }) componentViewContainerRef: ViewContainerRef; private showErrorMessagesPreviousStage: boolean; @@ -229,9 +238,11 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo private zone: NgZone, private store: Store, private submissionObjectService: SubmissionObjectDataService, - private ref: ChangeDetectorRef + private ref: ChangeDetectorRef, + private formService: FormService, + private formBuilderService: FormBuilderService, + private submissionService: SubmissionService ) { - super(componentFactoryResolver, layoutService, validationService, dynamicFormComponentService, relationService); } @@ -239,62 +250,76 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo * Sets up the necessary variables for when this control can be used to add relationships to the submitted item */ ngOnInit(): void { - this.hasRelationLookup = hasValue(this.model.relationship); - this.reorderables = []; - if (this.hasRelationLookup) { + this.isRelationship = hasValue(this.model.relationship); + const isWrapperAroundRelationshipList = hasValue(this.model.relationshipConfig); - this.listId = 'list-' + this.model.relationship.relationshipType; + if (this.isRelationship || isWrapperAroundRelationshipList) { + const config = this.model.relationshipConfig || this.model.relationship; + const relationshipOptions = Object.assign(new RelationshipOptions(), config); + this.listId = `list-${this.model.submissionId}-${relationshipOptions.relationshipType}`; + this.setItem(); - const submissionObject$ = this.submissionObjectService - .findById(this.model.submissionId, followLink('item'), followLink('collection')).pipe( - getAllSucceededRemoteData(), - getRemoteDataPayload() - ); + if (isWrapperAroundRelationshipList || !this.model.repeatable) { + const subscription = this.selectableListService.getSelectableList(this.listId).pipe( + find((list: SelectableListState) => hasNoValue(list)), + switchMap(() => this.item$.pipe(take(1))), + switchMap((item) => { + const relationshipsRD$ = this.relationshipService.getItemRelationshipsByLabel(item, + relationshipOptions.relationshipType, + undefined, + followLink('leftItem'), + followLink('rightItem'), + followLink('relationshipType') + ); - const item$ = submissionObject$.pipe(switchMap((submissionObject: SubmissionObject) => (submissionObject.item as Observable>).pipe(getAllSucceededRemoteData(), getRemoteDataPayload()))); - const collection$ = submissionObject$.pipe(switchMap((submissionObject: SubmissionObject) => (submissionObject.collection as Observable>).pipe(getAllSucceededRemoteData(), getRemoteDataPayload()))); + relationshipsRD$.pipe( + getFirstSucceededRemoteDataPayload(), + getPaginatedListPayload() + ).subscribe((relationships: Relationship[]) => { + // set initial namevariants for pre-existing relationships + relationships.forEach((relationship: Relationship) => { + const relationshipMD: MetadataValue = item.firstMetadata(relationshipOptions.metadataField, { authority: `${VIRTUAL_METADATA_PREFIX}${relationship.id}` }); + const nameVariantMD: MetadataValue = item.firstMetadata(this.model.metadataFields, { authority: `${VIRTUAL_METADATA_PREFIX}${relationship.id}` }); + if (hasValue(relationshipMD) && isNotEmpty(relationshipMD.value) && hasValue(nameVariantMD) && isNotEmpty(nameVariantMD.value)) { + this.relationshipService.setNameVariant(this.listId, relationshipMD.value, nameVariantMD.value); + } + }); + }); - this.subs.push(item$.subscribe((item) => this.item = item)); - this.subs.push(collection$.subscribe((collection) => this.collection = collection)); - this.reorderables$ = item$.pipe( - switchMap((item) => this.relationshipService.getItemRelationshipsByLabel(item, this.model.relationship.relationshipType, undefined, followLink('leftItem'), followLink('rightItem'), followLink('relationshipType')) + return relationshipsRD$.pipe( + paginatedRelationsToItems(item.uuid), + getSucceededRemoteData(), + map((items: RemoteData>) => items.payload.page.map((i) => Object.assign(new ItemSearchResult(), { indexableObject: i }))), + ) + }) + ).subscribe((relatedItems: Array>) => this.selectableListService.select(this.listId, relatedItems)); + this.subs.push(subscription); + } + + if (hasValue(this.model.metadataValue)) { + this.value = Object.assign(new MetadataValue(), this.model.metadataValue); + } else { + this.value = Object.assign(new MetadataValue(), this.model.value); + } + + if (hasValue(this.value) && this.value.isVirtual) { + const relationship$ = this.relationshipService.findById(this.value.virtualValue, followLink('leftItem'), followLink('rightItem'), followLink('relationshipType')) .pipe( getAllSucceededRemoteData(), - getRemoteDataPayload(), - map((relationshipList: PaginatedList) => relationshipList.page), - startWith([]), - switchMap((relationships: Relationship[]) => - observableCombineLatest( - relationships.map((relationship: Relationship) => - relationship.leftItem.pipe( - getSucceededRemoteData(), - getRemoteDataPayload(), - map((leftItem: Item) => { - return new ReorderableRelationship(relationship, leftItem.uuid !== this.item.uuid) - }), - ) - ))), - map((relationships: ReorderableRelationship[]) => - relationships - .sort((a: Reorderable, b: Reorderable) => { - return Math.sign(a.getPlace() - b.getPlace()); - }) + getRemoteDataPayload()); + this.relationshipValue$ = observableCombineLatest([this.item$.pipe(take(1)), relationship$]).pipe( + switchMap(([item, relationship]: [Item, Relationship]) => + relationship.leftItem.pipe( + getAllSucceededRemoteData(), + getRemoteDataPayload(), + map((leftItem: Item) => { + return new ReorderableRelationship(relationship, leftItem.uuid !== item.uuid, this.relationshipService, this.store, this.model.submissionId) + }), ) - ) - ) - ); - - this.subs.push(this.reorderables$.subscribe((rs) => { - this.reorderables = rs; - this.ref.detectChanges(); - })); - - item$.pipe( - switchMap((item) => this.relationshipService.getRelatedItemsByLabel(item, this.model.relationship.relationshipType)), - map((items: RemoteData>) => items.payload.page.map((item) => Object.assign(new ItemSearchResult(), { indexableObject: item }))), - ).subscribe((relatedItems: Array>) => { - this.selectableListService.select(this.listId, relatedItems) - }); + ), + startWith(undefined) + ); + } } } @@ -303,7 +328,7 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo } ngOnChanges(changes: SimpleChanges) { - if (changes) { + if (changes && !this.isRelationship && hasValue(this.group.get(this.model.id))) { super.ngOnChanges(changes); if (this.model && this.model.placeholder) { this.model.placeholder = this.translateService.instant(this.model.placeholder); @@ -351,6 +376,27 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo size: 'lg' }); const modalComp = this.modalRef.componentInstance; + + if (hasValue(this.model.value) && !this.model.readOnly) { + if (typeof this.model.value === 'string') { + modalComp.query = this.model.value; + } else if (typeof this.model.value.value === 'string') { + modalComp.query = this.model.value.value; + } + } + + if (hasValue(this.model.value)) { + this.model.value = ''; + this.onChange({ + $event: { previousIndex: 0 }, + context: { index: 0 }, + control: this.control, + model: this.model, + type: DynamicFormControlEventType.Change + }); + } + this.submissionService.dispatchSave(this.model.submissionId); + modalComp.repeatable = this.model.repeatable; modalComp.listId = this.listId; modalComp.relationshipOptions = this.model.relationship; @@ -358,32 +404,18 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo modalComp.metadataFields = this.model.metadataFields; modalComp.item = this.item; modalComp.collection = this.collection; + modalComp.submissionId = this.model.submissionId; } /** - * Method to move a relationship inside the list of relationships - * This will update the view and update the right or left place field of the relationships in the list - * @param event + * Callback for the remove event, + * remove the current control from its array */ - moveSelection(event: CdkDragDrop) { - this.zone.runOutsideAngular(() => { - moveItemInArray(this.reorderables, event.previousIndex, event.currentIndex); - const reorderables: Reorderable[] = this.reorderables.map((reo: Reorderable, index: number) => { - reo.oldIndex = reo.getPlace(); - reo.newIndex = index; - return reo; - } - ); - observableCombineLatest( - reorderables.map((rel: ReorderableRelationship) => { - if (rel.oldIndex !== rel.newIndex) { - return this.relationshipService.updatePlace(rel); - } else { - return observableOf(undefined) as Observable>; - } - }) - ).subscribe(); - }) + onRemove(): void { + const arrayContext: DynamicFormArrayModel = (this.context as DynamicFormArrayGroupModel).context; + const path = this.formBuilderService.getPath(arrayContext); + const formArrayControl = this.group.root.get(path) as FormArray; + this.formBuilderService.removeFormArrayGroup(this.context.index, formArrayControl, arrayContext); } /** @@ -396,9 +428,20 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo } /** - * Prevent unnecessary rerendering so fields don't lose focus + * Initialize this.item$ based on this.model.submissionId */ - trackReorderable(index, reorderable: Reorderable) { - return hasValue(reorderable) ? reorderable.getId() : undefined; + private setItem() { + const submissionObject$ = this.submissionObjectService + .findById(this.model.submissionId, followLink('item'), followLink('collection')).pipe( + getAllSucceededRemoteData(), + getRemoteDataPayload() + ); + + this.item$ = submissionObject$.pipe(switchMap((submissionObject: SubmissionObject) => (submissionObject.item as Observable>).pipe(getAllSucceededRemoteData(), getRemoteDataPayload()))); + const collection$ = submissionObject$.pipe(switchMap((submissionObject: SubmissionObject) => (submissionObject.collection as Observable>).pipe(getAllSucceededRemoteData(), getRemoteDataPayload()))); + + this.subs.push(this.item$.subscribe((item) => this.item = item)); + this.subs.push(collection$.subscribe((collection) => this.collection = collection)); + } } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form.component.html index 4d8123a4b9..5684f4eac9 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form.component.html @@ -7,6 +7,7 @@ [model]="model" [ngClass]="[getClass(model, 'element', 'host'), getClass(model, 'grid', 'host')]" [templates]="templates" + [asBootstrapFormGroup]="true" (dfBlur)="onEvent($event, 'blur')" (dfChange)="onEvent($event, 'change')" (dfFocus)="onEvent($event, 'focus')"> diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form.component.ts index 490275a03b..ad1c18706d 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form.component.ts @@ -37,5 +37,4 @@ export class DsDynamicFormComponent extends DynamicFormComponent { constructor(protected formService: FormBuilderService, protected layoutService: DynamicFormLayoutService) { super(formService, layoutService); } - } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.html index 960dd78767..57ab7d66d8 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.html @@ -1,11 +1,14 @@ -
  • - - - - - -
  • +
    diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.scss index e69de29bb2..ab63e324bd 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.scss +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.scss @@ -0,0 +1,3 @@ +span.text-contents{ + padding: $btn-padding-y 0; +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.spec.ts index 79a650b597..ff2fd0c798 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.spec.ts @@ -10,6 +10,8 @@ import { RelationshipOptions } from '../../models/relationship-options.model'; import { createSuccessfulRemoteDataObject$ } from '../../../../remote-data.utils'; import { RemoveRelationshipAction } from '../relation-lookup-modal/relationship.actions'; import { ItemSearchResult } from '../../../../object-collection/shared/item-search-result.model'; +import { of as observableOf } from 'rxjs'; +import { RelationshipService } from '../../../../../core/data/relationship.service'; describe('ExistingMetadataListElementComponent', () => { let component: ExistingMetadataListElementComponent; @@ -28,6 +30,8 @@ describe('ExistingMetadataListElementComponent', () => { let leftItemRD$; let rightItemRD$; let relatedSearchResult; + let submissionId; + let relationshipService; function init() { uuid1 = '91ce578d-2e63-4093-8c73-3faafd716000'; @@ -42,9 +46,13 @@ describe('ExistingMetadataListElementComponent', () => { leftItemRD$ = createSuccessfulRemoteDataObject$(relatedItem); rightItemRD$ = createSuccessfulRemoteDataObject$(submissionItem); relatedSearchResult = Object.assign(new ItemSearchResult(), { indexableObject: relatedItem }); + relationshipService = { + updatePlace:() => observableOf({}) + } as any; relationship = Object.assign(new Relationship(), { leftItem: leftItemRD$, rightItem: rightItemRD$ }); - reoRel = new ReorderableRelationship(relationship, true); + submissionId = '1234'; + reoRel = new ReorderableRelationship(relationship, true, relationshipService, {} as any, submissionId); } beforeEach(async(() => { @@ -68,6 +76,7 @@ describe('ExistingMetadataListElementComponent', () => { component.reoRel = reoRel; component.metadataFields = metadataFields; component.relationshipOptions = relationshipOptions; + component.submissionId = submissionId; fixture.detectChanges(); component.ngOnChanges(); }); @@ -84,9 +93,8 @@ describe('ExistingMetadataListElementComponent', () => { it('should dispatch a RemoveRelationshipAction', () => { component.removeSelection(); - const action = new RemoveRelationshipAction(submissionItem, relatedItem, relationshipOptions.relationshipType); + const action = new RemoveRelationshipAction(submissionItem, relatedItem, relationshipOptions.relationshipType, submissionId); expect(store.dispatch).toHaveBeenCalledWith(action); - }); }) }); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.ts index 09aaa253c6..d4ce3342e7 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.ts @@ -1,50 +1,126 @@ -import { Component, Input, OnChanges, OnDestroy, OnInit } from '@angular/core'; +import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output } from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { DynamicFormArrayGroupModel } from '@ng-dynamic-forms/core'; +import { Store } from '@ngrx/store'; +import { BehaviorSubject, Subscription } from 'rxjs'; +import { filter } from 'rxjs/operators'; +import { AppState } from '../../../../../app.reducer'; +import { RelationshipService } from '../../../../../core/data/relationship.service'; +import { Relationship } from '../../../../../core/shared/item-relationships/relationship.model'; import { Item } from '../../../../../core/shared/item.model'; +import { ItemMetadataRepresentation } from '../../../../../core/shared/metadata-representation/item/item-metadata-representation.model'; import { MetadataRepresentation } from '../../../../../core/shared/metadata-representation/metadata-representation.model'; +import { MetadataValue } from '../../../../../core/shared/metadata.models'; import { getAllSucceededRemoteData, getRemoteDataPayload } from '../../../../../core/shared/operators'; import { hasValue, isNotEmpty } from '../../../../empty.util'; -import { Subscription } from 'rxjs'; -import { filter } from 'rxjs/operators'; -import { Relationship } from '../../../../../core/shared/item-relationships/relationship.model'; -import { MetadataValue } from '../../../../../core/shared/metadata.models'; -import { ItemMetadataRepresentation } from '../../../../../core/shared/metadata-representation/item/item-metadata-representation.model'; -import { RelationshipOptions } from '../../models/relationship-options.model'; -import { RemoveRelationshipAction } from '../relation-lookup-modal/relationship.actions'; -import { SelectableListService } from '../../../../object-list/selectable-list/selectable-list.service'; -import { Store } from '@ngrx/store'; -import { AppState } from '../../../../../app.reducer'; import { ItemSearchResult } from '../../../../object-collection/shared/item-search-result.model'; +import { SelectableListService } from '../../../../object-list/selectable-list/selectable-list.service'; +import { FormFieldMetadataValueObject } from '../../models/form-field-metadata-value.model'; +import { RelationshipOptions } from '../../models/relationship-options.model'; +import { DynamicConcatModel } from '../models/ds-dynamic-concat.model'; +import { RemoveRelationshipAction } from '../relation-lookup-modal/relationship.actions'; // tslint:disable:max-classes-per-file /** * Abstract class that defines objects that can be reordered */ export abstract class Reorderable { + constructor(public oldIndex?: number, public newIndex?: number) { } + /** + * Return the id for this Reorderable + */ abstract getId(): string; + /** + * Return the place metadata for this Reorderable + */ abstract getPlace(): number; + + /** + * Update the Reorderable + */ + update(): void { + this.oldIndex = this.newIndex; + } + + /** + * Returns true if the oldIndex of this Reorderable + * differs from the newIndex + */ + get hasMoved(): boolean { + return this.oldIndex !== this.newIndex + } +} + +/** + * A Reorderable representation of a FormFieldMetadataValue + */ +export class ReorderableFormFieldMetadataValue extends Reorderable { + + constructor( + public metadataValue: FormFieldMetadataValueObject, + public model: DynamicConcatModel, + public control: FormControl, + public group: DynamicFormArrayGroupModel, + oldIndex?: number, + newIndex?: number + ) { + super(oldIndex, newIndex); + this.metadataValue = metadataValue; + } + + /** + * Return the id for this Reorderable + */ + getId(): string { + if (hasValue(this.metadataValue.authority)) { + return this.metadataValue.authority; + } else { + // can't use UUIDs, they're generated client side + return this.metadataValue.value; + } + } + + /** + * Return the place metadata for this Reorderable + */ + getPlace(): number { + return this.metadataValue.place; + } + } /** * Represents a single relationship that can be reordered in a list of multiple relationships */ export class ReorderableRelationship extends Reorderable { - relationship: Relationship; - useLeftItem: boolean; - constructor(relationship: Relationship, useLeftItem: boolean, oldIndex?: number, newIndex?: number) { + constructor( + public relationship: Relationship, + public useLeftItem: boolean, + protected relationshipService: RelationshipService, + protected store: Store, + protected submissionID: string, + oldIndex?: number, + newIndex?: number) { super(oldIndex, newIndex); this.relationship = relationship; this.useLeftItem = useLeftItem; } + /** + * Return the id for this Reorderable + */ getId(): string { return this.relationship.id; } + /** + * Return the place metadata for this Reorderable + */ getPlace(): number { if (this.useLeftItem) { return this.relationship.rightPlace @@ -62,15 +138,16 @@ export class ReorderableRelationship extends Reorderable { templateUrl: './existing-metadata-list-element.component.html', styleUrls: ['./existing-metadata-list-element.component.scss'] }) -export class ExistingMetadataListElementComponent implements OnChanges, OnDestroy { +export class ExistingMetadataListElementComponent implements OnInit, OnChanges, OnDestroy { @Input() listId: string; @Input() submissionItem: Item; @Input() reoRel: ReorderableRelationship; @Input() metadataFields: string[]; @Input() relationshipOptions: RelationshipOptions; - metadataRepresentation: MetadataRepresentation; + @Input() submissionId: string; + metadataRepresentation$: BehaviorSubject = new BehaviorSubject(undefined); relatedItem: Item; - + @Output() remove: EventEmitter = new EventEmitter(); /** * List of subscriptions to unsubscribe from */ @@ -82,24 +159,35 @@ export class ExistingMetadataListElementComponent implements OnChanges, OnDestro ) { } + ngOnInit(): void { + this.ngOnChanges(); + } + + /** + * Change callback for the component + */ ngOnChanges() { - const item$ = this.reoRel.useLeftItem ? - this.reoRel.relationship.leftItem : this.reoRel.relationship.rightItem; - this.subs.push(item$.pipe( - getAllSucceededRemoteData(), - getRemoteDataPayload(), - filter((item: Item) => hasValue(item) && isNotEmpty(item.uuid)) - ).subscribe((item: Item) => { - this.relatedItem = item; - const relationMD: MetadataValue = this.submissionItem.firstMetadata(this.relationshipOptions.metadataField, { value: this.relatedItem.uuid }); - if (hasValue(relationMD)) { - const metadataRepresentationMD: MetadataValue = this.submissionItem.firstMetadata(this.metadataFields, { authority: relationMD.authority }); - this.metadataRepresentation = Object.assign( - new ItemMetadataRepresentation(metadataRepresentationMD), - this.relatedItem - ) - } - })); + if (hasValue(this.reoRel)) { + const item$ = this.reoRel.useLeftItem ? + this.reoRel.relationship.leftItem : this.reoRel.relationship.rightItem; + this.subs.push(item$.pipe( + getAllSucceededRemoteData(), + getRemoteDataPayload(), + filter((item: Item) => hasValue(item) && isNotEmpty(item.uuid)) + ).subscribe((item: Item) => { + this.relatedItem = item; + const relationMD: MetadataValue = this.submissionItem.firstMetadata(this.relationshipOptions.metadataField, { value: this.relatedItem.uuid }); + if (hasValue(relationMD)) { + const metadataRepresentationMD: MetadataValue = this.submissionItem.firstMetadata(this.metadataFields, { authority: relationMD.authority }); + + const nextValue = Object.assign( + new ItemMetadataRepresentation(metadataRepresentationMD), + this.relatedItem + ); + this.metadataRepresentation$.next(nextValue); + } + })); + } } /** @@ -107,7 +195,8 @@ export class ExistingMetadataListElementComponent implements OnChanges, OnDestro */ removeSelection() { this.selectableListService.deselectSingle(this.listId, Object.assign(new ItemSearchResult(), { indexableObject: this.relatedItem })); - this.store.dispatch(new RemoveRelationshipAction(this.submissionItem, this.relatedItem, this.relationshipOptions.relationshipType)) + this.store.dispatch(new RemoveRelationshipAction(this.submissionItem, this.relatedItem, this.relationshipOptions.relationshipType, this.submissionId)); + this.remove.emit(); } /** @@ -120,4 +209,5 @@ export class ExistingMetadataListElementComponent implements OnChanges, OnDestro } } + // tslint:enable:max-classes-per-file diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/existing-relation-list-element/existing-relation-list-element.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-relation-list-element/existing-relation-list-element.component.html new file mode 100644 index 0000000000..15087d2553 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-relation-list-element/existing-relation-list-element.component.html @@ -0,0 +1,14 @@ +
    + + + + + + + + + +
    diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/existing-relation-list-element/existing-relation-list-element.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-relation-list-element/existing-relation-list-element.component.scss new file mode 100644 index 0000000000..ab63e324bd --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-relation-list-element/existing-relation-list-element.component.scss @@ -0,0 +1,3 @@ +span.text-contents{ + padding: $btn-padding-y 0; +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/existing-relation-list-element/existing-relation-list-element.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-relation-list-element/existing-relation-list-element.component.spec.ts new file mode 100644 index 0000000000..6b6c518bb0 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-relation-list-element/existing-relation-list-element.component.spec.ts @@ -0,0 +1,100 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ExistingRelationListElementComponent } from './existing-relation-list-element.component'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { SelectableListService } from '../../../../object-list/selectable-list/selectable-list.service'; +import { select, Store } from '@ngrx/store'; +import { Item } from '../../../../../core/shared/item.model'; +import { Relationship } from '../../../../../core/shared/item-relationships/relationship.model'; +import { RelationshipOptions } from '../../models/relationship-options.model'; +import { RemoveRelationshipAction } from '../relation-lookup-modal/relationship.actions'; +import { ItemSearchResult } from '../../../../object-collection/shared/item-search-result.model'; +import { of as observableOf } from 'rxjs'; +import { ReorderableRelationship } from '../existing-metadata-list-element/existing-metadata-list-element.component'; +import { createSuccessfulRemoteDataObject$ } from '../../../../remote-data.utils'; + +describe('ExistingRelationListElementComponent', () => { + let component: ExistingRelationListElementComponent; + let fixture: ComponentFixture; + let selectionService; + let store; + let listID; + let submissionItem; + let relationship; + let reoRel; + let metadataFields; + let relationshipOptions; + let uuid1; + let uuid2; + let relatedItem; + let leftItemRD$; + let rightItemRD$; + let relatedSearchResult; + let submissionId; + let relationshipService; + + function init() { + uuid1 = '91ce578d-2e63-4093-8c73-3faafd716000'; + uuid2 = '0e9dba1c-e1c3-4e05-a539-446f08ef57a7'; + selectionService = jasmine.createSpyObj('selectionService', ['deselectSingle']); + store = jasmine.createSpyObj('store', ['dispatch']); + listID = '1234-listID'; + submissionItem = Object.assign(new Item(), { uuid: uuid1 }); + metadataFields = ['dc.contributor.author']; + relationshipOptions = Object.assign(new RelationshipOptions(), { relationshipType: 'isPublicationOfAuthor', filter: 'test.filter', searchConfiguration: 'personConfiguration', nameVariants: true }) + relatedItem = Object.assign(new Item(), { uuid: uuid2 }); + leftItemRD$ = createSuccessfulRemoteDataObject$(relatedItem); + rightItemRD$ = createSuccessfulRemoteDataObject$(submissionItem); + relatedSearchResult = Object.assign(new ItemSearchResult(), { indexableObject: relatedItem }); + relationshipService = { + updatePlace:() => observableOf({}) + } as any; + + relationship = Object.assign(new Relationship(), { leftItem: leftItemRD$, rightItem: rightItemRD$ }); + submissionId = '1234'; + reoRel = new ReorderableRelationship(relationship, true, relationshipService, {} as any, submissionId); + } + + beforeEach(async(() => { + init(); + TestBed.configureTestingModule({ + declarations: [ExistingRelationListElementComponent], + providers: [ + { provide: SelectableListService, useValue: selectionService }, + { provide: Store, useValue: store }, + ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ExistingRelationListElementComponent); + component = fixture.componentInstance; + component.listId = listID; + component.submissionItem = submissionItem; + component.reoRel = reoRel; + component.metadataFields = metadataFields; + component.relationshipOptions = relationshipOptions; + component.submissionId = submissionId; + fixture.detectChanges(); + component.ngOnChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('removeSelection', () => { + it('should deselect the object in the selectable list service', () => { + component.removeSelection(); + expect(selectionService.deselectSingle).toHaveBeenCalledWith(listID, relatedSearchResult); + }); + + it('should dispatch a RemoveRelationshipAction', () => { + component.removeSelection(); + const action = new RemoveRelationshipAction(submissionItem, relatedItem, relationshipOptions.relationshipType, submissionId); + expect(store.dispatch).toHaveBeenCalledWith(action); + }); + }) +}); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/existing-relation-list-element/existing-relation-list-element.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-relation-list-element/existing-relation-list-element.component.ts new file mode 100644 index 0000000000..65b3730773 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-relation-list-element/existing-relation-list-element.component.ts @@ -0,0 +1,120 @@ +import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { BehaviorSubject, Observable, Subscription } from 'rxjs'; +import { filter } from 'rxjs/operators'; +import { AppState } from '../../../../../app.reducer'; +import { Item } from '../../../../../core/shared/item.model'; +import { getAllSucceededRemoteData, getRemoteDataPayload } from '../../../../../core/shared/operators'; +import { hasValue, isNotEmpty } from '../../../../empty.util'; +import { ItemSearchResult } from '../../../../object-collection/shared/item-search-result.model'; +import { SelectableListService } from '../../../../object-list/selectable-list/selectable-list.service'; +import { RelationshipOptions } from '../../models/relationship-options.model'; +import { RemoveRelationshipAction } from '../relation-lookup-modal/relationship.actions'; +import { ViewMode } from '../../../../../core/shared/view-mode.model'; +import { ReorderableRelationship } from '../existing-metadata-list-element/existing-metadata-list-element.component'; + +// tslint:disable:max-classes-per-file +/** + * Abstract class that defines objects that can be reordered + */ +export abstract class Reorderable { + + constructor(public oldIndex?: number, public newIndex?: number) { + } + + /** + * Return the id for this Reorderable + */ + abstract getId(): string; + + /** + * Return the place metadata for this Reorderable + */ + abstract getPlace(): number; + + /** + * Update the Reorderable + */ + abstract update(): Observable; + + /** + * Returns true if the oldIndex of this Reorderable + * differs from the newIndex + */ + get hasMoved(): boolean { + return this.oldIndex !== this.newIndex + } +} + +/** + * Represents a single existing relationship value as metadata in submission + */ +@Component({ + selector: 'ds-existing-relation-list-element', + templateUrl: './existing-relation-list-element.component.html', + styleUrls: ['./existing-relation-list-element.component.scss'] +}) +export class ExistingRelationListElementComponent implements OnInit, OnChanges, OnDestroy { + @Input() listId: string; + @Input() submissionItem: Item; + @Input() reoRel: ReorderableRelationship; + @Input() metadataFields: string[]; + @Input() relationshipOptions: RelationshipOptions; + @Input() submissionId: string; + relatedItem$: BehaviorSubject = new BehaviorSubject(undefined); + viewType = ViewMode.ListElement; + @Output() remove: EventEmitter = new EventEmitter(); + + /** + * List of subscriptions to unsubscribe from + */ + private subs: Subscription[] = []; + + constructor( + private selectableListService: SelectableListService, + private store: Store + ) { + } + + ngOnInit(): void { + this.ngOnChanges(); + } + + /** + * Change callback for the component + */ + ngOnChanges() { + if (hasValue(this.reoRel)) { + const item$ = this.reoRel.useLeftItem ? + this.reoRel.relationship.leftItem : this.reoRel.relationship.rightItem; + this.subs.push(item$.pipe( + getAllSucceededRemoteData(), + getRemoteDataPayload(), + filter((item: Item) => hasValue(item) && isNotEmpty(item.uuid)) + ).subscribe((item: Item) => { + this.relatedItem$.next(item); + })); + } + + } + + /** + * Removes the selected relationship from the list + */ + removeSelection() { + this.selectableListService.deselectSingle(this.listId, Object.assign(new ItemSearchResult(), { indexableObject: this.relatedItem$.getValue() })); + this.store.dispatch(new RemoveRelationshipAction(this.submissionItem, this.relatedItem$.getValue(), this.relationshipOptions.relationshipType, this.submissionId)); + } + + /** + * Unsubscribe from all subscriptions + */ + ngOnDestroy(): void { + this.subs + .filter((sub) => hasValue(sub)) + .forEach((sub) => sub.unsubscribe()); + } + +} + +// tslint:enable:max-classes-per-file diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.html index 75c27b6ca5..ac5ece93d1 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.html @@ -1,32 +1,45 @@ -
    - -
    - - - - - - - +
    + + + +
    +
    +
    +
    + + + + + + +
    +
    -
    - + + + + + diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.scss new file mode 100644 index 0000000000..b61bb9232b --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.scss @@ -0,0 +1,52 @@ +@import './../../../../../../../styles/variables'; + +:host { + display: block; +} + +.cdk-drag { + margin-left: -(2 * $spacer); + margin-right: -(0.5 * $spacer); + padding-right: (0.5 * $spacer); + .drag-icon { + visibility: hidden; + width: (2 * $spacer); + color: $gray-600; + margin: $btn-padding-y 0; + line-height: $btn-line-height; + text-indent: 0.5 * $spacer + } + + &:hover, &:focus { + cursor: grab; + .drag-icon { + visibility: visible; + } + } + +} + +.cdk-drop-list-dragging { + .cdk-drag { + cursor: grabbing; + .drag-icon { + visibility: hidden; + } + } +} + +.cdk-drag-preview { + background-color: white; + border-radius: $border-radius-sm; + margin-left: 0; + box-shadow: 0 5px 5px 0px rgba(0, 0, 0, 0.2), + 0 8px 10px 1px rgba(0, 0, 0, 0.14), + 0 3px 14px 2px rgba(0, 0, 0, 0.12); + .drag-icon { + visibility: visible; + } +} + +.cdk-drag-placeholder { + opacity: 0; +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.ts index 1e8fd3b55e..ea6455a138 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.ts @@ -1,25 +1,31 @@ +import { CdkDragDrop } from '@angular/cdk/drag-drop'; import { Component, EventEmitter, Input, Output, QueryList } from '@angular/core'; import { FormGroup } from '@angular/forms'; import { DynamicFormArrayComponent, - DynamicFormArrayModel, - DynamicFormControlCustomEvent, DynamicFormControlEvent, + DynamicFormControlCustomEvent, + DynamicFormControlEvent, + DynamicFormControlEventType, DynamicFormLayout, DynamicFormLayoutService, DynamicFormValidationService, DynamicTemplateDirective } from '@ng-dynamic-forms/core'; +import { Relationship } from '../../../../../../core/shared/item-relationships/relationship.model'; +import { DynamicRowArrayModel } from '../ds-dynamic-row-array-model'; +import { hasValue } from '../../../../../empty.util'; @Component({ - selector: 'ds-dynamic-form-array', - templateUrl: './dynamic-form-array.component.html' + selector: 'ds-dynamic-form-array', + templateUrl: './dynamic-form-array.component.html', + styleUrls: ['./dynamic-form-array.component.scss'] }) export class DsDynamicFormArrayComponent extends DynamicFormArrayComponent { @Input() bindId = true; @Input() group: FormGroup; @Input() layout: DynamicFormLayout; - @Input() model: DynamicFormArrayModel; + @Input() model: DynamicRowArrayModel; @Input() templates: QueryList | undefined; /* tslint:disable:no-output-rename */ @@ -27,12 +33,39 @@ export class DsDynamicFormArrayComponent extends DynamicFormArrayComponent { @Output('dfChange') change: EventEmitter = new EventEmitter(); @Output('dfFocus') focus: EventEmitter = new EventEmitter(); @Output('ngbEvent') customEvent: EventEmitter = new EventEmitter(); + /* tslint:enable:no-output-rename */ constructor(protected layoutService: DynamicFormLayoutService, - protected validationService: DynamicFormValidationService) { - + protected validationService: DynamicFormValidationService, + ) { super(layoutService, validationService); } + moveSelection(event: CdkDragDrop) { + this.model.moveGroup(event.previousIndex, event.currentIndex - event.previousIndex); + const prevIndex = event.previousIndex - 1; + const index = event.currentIndex - 1; + + if (hasValue(this.model.groups[index]) && hasValue((this.control as any).controls[index])) { + const $event = { + $event: { previousIndex: prevIndex }, + context: { index }, + control: (this.control as any).controls[index], + group: this.group, + model: this.model.groups[index].group[0], + type: DynamicFormControlEventType.Change + }; + + this.onChange($event); + } + } + + update(event: any, index: number) { + const $event = Object.assign({}, event, { + context: { index: index - 1} + }); + + this.onChange($event) + } } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.model.ts index 995fcbf350..7f7c3e68d5 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.model.ts @@ -17,6 +17,7 @@ export class DynamicDsDatePickerModel extends DynamicDateControlModel { valueUpdates: Subject; malformedDate: boolean; hasLanguages = false; + repeatable = false; constructor(config: DynamicDateControlModelConfig, layout?: DynamicFormControlLayout) { super(config, layout); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/disabled/dynamic-disabled.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/disabled/dynamic-disabled.component.spec.ts index 8e0c6fc20e..3aaff1339f 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/disabled/dynamic-disabled.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/disabled/dynamic-disabled.component.spec.ts @@ -15,7 +15,7 @@ describe('DsDynamicDisabledComponent', () => { let model; function init() { - model = new DynamicDisabledModel({ value: 'test', repeatable: false, metadataFields: [], submissionId: '1234', id: '1' }); + model = new DynamicDisabledModel({ value: 'test', repeatable: false, metadataFields: [], submissionId: '1234', id: '1', hasSelectableMetadata: false }); } beforeEach(async(() => { @@ -52,7 +52,6 @@ describe('DsDynamicDisabledComponent', () => { it('should have a disabled input', () => { const input = de.query(By.css('input')); - console.log(input.nativeElement.getAttribute('disabled')); expect(input.nativeElement.getAttribute('disabled')).toEqual(''); }); }); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/disabled/dynamic-disabled.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/disabled/dynamic-disabled.model.ts index 0fa2b3e5ed..5eb9aa8dd2 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/disabled/dynamic-disabled.model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/disabled/dynamic-disabled.model.ts @@ -5,6 +5,7 @@ export const DYNAMIC_FORM_CONTROL_TYPE_DISABLED = 'EMPTY'; export interface DsDynamicDisabledModelConfig extends DsDynamicInputModelConfig { value?: any; + hasSelectableMetadata: boolean; } /** @@ -14,11 +15,14 @@ export class DynamicDisabledModel extends DsDynamicInputModel { @serializable() readonly type: string = DYNAMIC_FORM_CONTROL_TYPE_DISABLED; @serializable() value: any; + @serializable() hasSelectableMetadata: boolean; constructor(config: DsDynamicDisabledModelConfig, layout?: DynamicFormControlLayout) { super(config, layout); this.readOnly = true; this.disabled = true; + this.hasSelectableMetadata = config.hasSelectableMetadata; + this.valueUpdates.next(config.value); } } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-concat.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-concat.model.ts index af05d5bf35..7d4b58c95d 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-concat.model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-concat.model.ts @@ -2,10 +2,11 @@ import { DynamicFormControlLayout, DynamicFormGroupModel, DynamicFormGroupModelC import { Subject } from 'rxjs'; -import { isNotEmpty } from '../../../../empty.util'; +import { hasNoValue, isNotEmpty } from '../../../../empty.util'; import { DsDynamicInputModel } from './ds-dynamic-input.model'; import { FormFieldMetadataValueObject } from '../../models/form-field-metadata-value.model'; import { RelationshipOptions } from '../../models/relationship-options.model'; +import { MetadataValue } from '../../../../../core/shared/metadata.models'; export const CONCAT_GROUP_SUFFIX = '_CONCAT_GROUP'; export const CONCAT_FIRST_INPUT_SUFFIX = '_CONCAT_FIRST_INPUT'; @@ -14,11 +15,14 @@ export const CONCAT_SECOND_INPUT_SUFFIX = '_CONCAT_SECOND_INPUT'; export interface DynamicConcatModelConfig extends DynamicFormGroupModelConfig { separator: string; value?: any; + hint?: string; relationship?: RelationshipOptions; repeatable: boolean; required: boolean; metadataFields: string[]; submissionId: string; + hasSelectableMetadata: boolean; + metadataValue?: MetadataValue; } export class DynamicConcatModel extends DynamicFormGroupModel { @@ -28,8 +32,11 @@ export class DynamicConcatModel extends DynamicFormGroupModel { @serializable() relationship?: RelationshipOptions; @serializable() repeatable?: boolean; @serializable() required?: boolean; + @serializable() hint?: string; @serializable() metadataFields: string[]; @serializable() submissionId: string; + @serializable() hasSelectableMetadata: boolean; + @serializable() metadataValue: MetadataValue; isCustomGroup = true; valueUpdates: Subject; @@ -37,26 +44,30 @@ export class DynamicConcatModel extends DynamicFormGroupModel { constructor(config: DynamicConcatModelConfig, layout?: DynamicFormControlLayout) { super(config, layout); - this.separator = config.separator + ' '; this.relationship = config.relationship; this.repeatable = config.repeatable; this.required = config.required; + this.hint = config.hint; this.metadataFields = config.metadataFields; this.submissionId = config.submissionId; - + this.hasSelectableMetadata = config.hasSelectableMetadata; + this.metadataValue = config.metadataValue; this.valueUpdates = new Subject(); this.valueUpdates.subscribe((value: string) => this.value = value); } get value() { - const firstValue = (this.get(0) as DsDynamicInputModel).value; - const secondValue = (this.get(1) as DsDynamicInputModel).value; - - if (isNotEmpty(firstValue) && isNotEmpty(secondValue)) { - return new FormFieldMetadataValueObject(firstValue + this.separator + secondValue); - } else if (isNotEmpty(firstValue)) { - return new FormFieldMetadataValueObject(firstValue); + const [firstValue, secondValue] = this.group.map((inputModel: DsDynamicInputModel) => + (typeof inputModel.value === 'string') ? + Object.assign(new FormFieldMetadataValueObject(), { value: inputModel.value, display: inputModel.value }) : + (inputModel.value as any)); + if (isNotEmpty(firstValue) && isNotEmpty(firstValue.value) && isNotEmpty(secondValue) && isNotEmpty(secondValue.value)) { + return Object.assign(new FormFieldMetadataValueObject(), firstValue, { value: firstValue.value + this.separator + secondValue.value }); + } else if (isNotEmpty(firstValue) && isNotEmpty(firstValue.value)) { + return Object.assign(new FormFieldMetadataValueObject(), firstValue); + } else if (isNotEmpty(secondValue) && isNotEmpty(secondValue.value)) { + return Object.assign(new FormFieldMetadataValueObject(), secondValue); } else { return null; } @@ -71,18 +82,21 @@ export class DynamicConcatModel extends DynamicFormGroupModel { } else { tempValue = value.value; } - - if (tempValue.includes(this.separator)) { - values = tempValue.split(this.separator); - } else { - values = [tempValue, null]; + if (hasNoValue(tempValue)) { + tempValue = ''; } + values = [...tempValue.split(this.separator), null].map((v) => + Object.assign(new FormFieldMetadataValueObject(), value, { display: v, value: v })); - if (values[0]) { + if (values[0].value) { (this.get(0) as DsDynamicInputModel).valueUpdates.next(values[0]); + } else { + (this.get(0) as DsDynamicInputModel).valueUpdates.next(undefined); } - if (values[1]) { + if (values[1].value) { (this.get(1) as DsDynamicInputModel).valueUpdates.next(values[1]); + } else { + (this.get(1) as DsDynamicInputModel).valueUpdates.next(undefined); } } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model.ts index 3827df7be6..7573b67912 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model.ts @@ -6,16 +6,21 @@ import { AuthorityOptions } from '../../../../../core/integration/models/authori import { hasValue } from '../../../../empty.util'; import { FormFieldMetadataValueObject } from '../../models/form-field-metadata-value.model'; import { RelationshipOptions } from '../../models/relationship-options.model'; +import { MetadataValue } from '../../../../../core/shared/metadata.models'; export interface DsDynamicInputModelConfig extends DynamicInputModelConfig { authorityOptions?: AuthorityOptions; languageCodes?: LanguageCode[]; language?: string; + place?: number; value?: any; relationship?: RelationshipOptions; repeatable: boolean; metadataFields: string[]; submissionId: string; + hasSelectableMetadata: boolean; + metadataValue?: MetadataValue; + } export class DsDynamicInputModel extends DynamicInputModel { @@ -28,6 +33,8 @@ export class DsDynamicInputModel extends DynamicInputModel { @serializable() repeatable?: boolean; @serializable() metadataFields: string[]; @serializable() submissionId: string; + @serializable() hasSelectableMetadata: boolean; + @serializable() metadataValue: MetadataValue; constructor(config: DsDynamicInputModelConfig, layout?: DynamicFormControlLayout) { super(config, layout); @@ -38,6 +45,8 @@ export class DsDynamicInputModel extends DynamicInputModel { this.value = config.value; this.relationship = config.relationship; this.submissionId = config.submissionId; + this.hasSelectableMetadata = config.hasSelectableMetadata; + this.metadataValue = config.metadataValue; this.language = config.language; if (!this.language) { diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-array-model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-array-model.ts index 7de319bf56..8925d8fd87 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-array-model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-array-model.ts @@ -1,18 +1,34 @@ import { DynamicFormArrayModel, DynamicFormArrayModelConfig, DynamicFormControlLayout, serializable } from '@ng-dynamic-forms/core'; +import { RelationshipOptions } from '../../models/relationship-options.model'; export interface DynamicRowArrayModelConfig extends DynamicFormArrayModelConfig { notRepeatable: boolean; required: boolean; + submissionId: string; + relationshipConfig: RelationshipOptions; + metadataKey: string; + metadataFields: string[]; + hasSelectableMetadata: boolean; } export class DynamicRowArrayModel extends DynamicFormArrayModel { @serializable() notRepeatable = false; @serializable() required = false; + @serializable() submissionId: string; + @serializable() relationshipConfig: RelationshipOptions; + @serializable() metadataKey: string; + @serializable() metadataFields: string[]; + @serializable() hasSelectableMetadata: boolean; isRowArray = true; constructor(config: DynamicRowArrayModelConfig, layout?: DynamicFormControlLayout) { super(config, layout); this.notRepeatable = config.notRepeatable; this.required = config.required; + this.submissionId = config.submissionId; + this.relationshipConfig = config.relationshipConfig; + this.metadataKey = config.metadataKey; + this.metadataFields = config.metadataFields; + this.hasSelectableMetadata = config.hasSelectableMetadata; } } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/form-group/dynamic-form-group.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/form-group/dynamic-form-group.component.html index 897ea4c5e3..c80b0d4e08 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/form-group/dynamic-form-group.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/form-group/dynamic-form-group.component.html @@ -6,7 +6,6 @@ [ngClass]="getClass('element','control')">
    -
    diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.spec.ts index c77aabfeed..b11aa2cb20 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.spec.ts @@ -11,7 +11,7 @@ import { DynamicFormsNGBootstrapUIModule } from '@ng-dynamic-forms/ui-ng-bootstr import { AuthorityService } from '../../../../../../core/integration/authority.service'; import { AuthorityServiceStub } from '../../../../../testing/authority-service.stub'; import { DsDynamicLookupComponent } from './dynamic-lookup.component'; -import { DynamicLookupModel } from './dynamic-lookup.model'; +import { DynamicLookupModel, DynamicLookupModelConfig } from './dynamic-lookup.model'; import { InfiniteScrollModule } from 'ngx-infinite-scroll'; import { TranslateModule } from '@ngx-translate/core'; import { FormFieldMetadataValueObject } from '../../../models/form-field-metadata-value.model'; @@ -22,7 +22,7 @@ import { DynamicLookupNameModel } from './dynamic-lookup-name.model'; import { AuthorityConfidenceStateDirective } from '../../../../../authority-confidence/authority-confidence-state.directive'; import { ObjNgFor } from '../../../../../utils/object-ngfor.pipe'; -let LOOKUP_TEST_MODEL_CONFIG = { +let LOOKUP_TEST_MODEL_CONFIG: DynamicLookupModelConfig = { authorityOptions: { closed: false, metadata: 'lookup', @@ -39,11 +39,11 @@ let LOOKUP_TEST_MODEL_CONFIG = { readOnly: false, required: true, repeatable: true, - separator: ',', validators: { required: null }, value: undefined, metadataFields: [], - submissionId: '1234' + submissionId: '1234', + hasSelectableMetadata: false }; let LOOKUP_NAME_TEST_MODEL_CONFIG = { @@ -63,11 +63,11 @@ let LOOKUP_NAME_TEST_MODEL_CONFIG = { readOnly: false, required: true, repeatable: true, - separator: ',', validators: { required: null }, value: undefined, metadataFields: [], - submissionId: '1234' + submissionId: '1234', + hasSelectableMetadata: false }; let LOOKUP_TEST_GROUP = new FormGroup({ @@ -94,11 +94,11 @@ describe('Dynamic Lookup component', () => { readOnly: false, required: true, repeatable: true, - separator: ',', validators: { required: null }, value: undefined, metadataFields: [], - submissionId: '1234' + submissionId: '1234', + hasSelectableMetadata: false }; LOOKUP_NAME_TEST_MODEL_CONFIG = { @@ -118,11 +118,11 @@ describe('Dynamic Lookup component', () => { readOnly: false, required: true, repeatable: true, - separator: ',', validators: { required: null }, value: undefined, metadataFields: [], - submissionId: '1234' + submissionId: '1234', + hasSelectableMetadata: false }; LOOKUP_TEST_GROUP = new FormGroup({ diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.component.spec.ts index bcddb52123..b5cb153db2 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.component.spec.ts @@ -80,7 +80,8 @@ function init() { submissionScope: undefined, validators: { required: null }, repeatable: false, - metadataFields: [] + metadataFields: [], + hasSelectableMetadata: false } as DynamicRelationGroupModelConfig; FORM_GROUP_TEST_GROUP = new FormGroup({ diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.html index cfe50def98..8cb44bc733 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.html @@ -1,8 +1,7 @@
    - - diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts index 6086444264..21c832d4f8 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts @@ -40,7 +40,8 @@ export const SD_TEST_MODEL_CONFIG = { repeatable: false, value: undefined, metadataFields: [], - submissionId: '1234' + submissionId: '1234', + hasSelectableMetadata: false }; describe('Dynamic Dynamic Scrollable Dropdown component', () => { diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.html index b4a9e4c9c7..909dcb1934 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.html @@ -1,5 +1,5 @@
    + { let lookupRelationService; function init() { - relationship = { filter: 'filter', relationshipType: 'isAuthorOfPublication', nameVariants: true } as RelationshipOptions; + relationship = Object.assign(new RelationshipOptions(), { filter: 'filter', relationshipType: 'isAuthorOfPublication', nameVariants: true, searchConfiguration: 'personConfig' }); pSearchOptions = new PaginatedSearchOptions({}); item1 = Object.assign(new Item(), { uuid: 'e1c51c69-896d-42dc-8221-1d5f2ad5516e' }); item2 = Object.assign(new Item(), { uuid: 'c8279647-1acc-41ae-b036-951d5f65649b' }); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.ts index 9484631610..f851e52537 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.ts @@ -2,20 +2,18 @@ import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angu import { SEARCH_CONFIG_SERVICE } from '../../../../../../+my-dspace-page/my-dspace-page.component'; import { SearchConfigurationService } from '../../../../../../core/shared/search/search-configuration.service'; import { Item } from '../../../../../../core/shared/item.model'; -import { PaginatedSearchOptions } from '../../../../../search/paginated-search-options.model'; import { SearchResult } from '../../../../../search/search-result.model'; import { PaginatedList } from '../../../../../../core/data/paginated-list'; import { RemoteData } from '../../../../../../core/data/remote-data'; -import { Observable, ReplaySubject } from 'rxjs'; +import { Observable } from 'rxjs'; import { RelationshipOptions } from '../../../models/relationship-options.model'; import { PaginationComponentOptions } from '../../../../../pagination/pagination-component-options.model'; import { ListableObject } from '../../../../../object-collection/shared/listable-object.model'; import { SearchService } from '../../../../../../core/shared/search/search.service'; import { ActivatedRoute, Router } from '@angular/router'; import { SelectableListService } from '../../../../../object-list/selectable-list/selectable-list.service'; -import { hasValue, isNotEmpty } from '../../../../../empty.util'; -import { concat, map, multicast, switchMap, take, takeWhile, tap } from 'rxjs/operators'; -import { DSpaceObject } from '../../../../../../core/shared/dspace-object.model'; +import { hasValue } from '../../../../../empty.util'; +import { map, startWith, switchMap, take, tap } from 'rxjs/operators'; import { getSucceededRemoteData } from '../../../../../../core/shared/operators'; import { RouteService } from '../../../../../../core/services/route.service'; import { CollectionElementLinkType } from '../../../../../object-collection/collection-element-link.type'; @@ -47,6 +45,7 @@ export class DsDynamicLookupRelationSearchTabComponent implements OnInit, OnDest * The ID of the list to add/remove selected items to/from */ @Input() listId: string; + @Input() query: string; /** * Is the selection repeatable? @@ -101,10 +100,10 @@ export class DsDynamicLookupRelationSearchTabComponent implements OnInit, OnDest /** * The initial pagination to use */ - initialPagination = Object.assign(new PaginationComponentOptions(), { - id: 'submission-relation-list', + initialPagination = { + page: 1, pageSize: 5 - }); + }; /** * The type of links to display @@ -129,10 +128,8 @@ export class DsDynamicLookupRelationSearchTabComponent implements OnInit, OnDest this.resetRoute(); this.routeService.setParameter('fixedFilterQuery', this.relationship.filter); this.routeService.setParameter('configuration', this.relationship.searchConfiguration); - - this.someSelected$ = this.selection$.pipe(map((selection) => isNotEmpty(selection))); this.resultsRD$ = this.searchConfigService.paginatedSearchOptions.pipe( - switchMap((options) => this.lookupRelationService.getLocalResults(this.relationship, options)) + switchMap((options) => this.lookupRelationService.getLocalResults(this.relationship, options).pipe(startWith(undefined))) ); } @@ -141,7 +138,7 @@ export class DsDynamicLookupRelationSearchTabComponent implements OnInit, OnDest */ resetRoute() { this.router.navigate([], { - queryParams: Object.assign({}, { pageSize: this.initialPagination.pageSize }, this.route.snapshot.queryParams, { page: 1 }) + queryParams: Object.assign({ query: this.query }, this.route.snapshot.queryParams, this.initialPagination), }); } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/selection-tab/dynamic-lookup-relation-selection-tab.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/selection-tab/dynamic-lookup-relation-selection-tab.component.html index 46ee1727fe..cd55553f5b 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/selection-tab/dynamic-lookup-relation-selection-tab.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/selection-tab/dynamic-lookup-relation-selection-tab.component.html @@ -8,7 +8,7 @@ {{'submission.sections.describe.relationship-lookup.selection-tab.no-selection' | translate}}
    -

    {{ 'submission.sections.describe.relationship-lookup.selection-tab.title.' + label | translate}}

    +

    {{ 'submission.sections.describe.relationship-lookup.selection-tab.title.' + relationshipType | translate}}

    - \ No newline at end of file + diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/selection-tab/dynamic-lookup-relation-selection-tab.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/selection-tab/dynamic-lookup-relation-selection-tab.component.ts index a918b51930..b01af2e57b 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/selection-tab/dynamic-lookup-relation-selection-tab.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/selection-tab/dynamic-lookup-relation-selection-tab.component.ts @@ -30,9 +30,9 @@ import { createSuccessfulRemoteDataObject } from '../../../../../remote-data.uti */ export class DsDynamicLookupRelationSelectionTabComponent { /** - * The label to use to display i18n messages (describing the type of relationship) + * A string that describes the type of relationship */ - @Input() label: string; + @Input() relationshipType: string; /** * The ID of the list to add/remove selected items to/from diff --git a/src/app/shared/form/builder/form-builder.service.spec.ts b/src/app/shared/form/builder/form-builder.service.spec.ts index 972abb68b5..400ba0daff 100644 --- a/src/app/shared/form/builder/form-builder.service.spec.ts +++ b/src/app/shared/form/builder/form-builder.service.spec.ts @@ -56,10 +56,10 @@ describe('FormBuilderService test suite', () => { let testFormConfiguration: SubmissionFormsModel; let service: FormBuilderService; - const submissionId = '1234'; + const submissionId = '1234'; function testValidator() { - return {testValidator: {valid: true}}; + return { testValidator: { valid: true } }; } function testAsyncValidator() { @@ -71,10 +71,10 @@ describe('FormBuilderService test suite', () => { TestBed.configureTestingModule({ imports: [ReactiveFormsModule], providers: [ - {provide: FormBuilderService, useClass: FormBuilderService}, - {provide: DynamicFormValidationService, useValue: {}}, - {provide: NG_VALIDATORS, useValue: testValidator, multi: true}, - {provide: NG_ASYNC_VALIDATORS, useValue: testAsyncValidator, multi: true} + { provide: FormBuilderService, useClass: FormBuilderService }, + { provide: DynamicFormValidationService, useValue: {} }, + { provide: NG_VALIDATORS, useValue: testValidator, multi: true }, + { provide: NG_ASYNC_VALIDATORS, useValue: testAsyncValidator, multi: true } ] }); @@ -148,9 +148,9 @@ describe('FormBuilderService test suite', () => { } ), - new DynamicTextAreaModel({id: 'testTextArea'}), + new DynamicTextAreaModel({ id: 'testTextArea' }), - new DynamicCheckboxModel({id: 'testCheckbox'}), + new DynamicCheckboxModel({ id: 'testCheckbox' }), new DynamicFormArrayModel( { @@ -158,10 +158,10 @@ describe('FormBuilderService test suite', () => { initialCount: 5, groupFactory: () => { return [ - new DynamicInputModel({id: 'testFormArrayGroupInput'}), + new DynamicInputModel({ id: 'testFormArrayGroupInput' }), new DynamicFormArrayModel({ id: 'testNestedFormArray', groupFactory: () => [ - new DynamicInputModel({id: 'testNestedFormArrayGroupInput'}) + new DynamicInputModel({ id: 'testNestedFormArrayGroupInput' }) ] }) ]; @@ -173,37 +173,37 @@ describe('FormBuilderService test suite', () => { { id: 'testFormGroup', group: [ - new DynamicInputModel({id: 'nestedTestInput'}), - new DynamicTextAreaModel({id: 'nestedTestTextArea'}) + new DynamicInputModel({ id: 'nestedTestInput' }), + new DynamicTextAreaModel({ id: 'nestedTestTextArea' }) ] } ), - new DynamicSliderModel({id: 'testSlider'}), + new DynamicSliderModel({ id: 'testSlider' }), - new DynamicSwitchModel({id: 'testSwitch'}), + new DynamicSwitchModel({ id: 'testSwitch' }), - new DynamicDatePickerModel({id: 'testDatepicker', value: new Date()}), + new DynamicDatePickerModel({ id: 'testDatepicker', value: new Date() }), - new DynamicFileUploadModel({id: 'testFileUpload'}), + new DynamicFileUploadModel({ id: 'testFileUpload' }), - new DynamicEditorModel({id: 'testEditor'}), + new DynamicEditorModel({ id: 'testEditor' }), - new DynamicTimePickerModel({id: 'testTimePicker'}), + new DynamicTimePickerModel({ id: 'testTimePicker' }), - new DynamicRatingModel({id: 'testRating'}), + new DynamicRatingModel({ id: 'testRating' }), - new DynamicColorPickerModel({id: 'testColorPicker'}), + new DynamicColorPickerModel({ id: 'testColorPicker' }), - new DynamicTypeaheadModel({id: 'testTypeahead', repeatable: false, metadataFields: [], submissionId: '1234'}), + new DynamicTypeaheadModel({ id: 'testTypeahead', repeatable: false, metadataFields: [], submissionId: '1234', hasSelectableMetadata: false }), - new DynamicScrollableDropdownModel({id: 'testScrollableDropdown', authorityOptions: authorityOptions, repeatable: false, metadataFields: [], submissionId: '1234'}), + new DynamicScrollableDropdownModel({ id: 'testScrollableDropdown', authorityOptions: authorityOptions, repeatable: false, metadataFields: [], submissionId: '1234', hasSelectableMetadata: false }), - new DynamicTagModel({id: 'testTag', repeatable: false, metadataFields: [], submissionId: '1234'}), + new DynamicTagModel({ id: 'testTag', repeatable: false, metadataFields: [], submissionId: '1234', hasSelectableMetadata: false }), - new DynamicListCheckboxGroupModel({id: 'testCheckboxList', authorityOptions: authorityOptions, repeatable: true}), + new DynamicListCheckboxGroupModel({ id: 'testCheckboxList', authorityOptions: authorityOptions, repeatable: true }), - new DynamicListRadioGroupModel({id: 'testRadioList', authorityOptions: authorityOptions, repeatable: false}), + new DynamicListRadioGroupModel({ id: 'testRadioList', authorityOptions: authorityOptions, repeatable: false }), new DynamicRelationGroupModel({ submissionId, @@ -211,7 +211,7 @@ describe('FormBuilderService test suite', () => { formConfiguration: [{ fields: [{ hints: 'Enter the name of the author.', - input: {type: 'onebox'}, + input: { type: 'onebox' }, label: 'Authors', languageCodes: [], mandatory: 'true', @@ -221,12 +221,12 @@ describe('FormBuilderService test suite', () => { authority: 'RPAuthority', closed: false, metadata: 'dc.contributor.author' - }], + }] } as FormFieldModel] } as FormRowModel, { fields: [{ hints: 'Enter the affiliation of the author.', - input: {type: 'onebox'}, + input: { type: 'onebox' }, label: 'Affiliation', languageCodes: [], mandatory: 'false', @@ -244,29 +244,35 @@ describe('FormBuilderService test suite', () => { scopeUUID: '', submissionScope: '', repeatable: false, - metadataFields: [] + metadataFields: [], + hasSelectableMetadata: true }), - new DynamicDsDatePickerModel({id: 'testDate'}), + new DynamicDsDatePickerModel({ id: 'testDate' }), - new DynamicLookupModel({id: 'testLookup', repeatable: false, metadataFields: [], submissionId: '1234'}), + new DynamicLookupModel({ id: 'testLookup', repeatable: false, metadataFields: [], submissionId: '1234', hasSelectableMetadata: true }), - new DynamicLookupNameModel({id: 'testLookupName', repeatable: false, metadataFields: [], submissionId: '1234'}), + new DynamicLookupNameModel({ id: 'testLookupName', repeatable: false, metadataFields: [], submissionId: '1234', hasSelectableMetadata: true }), - new DynamicQualdropModel({id: 'testCombobox', readOnly: false, required: false}), + new DynamicQualdropModel({ id: 'testCombobox', readOnly: false, required: false }), new DynamicRowArrayModel( { id: 'testFormRowArray', initialCount: 5, notRepeatable: false, + relationshipConfig: undefined, + submissionId: '1234', groupFactory: () => { return [ - new DynamicInputModel({id: 'testFormRowArrayGroupInput'}) + new DynamicInputModel({ id: 'testFormRowArrayGroupInput' }) ]; }, - required: false - } + required: false, + metadataKey: 'dc.contributor.author', + metadataFields: ['dc.contributor.author'], + hasSelectableMetadata: true + }, ), ]; @@ -628,7 +634,7 @@ describe('FormBuilderService test suite', () => { it('should throw when unknown DynamicFormControlModel id is specified in JSON', () => { - expect(() => service.fromJSON([{id: 'test'}])) + expect(() => service.fromJSON([{ id: 'test' }])) .toThrow(new Error(`unknown form control model type defined on JSON object with id "test"`)); }); @@ -648,8 +654,8 @@ describe('FormBuilderService test suite', () => { const formGroup = service.createFormGroup(testModel); const nestedFormGroup = formGroup.controls.testFormGroup as FormGroup; const nestedFormGroupModel = testModel[7] as DynamicFormGroupModel; - const newModel1 = new DynamicInputModel({id: 'newInput1'}); - const newModel2 = new DynamicInputModel({id: 'newInput2'}); + const newModel1 = new DynamicInputModel({ id: 'newInput1' }); + const newModel2 = new DynamicInputModel({ id: 'newInput2' }); service.addFormGroupControl(formGroup, testModel, newModel1); service.addFormGroupControl(nestedFormGroup, nestedFormGroupModel, newModel2); @@ -666,8 +672,8 @@ describe('FormBuilderService test suite', () => { const formGroup = service.createFormGroup(testModel); const nestedFormGroup = formGroup.controls.testFormGroup as FormGroup; const nestedFormGroupModel = testModel[7] as DynamicFormGroupModel; - const newModel1 = new DynamicInputModel({id: 'newInput1'}); - const newModel2 = new DynamicInputModel({id: 'newInput2'}); + const newModel1 = new DynamicInputModel({ id: 'newInput1' }); + const newModel2 = new DynamicInputModel({ id: 'newInput2' }); service.insertFormGroupControl(4, formGroup, testModel, newModel1); service.insertFormGroupControl(0, nestedFormGroup, nestedFormGroupModel, newModel2); diff --git a/src/app/shared/form/builder/form-builder.service.ts b/src/app/shared/form/builder/form-builder.service.ts index 7e657d97d4..1ca0a2748c 100644 --- a/src/app/shared/form/builder/form-builder.service.ts +++ b/src/app/shared/form/builder/form-builder.service.ts @@ -226,7 +226,7 @@ export class FormBuilderService extends DynamicFormService { } hasArrayGroupValue(model: DynamicFormControlModel): boolean { - return model && (this.isListGroup(model) || model.type === DYNAMIC_FORM_CONTROL_TYPE_TAG); + return model && (this.isListGroup(model) || model.type === DYNAMIC_FORM_CONTROL_TYPE_TAG || model.type === DYNAMIC_FORM_CONTROL_TYPE_ARRAY); } hasMappedGroupValue(model: DynamicFormControlModel): boolean { @@ -283,11 +283,16 @@ export class FormBuilderService extends DynamicFormService { return isNotEmpty(fieldModel) ? formGroup.get(this.getPath(fieldModel)) : null; } + /** + * Note (discovered while debugging) this is not the ID as used in the form, + * but the first part of the path needed in a patch operation: + * e.g. add foo/0 -> the id is 'foo' + */ getId(model: DynamicPathable): string { let tempModel: DynamicFormControlModel; if (this.isArrayGroup(model as DynamicFormControlModel)) { - return model.index.toString(); + return hasValue((model as any).metadataKey) ? (model as any).metadataKey : model.index.toString(); } else if (this.isModelInCustomGroup(model as DynamicFormControlModel)) { tempModel = (model as any).parent; } else { diff --git a/src/app/shared/form/builder/models/form-field-metadata-value.model.ts b/src/app/shared/form/builder/models/form-field-metadata-value.model.ts index 45489e3618..c0bdb338e3 100644 --- a/src/app/shared/form/builder/models/form-field-metadata-value.model.ts +++ b/src/app/shared/form/builder/models/form-field-metadata-value.model.ts @@ -1,7 +1,7 @@ -import { isEmpty, isNotEmpty, isNotNull } from '../../../empty.util'; +import { hasValue, isEmpty, isNotEmpty, isNotNull } from '../../../empty.util'; import { ConfidenceType } from '../../../../core/integration/models/confidence-type'; import { PLACEHOLDER_PARENT_METADATA } from '../ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model'; -import { MetadataValueInterface } from '../../../../core/shared/metadata.models'; +import { MetadataValueInterface, VIRTUAL_METADATA_PREFIX } from '../../../../core/shared/metadata.models'; export interface OtherInformation { [name: string]: string @@ -64,4 +64,12 @@ export class FormFieldMetadataValueObject implements MetadataValueInterface { hasPlaceholder() { return this.hasValue() && this.value === PLACEHOLDER_PARENT_METADATA; } + + get isVirtual(): boolean { + return hasValue(this.authority) && this.authority.startsWith(VIRTUAL_METADATA_PREFIX); + } + + toString() { + return this.display || this.value; + } } diff --git a/src/app/shared/form/builder/models/relationship-options.model.ts b/src/app/shared/form/builder/models/relationship-options.model.ts index f1d3d0ae7a..031f468f25 100644 --- a/src/app/shared/form/builder/models/relationship-options.model.ts +++ b/src/app/shared/form/builder/models/relationship-options.model.ts @@ -1,4 +1,4 @@ -const RELATION_METADATA_PREFIX = 'relation.' +const RELATION_METADATA_PREFIX = 'relation.'; /** * The submission options for fields that can represent relationships @@ -7,7 +7,7 @@ export class RelationshipOptions { relationshipType: string; filter: string; searchConfiguration: string; - nameVariants: boolean; + nameVariants: string; get metadataField() { return RELATION_METADATA_PREFIX + this.relationshipType diff --git a/src/app/shared/form/builder/parsers/concat-field-parser.ts b/src/app/shared/form/builder/parsers/concat-field-parser.ts index 33a92c726d..09a3f53c58 100644 --- a/src/app/shared/form/builder/parsers/concat-field-parser.ts +++ b/src/app/shared/form/builder/parsers/concat-field-parser.ts @@ -13,7 +13,7 @@ import { DynamicConcatModel, DynamicConcatModelConfig } from '../ds-dynamic-form-ui/models/ds-dynamic-concat.model'; -import { isNotEmpty } from '../../../empty.util'; +import { hasNoValue, hasValue, isNotEmpty } from '../../../empty.util'; import { ParserOptions } from './parser-options'; import { CONFIG_DATA, @@ -53,14 +53,18 @@ export class ConcatFieldParser extends FieldParser { }; const groupId = id.replace(/\./g, '_') + CONCAT_GROUP_SUFFIX; - const concatGroup: DynamicConcatModelConfig = this.initModel(groupId, label, false); + const concatGroup: DynamicConcatModelConfig = this.initModel(groupId, label, false, true); concatGroup.group = []; concatGroup.separator = this.separator; const input1ModelConfig: DynamicInputModelConfig = this.initModel(id + CONCAT_FIRST_INPUT_SUFFIX, false, false); const input2ModelConfig: DynamicInputModelConfig = this.initModel(id + CONCAT_SECOND_INPUT_SUFFIX, false, false); - input2ModelConfig.hint = ' '; + + if (hasNoValue(concatGroup.hint) && hasValue(input1ModelConfig.hint) && hasNoValue(input2ModelConfig.hint)) { + concatGroup.hint = input1ModelConfig.hint; + input1ModelConfig.hint = undefined; + } if (this.configData.mandatory) { concatGroup.required = true; diff --git a/src/app/shared/form/builder/parsers/disabled-field-parser.spec.ts b/src/app/shared/form/builder/parsers/disabled-field-parser.spec.ts index c885b737c2..9cd0421ea6 100644 --- a/src/app/shared/form/builder/parsers/disabled-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/disabled-field-parser.spec.ts @@ -51,7 +51,7 @@ describe('DisabledFieldParser test suite', () => { it('should set init value properly', () => { initFormValues = { description: [ - new FormFieldMetadataValueObject('test description'), + 'test description', ], }; const expectedValue ='test description'; @@ -59,7 +59,7 @@ describe('DisabledFieldParser test suite', () => { const parser = new DisabledFieldParser(submissionId, field, initFormValues, parserOptions); const fieldModel = parser.parse(); - expect(fieldModel.value).toEqual(expectedValue); + expect(fieldModel.value.value).toEqual(expectedValue); }); }); diff --git a/src/app/shared/form/builder/parsers/disabled-field-parser.ts b/src/app/shared/form/builder/parsers/disabled-field-parser.ts index 14d7051466..330c288fe9 100644 --- a/src/app/shared/form/builder/parsers/disabled-field-parser.ts +++ b/src/app/shared/form/builder/parsers/disabled-field-parser.ts @@ -9,7 +9,7 @@ export class DisabledFieldParser extends FieldParser { public modelFactory(fieldValue?: FormFieldMetadataValueObject | any, label?: boolean): any { const emptyModelConfig: DsDynamicDisabledModelConfig = this.initModel(null, label); - this.setValues(emptyModelConfig, fieldValue); + this.setValues(emptyModelConfig, fieldValue, true); return new DynamicDisabledModel(emptyModelConfig) } } diff --git a/src/app/shared/form/builder/parsers/field-parser.ts b/src/app/shared/form/builder/parsers/field-parser.ts index f218d442e1..6fafe9cbc1 100644 --- a/src/app/shared/form/builder/parsers/field-parser.ts +++ b/src/app/shared/form/builder/parsers/field-parser.ts @@ -1,13 +1,10 @@ import { Inject, InjectionToken } from '@angular/core'; -import { hasValue, isNotEmpty, isNotNull, isNotUndefined, isEmpty } from '../../../empty.util'; +import { hasValue, isNotEmpty, isNotNull, isNotUndefined } from '../../../empty.util'; import { FormFieldModel } from '../models/form-field.model'; import { uniqueId } from 'lodash'; import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; -import { - DynamicRowArrayModel, - DynamicRowArrayModelConfig -} from '../ds-dynamic-form-ui/models/ds-dynamic-row-array-model'; +import { DynamicRowArrayModel, DynamicRowArrayModelConfig } from '../ds-dynamic-form-ui/models/ds-dynamic-row-array-model'; import { DsDynamicInputModel, DsDynamicInputModelConfig } from '../ds-dynamic-form-ui/models/ds-dynamic-input.model'; import { DynamicFormControlLayout } from '@ng-dynamic-forms/core'; import { setLayout } from './parser.utils'; @@ -39,36 +36,51 @@ export abstract class FieldParser { && (this.configData.input.type !== 'list') && (this.configData.input.type !== 'tag') && (this.configData.input.type !== 'group') - && isEmpty(this.configData.selectableRelationship) ) { let arrayCounter = 0; let fieldArrayCounter = 0; + let metadataKey; + + if (Array.isArray(this.configData.selectableMetadata) && this.configData.selectableMetadata.length === 1) { + metadataKey = this.configData.selectableMetadata[0].metadata; + } const config = { id: uniqueId() + '_array', label: this.configData.label, initialCount: this.getInitArrayIndex(), notRepeatable: !this.configData.repeatable, - required: JSON.parse( this.configData.mandatory), + relationshipConfig: this.configData.selectableRelationship, + required: JSON.parse(this.configData.mandatory), + submissionId: this.submissionId, + metadataKey, + metadataFields: this.getAllFieldIds(), + hasSelectableMetadata: isNotEmpty(this.configData.selectableMetadata), groupFactory: () => { let model; if ((arrayCounter === 0)) { model = this.modelFactory(); arrayCounter++; } else { - const fieldArrayOfValueLenght = this.getInitValueCount(arrayCounter - 1); + const fieldArrayOfValueLength = this.getInitValueCount(arrayCounter - 1); let fieldValue = null; - if (fieldArrayOfValueLenght > 0) { - fieldValue = this.getInitFieldValue(arrayCounter - 1, fieldArrayCounter++); - if (fieldArrayCounter === fieldArrayOfValueLenght) { + if (fieldArrayOfValueLength > 0) { + if (fieldArrayCounter === 0) { + fieldValue = ''; + } else { + fieldValue = this.getInitFieldValue(arrayCounter - 1, fieldArrayCounter - 1); + } + fieldArrayCounter++; + if (fieldArrayCounter === fieldArrayOfValueLength + 1) { fieldArrayCounter = 0; arrayCounter++; } } model = this.modelFactory(fieldValue, false); + model.id = `${model.id}_${fieldArrayCounter}`; } setLayout(model, 'element', 'host', 'col'); - if (model.hasLanguages) { + if (model.hasLanguages || isNotEmpty(model.relationship)) { setLayout(model, 'grid', 'control', 'col'); } return [model]; @@ -85,6 +97,7 @@ export abstract class FieldParser { } else { const model = this.modelFactory(this.getInitFieldValue()); + model.submissionId = this.submissionId; if (model.hasLanguages || isNotEmpty(model.relationship)) { setLayout(model, 'grid', 'control', 'col'); } @@ -147,9 +160,10 @@ export abstract class FieldParser { } protected getInitArrayIndex() { + let fieldCount = 0; const fieldIds: any = this.getAllFieldIds(); if (isNotEmpty(this.initFormValues) && isNotNull(fieldIds) && fieldIds.length === 1 && this.initFormValues.hasOwnProperty(fieldIds)) { - return this.initFormValues[fieldIds].length; + fieldCount = this.initFormValues[fieldIds].filter((value) => hasValue(value) && hasValue(value.value)).length; } else if (isNotEmpty(this.initFormValues) && isNotNull(fieldIds) && fieldIds.length > 1) { let counter = 0; fieldIds.forEach((id) => { @@ -157,10 +171,9 @@ export abstract class FieldParser { counter = counter + this.initFormValues[id].length; } }); - return (counter === 0) ? 1 : counter; - } else { - return 1; + fieldCount = counter; } + return (fieldCount === 0) ? 1 : fieldCount + 1 } protected getFieldId(): string { @@ -178,11 +191,11 @@ export abstract class FieldParser { return ids; } } else { - return [this.configData.selectableRelationship.relationshipType]; + return ['relation.' + this.configData.selectableRelationship.relationshipType]; } } - protected initModel(id?: string, label = true, setErrors = true) { + protected initModel(id?: string, label = true, setErrors = true, hint = true) { const controlModel = Object.create(null); @@ -202,16 +215,17 @@ export abstract class FieldParser { controlModel.relationship = Object.assign(new RelationshipOptions(), this.configData.selectableRelationship); } controlModel.repeatable = this.configData.repeatable; - controlModel.metadataFields = isNotEmpty(this.configData.selectableMetadata) ? this.configData.selectableMetadata.map((metadataObject) => metadataObject.metadata) : []; + controlModel.metadataFields = this.getAllFieldIds() || []; + controlModel.hasSelectableMetadata = isNotEmpty(this.configData.selectableMetadata); controlModel.submissionId = this.submissionId; // Set label this.setLabel(controlModel, label); - + if (hint) { + controlModel.hint = this.configData.hints; + } controlModel.placeholder = this.configData.label; - controlModel.hint = this.configData.hints; - if (this.configData.mandatory && setErrors) { this.markAsRequired(controlModel); } @@ -247,7 +261,6 @@ export abstract class FieldParser { {}, controlModel.errorMessages, { pattern: 'error.validation.pattern' }); - } protected markAsRequired(controlModel) { @@ -302,7 +315,9 @@ export abstract class FieldParser { } if (typeof fieldValue === 'object') { + modelConfig.metadataValue = fieldValue; modelConfig.language = fieldValue.language; + modelConfig.place = fieldValue.place; if (forceValueAsObj) { modelConfig.value = fieldValue; } else { @@ -320,7 +335,6 @@ export abstract class FieldParser { } } } - return modelConfig; } diff --git a/src/app/shared/form/builder/parsers/name-field-parser.spec.ts b/src/app/shared/form/builder/parsers/name-field-parser.spec.ts index 1b0c637030..8b75856256 100644 --- a/src/app/shared/form/builder/parsers/name-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/name-field-parser.spec.ts @@ -95,7 +95,7 @@ describe('NameFieldParser test suite', () => { initFormValues = { name: [new FormFieldMetadataValueObject('test, name')], }; - const expectedValue = new FormFieldMetadataValueObject('test, name'); + const expectedValue = new FormFieldMetadataValueObject('test, name', undefined, undefined, 'test'); const parser = new NameFieldParser(submissionId, field1, initFormValues, parserOptions); diff --git a/src/app/shared/form/builder/parsers/series-field-parser.spec.ts b/src/app/shared/form/builder/parsers/series-field-parser.spec.ts index ceb4e96320..4e34acb401 100644 --- a/src/app/shared/form/builder/parsers/series-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/series-field-parser.spec.ts @@ -58,7 +58,7 @@ describe('SeriesFieldParser test suite', () => { initFormValues = { series: [new FormFieldMetadataValueObject('test; series')], }; - const expectedValue = new FormFieldMetadataValueObject('test; series'); + const expectedValue = new FormFieldMetadataValueObject('test; series', undefined, undefined, 'test'); const parser = new SeriesFieldParser(submissionId, field, initFormValues, parserOptions); diff --git a/src/app/shared/form/form.component.html b/src/app/shared/form/form.component.html index 20fb942380..32ccc3b696 100644 --- a/src/app/shared/form/form.component.html +++ b/src/app/shared/form/form.component.html @@ -1,65 +1,57 @@
    -
    + - + + + +
    +
    + +
    +
    - + +
    +
    + +
    +
    +
    - -
    -
    - - -
    -
    +
    - -
    -
    - -
    -
    - - -
    - - +
    -
    -
    - -
    - - +
    +
    +
    + + +
    +
    -
    -
    -
    +
    diff --git a/src/app/shared/form/form.component.scss b/src/app/shared/form/form.component.scss index acdeb792ca..01cf09576f 100644 --- a/src/app/shared/form/form.component.scss +++ b/src/app/shared/form/form.component.scss @@ -42,8 +42,3 @@ .right-addon input { padding-right: $spacer * 2.25; } - -.ds-form-qualdrop-hint { - top: -$spacer; - position: relative; -} diff --git a/src/app/shared/form/form.component.spec.ts b/src/app/shared/form/form.component.spec.ts index 6a8a1229d4..f7a0564191 100644 --- a/src/app/shared/form/form.component.spec.ts +++ b/src/app/shared/form/form.component.spec.ts @@ -92,7 +92,6 @@ function init() { groupFactory: () => { return [ new DynamicInputModel({ - id: 'bootstrapArrayGroupInput', placeholder: 'example array group input', readOnly: false @@ -362,7 +361,7 @@ describe('FormComponent test suite', () => { spyOn((formComp as any).formService, 'validateAllFormFields'); - form.next(formState.testForm) + form.next(formState.testForm); formFixture.detectChanges(); formComp.onSubmit(); @@ -418,7 +417,7 @@ describe('FormComponent test suite', () => { })); it('should dispatch FormChangeAction when an item has been removed from an array', inject([FormBuilderService], (service: FormBuilderService) => { - formComp.removeItem(new Event('click'), formComp.formModel[0] as DynamicFormArrayModel, 0); + formComp.removeItem(new Event('click'), formComp.formModel[0] as DynamicFormArrayModel, 1); expect(store.dispatch).toHaveBeenCalledWith(new FormChangeAction('testFormArray', service.getValueFromModel(formComp.formModel))); })); @@ -426,7 +425,7 @@ describe('FormComponent test suite', () => { it('should emit removeArrayItem Event when an item has been removed from an array', inject([FormBuilderService], (service: FormBuilderService) => { spyOn(formComp.removeArrayItem, 'emit'); - formComp.removeItem(new Event('click'), formComp.formModel[0] as DynamicFormArrayModel, 0); + formComp.removeItem(new Event('click'), formComp.formModel[0] as DynamicFormArrayModel, 1); expect(formComp.removeArrayItem.emit).toHaveBeenCalled(); })); diff --git a/src/app/shared/form/form.component.ts b/src/app/shared/form/form.component.ts index def61cb5b2..dee06c29b2 100644 --- a/src/app/shared/form/form.component.ts +++ b/src/app/shared/form/form.component.ts @@ -1,28 +1,18 @@ import { distinctUntilChanged, filter, map } from 'rxjs/operators'; -import { - ChangeDetectorRef, - Component, - EventEmitter, - Input, - OnDestroy, - OnInit, - Output, ViewEncapsulation -} from '@angular/core'; +import { ChangeDetectorRef, Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; import { AbstractControl, FormArray, FormControl, FormGroup } from '@angular/forms'; -import { - DynamicFormArrayModel, - DynamicFormControlEvent, - DynamicFormControlModel, - DynamicFormGroupModel, - DynamicFormLayout, -} from '@ng-dynamic-forms/core'; +import { DynamicFormArrayModel, DynamicFormControlEvent, DynamicFormControlModel, DynamicFormGroupModel, DynamicFormLayout, } from '@ng-dynamic-forms/core'; import { findIndex } from 'lodash'; import { FormBuilderService } from './builder/form-builder.service'; import { Observable, Subscription } from 'rxjs'; import { hasValue, isNotEmpty, isNotNull, isNull } from '../empty.util'; import { FormService } from './form.service'; import { FormEntry, FormError } from './form.reducer'; +import { DYNAMIC_FORM_CONTROL_TYPE_SCROLLABLE_DROPDOWN } from './builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.model'; +import { QUALDROP_GROUP_SUFFIX } from './builder/ds-dynamic-form-ui/models/ds-dynamic-qualdrop.model'; + +const QUALDROP_GROUP_REGEX = new RegExp(`${QUALDROP_GROUP_SUFFIX}_\\d+$`); /** * The default form component. @@ -304,15 +294,49 @@ export class FormComponent implements OnDestroy, OnInit { removeItem($event, arrayContext: DynamicFormArrayModel, index: number): void { const formArrayControl = this.formGroup.get(this.formBuilderService.getPath(arrayContext)) as FormArray; - this.removeArrayItem.emit(this.getEvent($event, arrayContext, index, 'remove')); + this.removeArrayItem.emit(this.getEvent($event, arrayContext, index - 1, 'remove')); this.formBuilderService.removeFormArrayGroup(index, formArrayControl, arrayContext); this.formService.changeForm(this.formId, this.formModel); } insertItem($event, arrayContext: DynamicFormArrayModel, index: number): void { const formArrayControl = this.formGroup.get(this.formBuilderService.getPath(arrayContext)) as FormArray; - this.formBuilderService.insertFormArrayGroup(index, formArrayControl, arrayContext); - this.addArrayItem.emit(this.getEvent($event, arrayContext, index, 'add')); + + // First emit the new value so it can be sent to the server + const value = formArrayControl.controls[0].value; + const event = this.getEvent($event, arrayContext, 0, 'add'); + this.addArrayItem.emit(event); + this.change.emit(event); + + // Next: update the UI so the user sees the changes + // without having to wait for the server's reply + + // add an empty new field at the bottom + this.formBuilderService.addFormArrayGroup(formArrayControl, arrayContext); + + // set that field to the new value + const model = arrayContext.groups[arrayContext.groups.length - 1].group[0] as any; + if (model.type === DYNAMIC_FORM_CONTROL_TYPE_SCROLLABLE_DROPDOWN) { + model.value = Object.values(value)[0]; + } else if (this.formBuilderService.isQualdropGroup(model)) { + const ctrl = formArrayControl.controls[formArrayControl.length - 1]; + const ctrlKey = Object.keys(ctrl.value).find((key: string) => isNotEmpty(key.match(QUALDROP_GROUP_REGEX))); + const valueKey = Object.keys(value).find((key: string) => isNotEmpty(key.match(QUALDROP_GROUP_REGEX))); + if (ctrlKey !== valueKey) { + Object.defineProperty(value, ctrlKey, Object.getOwnPropertyDescriptor(value, valueKey)); + delete value[valueKey]; + } + ctrl.setValue(value); + } else { + formArrayControl.controls[formArrayControl.length - 1].setValue(value); + } + + // Clear the topmost field by removing the filled out version and inserting a new, empty version. + // Doing it this way ensures an empty value of the correct type is added without a bunch of ifs here + this.formBuilderService.removeFormArrayGroup(0, formArrayControl, arrayContext); + this.formBuilderService.insertFormArrayGroup(0, formArrayControl, arrayContext); + + // Tell the formService that it should rerender. this.formService.changeForm(this.formId, this.formModel); } diff --git a/src/app/shared/lang-switch/lang-switch.component.spec.ts b/src/app/shared/lang-switch/lang-switch.component.spec.ts index 4e24ba4d04..9b8ee2e343 100644 --- a/src/app/shared/lang-switch/lang-switch.component.spec.ts +++ b/src/app/shared/lang-switch/lang-switch.component.spec.ts @@ -1,13 +1,14 @@ -import { LangSwitchComponent } from './lang-switch.component'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; + +import { Observable, of } from 'rxjs'; import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; + +import { LangSwitchComponent } from './lang-switch.component'; import { LangConfig } from '../../../config/lang-config.interface'; -import { Observable, of } from 'rxjs'; -import { By } from '@angular/platform-browser'; -import { CookieServiceMock } from '../mocks/cookie.service.mock'; -import { CookieService } from '../../core/services/cookie.service'; +import { LocaleService } from '../../core/locale/locale.service'; // This test is completely independent from any message catalogs or keys in the codebase // The translation module is instantiated with these bogus messages that we aren't using anyway. @@ -31,13 +32,16 @@ class CustomLoader implements TranslateLoader { /* tslint:enable:quotemark */ /* tslint:enable:object-literal-key-quotes */ -let cookie: CookieService; +let localService: any; describe('LangSwitchComponent', () => { - beforeEach(() => { - cookie = Object.assign(new CookieServiceMock()); - }); + function getMockLocaleService(): LocaleService { + return jasmine.createSpyObj('LocaleService', { + setCurrentLanguageCode: jasmine.createSpy('setCurrentLanguageCode'), + refreshAfterChangeLanguage: jasmine.createSpy('refreshAfterChangeLanguage') + }) + } describe('with English and Deutsch activated, English as default', () => { let component: LangSwitchComponent; @@ -72,7 +76,7 @@ describe('LangSwitchComponent', () => { schemas: [NO_ERRORS_SCHEMA], providers: [ TranslateService, - { provide: CookieService, useValue: cookie } + { provide: LocaleService, useValue: getMockLocaleService() }, ] }).compileComponents() .then(() => { @@ -82,6 +86,7 @@ describe('LangSwitchComponent', () => { translate.use('en'); http = TestBed.get(HttpTestingController); fixture = TestBed.createComponent(LangSwitchComponent); + localService = TestBed.get(LocaleService); component = fixture.componentInstance; de = fixture.debugElement; langSwitchElement = de.nativeElement; @@ -110,19 +115,16 @@ describe('LangSwitchComponent', () => { describe('when selecting a language', () => { beforeEach(() => { spyOn(translate, 'use'); - spyOn(cookie, 'set'); const langItem = fixture.debugElement.query(By.css('.dropdown-item')).nativeElement; langItem.click(); fixture.detectChanges(); }); - it('should translate the app', () => { - expect(translate.use).toHaveBeenCalled(); + it('should translate the app and set the client\'s language cookie', () => { + expect(localService.setCurrentLanguageCode).toHaveBeenCalled(); + expect(localService.refreshAfterChangeLanguage).toHaveBeenCalled(); }); - it('should set the client\'s language cookie', () => { - expect(cookie.set).toHaveBeenCalled(); - }); }); }); @@ -160,7 +162,7 @@ describe('LangSwitchComponent', () => { schemas: [NO_ERRORS_SCHEMA], providers: [ TranslateService, - { provide: CookieService, useValue: cookie } + { provide: LocaleService, useValue: getMockLocaleService() } ] }).compileComponents(); translate = TestBed.get(TranslateService); diff --git a/src/app/shared/lang-switch/lang-switch.component.ts b/src/app/shared/lang-switch/lang-switch.component.ts index 433d9038d7..e28b1d4bb7 100644 --- a/src/app/shared/lang-switch/lang-switch.component.ts +++ b/src/app/shared/lang-switch/lang-switch.component.ts @@ -1,9 +1,10 @@ -import {Component, Inject, OnInit} from '@angular/core'; -import {TranslateService} from '@ngx-translate/core'; -import {LangConfig} from '../../../config/lang-config.interface'; -import { LANG_COOKIE } from '../../app.component'; -import { CookieService } from '../../core/services/cookie.service'; +import { Component, Inject, OnInit } from '@angular/core'; + +import { TranslateService } from '@ngx-translate/core'; + +import { LangConfig } from '../../../config/lang-config.interface'; import { environment } from '../../../environments/environment'; +import { LocaleService } from '../../core/locale/locale.service'; @Component({ selector: 'ds-lang-switch', @@ -25,7 +26,7 @@ export class LangSwitchComponent implements OnInit { constructor( public translate: TranslateService, - public cookie: CookieService + private localeService: LocaleService ) { } @@ -53,8 +54,8 @@ export class LangSwitchComponent implements OnInit { * @param lang The language to switch to */ useLang(lang: string) { - this.translate.use(lang); - this.cookie.set(LANG_COOKIE, lang); + this.localeService.setCurrentLanguageCode(lang); + this.localeService.refreshAfterChangeLanguage(); } } diff --git a/src/app/shared/metadata-representation/metadata-representation-loader.component.ts b/src/app/shared/metadata-representation/metadata-representation-loader.component.ts index 86bede6789..c091534f62 100644 --- a/src/app/shared/metadata-representation/metadata-representation-loader.component.ts +++ b/src/app/shared/metadata-representation/metadata-representation-loader.component.ts @@ -5,6 +5,7 @@ import { Context } from '../../core/shared/context.model'; import { GenericConstructor } from '../../core/shared/generic-constructor'; import { MetadataRepresentationListElementComponent } from '../object-list/metadata-representation-list-element/metadata-representation-list-element.component'; import { MetadataRepresentationDirective } from './metadata-representation.directive'; +import { hasValue } from '../empty.util'; @Component({ selector: 'ds-metadata-representation-loader', @@ -15,10 +16,21 @@ import { MetadataRepresentationDirective } from './metadata-representation.direc * Component for determining what component to use depending on the item's relationship type (relationship.type), its metadata representation and, optionally, its context */ export class MetadataRepresentationLoaderComponent implements OnInit { + private componentRefInstance: MetadataRepresentationListElementComponent; + /** * The item or metadata to determine the component for */ - @Input() mdRepresentation: MetadataRepresentation; + private _mdRepresentation: MetadataRepresentation; + get mdRepresentation(): MetadataRepresentation { + return this._mdRepresentation; + } + @Input() set mdRepresentation(nextValue: MetadataRepresentation) { + this._mdRepresentation = nextValue; + if (hasValue(this.componentRefInstance)) { + this.componentRefInstance.metadataRepresentation = nextValue; + } + } /** * The optional context @@ -43,7 +55,8 @@ export class MetadataRepresentationLoaderComponent implements OnInit { viewContainerRef.clear(); const componentRef = viewContainerRef.createComponent(componentFactory); - (componentRef.instance as MetadataRepresentationListElementComponent).metadataRepresentation = this.mdRepresentation; + this.componentRefInstance = componentRef.instance as MetadataRepresentationListElementComponent; + this.componentRefInstance.metadataRepresentation = this.mdRepresentation; } /** diff --git a/src/app/shared/mocks/form-models.mock.ts b/src/app/shared/mocks/form-models.mock.ts index e4f9ec3131..cd6228417b 100644 --- a/src/app/shared/mocks/form-models.mock.ts +++ b/src/app/shared/mocks/form-models.mock.ts @@ -10,7 +10,6 @@ import { AuthorityValue } from '../../core/integration/models/authority.value'; import { FormFieldMetadataValueObject } from '../form/builder/models/form-field-metadata-value.model'; import { DynamicRowGroupModel } from '../form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-group-model'; import { FormRowModel } from '../../core/config/models/config-submission-form.model'; -import { WorkspaceItem } from '../../core/submission/models/workspaceitem.model'; export const qualdropSelectConfig = { name: 'dc.identifier_QUALDROP_METADATA', @@ -56,7 +55,8 @@ export const qualdropInputConfig = { repeatable: false, value: 'test', submissionId: '1234', - metadataFields: [] + metadataFields: [], + hasSelectableMetadata: false }; export const mockQualdropSelectModel = new DynamicSelectModel(qualdropSelectConfig); @@ -76,10 +76,15 @@ const rowArrayQualdropConfig = { id: 'row_QUALDROP_GROUP', initialCount: 1, notRepeatable: true, + relationshipConfig: undefined, groupFactory: () => { return [MockQualdropModel]; }, - required: false + required: false, + submissionId: '1234', + metadataKey: 'dc.some.key', + metadataFields: ['dc.some.key'], + hasSelectableMetadata: false } as DynamicRowArrayModelConfig; export const MockRowArrayQualdropModel: DynamicRowArrayModel = new DynamicRowArrayModel(rowArrayQualdropConfig); @@ -136,7 +141,8 @@ const relationGroupConfig = { 'issue test 2' ], }, - metadataFields: [] + metadataFields: [], + hasSelectableMetadata: false }; export const MockRelationModel: DynamicRelationGroupModel = new DynamicRelationGroupModel(relationGroupConfig); @@ -165,7 +171,8 @@ export const inputWithLanguageAndAuthorityConfig = { id: 'testWithLanguageAndAuthority', }, submissionId: '1234', - metadataFields: [] + metadataFields: [], + hasSelectableMetadata: false }; export const mockInputWithLanguageAndAuthorityModel = new DsDynamicInputModel(inputWithLanguageAndAuthorityConfig); @@ -189,7 +196,8 @@ export const inputWithLanguageConfig = { repeatable: false, value: 'testWithLanguage', submissionId: '1234', - metadataFields: [] + metadataFields: [], + hasSelectableMetadata: false }; export const mockInputWithLanguageModel = new DsDynamicInputModel(inputWithLanguageConfig); @@ -218,7 +226,8 @@ export const inputWithLanguageAndAuthorityArrayConfig = { id: 'testLanguageAndAuthorityArray', }], submissionId: '1234', - metadataFields: [] + metadataFields: [], + hasSelectableMetadata: false }; export const mockInputWithLanguageAndAuthorityArrayModel = new DsDynamicInputModel(inputWithLanguageAndAuthorityArrayConfig); @@ -231,7 +240,8 @@ export const inputWithFormFieldValueConfig = { repeatable: false, value: new FormFieldMetadataValueObject('testWithFormFieldValue'), submissionId: '1234', - metadataFields: [] + metadataFields: [], + hasSelectableMetadata: false }; export const mockInputWithFormFieldValueModel = new DsDynamicInputModel(inputWithFormFieldValueConfig); @@ -244,7 +254,8 @@ export const inputWithAuthorityValueConfig = { repeatable: false, value: Object.assign({}, new AuthorityValue(), { value: 'testWithAuthorityValue', id: 'testWithAuthorityValue', display: 'testWithAuthorityValue' }), submissionId: '1234', - metadataFields: [] + metadataFields: [], + hasSelectableMetadata: false }; export const mockInputWithAuthorityValueModel = new DsDynamicInputModel(inputWithAuthorityValueConfig); @@ -257,7 +268,8 @@ export const inputWithObjectValueConfig = { repeatable: false, value: { value: 'testWithObjectValue', id: 'testWithObjectValue', display: 'testWithObjectValue' }, submissionId: '1234', - metadataFields: [] + metadataFields: [], + hasSelectableMetadata: false }; export const mockInputWithObjectValueModel = new DsDynamicInputModel(inputWithObjectValueConfig); @@ -274,7 +286,8 @@ export const fileFormEditInputConfig = { disabled: false, repeatable: false, submissionId: '1234', - metadataFields: [] + metadataFields: [], + hasSelectableMetadata: false }; export const mockFileFormEditInputModel = new DsDynamicInputModel(fileFormEditInputConfig); diff --git a/src/app/shared/mydspace-actions/workspaceitem/workspaceitem-actions.component.spec.ts b/src/app/shared/mydspace-actions/workspaceitem/workspaceitem-actions.component.spec.ts index 6e7f7bf65b..503af1238a 100644 --- a/src/app/shared/mydspace-actions/workspaceitem/workspaceitem-actions.component.spec.ts +++ b/src/app/shared/mydspace-actions/workspaceitem/workspaceitem-actions.component.spec.ts @@ -148,7 +148,7 @@ describe('WorkspaceitemActionsComponent', () => { it('should display a success notification on delete success', async(() => { spyOn((component as any).modalService, 'open').and.returnValue({result: Promise.resolve('ok')}); - mockDataService.delete.and.returnValue(observableOf(true)); + mockDataService.delete.and.returnValue(observableOf({ isSuccessful: true })); spyOn(component, 'reload'); component.confirmDiscard('ok'); diff --git a/src/app/shared/mydspace-actions/workspaceitem/workspaceitem-actions.component.ts b/src/app/shared/mydspace-actions/workspaceitem/workspaceitem-actions.component.ts index 27512d899e..e28c6cb11d 100644 --- a/src/app/shared/mydspace-actions/workspaceitem/workspaceitem-actions.component.ts +++ b/src/app/shared/mydspace-actions/workspaceitem/workspaceitem-actions.component.ts @@ -11,6 +11,7 @@ import { WorkspaceitemDataService } from '../../../core/submission/workspaceitem import { NotificationsService } from '../../notifications/notifications.service'; import { RequestService } from '../../../core/data/request.service'; import { SearchService } from '../../../core/shared/search/search.service'; +import { RestResponse } from '../../../core/cache/response.models'; /** * This component represents actions related to WorkspaceItem object. @@ -63,9 +64,9 @@ export class WorkspaceitemActionsComponent extends MyDSpaceActionsComponent { + .subscribe((response: RestResponse) => { this.processingDelete$.next(false); - this.handleActionResponse(response); + this.handleActionResponse(response.isSuccessful); }) } } diff --git a/src/app/shared/object-collection/object-collection.component.spec.ts b/src/app/shared/object-collection/object-collection.component.spec.ts index 7ba8328495..4d23600603 100644 --- a/src/app/shared/object-collection/object-collection.component.spec.ts +++ b/src/app/shared/object-collection/object-collection.component.spec.ts @@ -33,8 +33,8 @@ describe('ObjectCollectionComponent', () => { beforeEach(async(() => { fixture = TestBed.createComponent(ObjectCollectionComponent); objectCollectionComponent = fixture.componentInstance; - })); + it('should only show the grid component when the viewmode is set to grid', () => { objectCollectionComponent.currentMode$ = observableOf(ViewMode.GridElement); diff --git a/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.ts b/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.ts index 4e6e206ddd..255f66ac86 100644 --- a/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.ts +++ b/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.ts @@ -46,6 +46,11 @@ export class ListableObjectComponentLoaderComponent implements OnInit { */ @Input() listID: string; + /** + * Whether to show the badge label or not + */ + @Input() showLabel = true; + /** * Directive hook used to place the dynamic child component */ @@ -68,6 +73,9 @@ export class ListableObjectComponentLoaderComponent implements OnInit { (componentRef.instance as any).index = this.index; (componentRef.instance as any).linkType = this.linkType; (componentRef.instance as any).listID = this.listID; + (componentRef.instance as any).showLabel = this.showLabel; + (componentRef.instance as any).context = this.context; + (componentRef.instance as any).viewMode = this.viewMode; } /** diff --git a/src/app/shared/object-collection/shared/object-collection-element/abstract-listable-element.component.ts b/src/app/shared/object-collection/shared/object-collection-element/abstract-listable-element.component.ts index 3602f45ede..402984731c 100644 --- a/src/app/shared/object-collection/shared/object-collection-element/abstract-listable-element.component.ts +++ b/src/app/shared/object-collection/shared/object-collection-element/abstract-listable-element.component.ts @@ -29,6 +29,21 @@ export class AbstractListableElementComponent { */ @Input() index: number; + /** + * Whether to show the badge label or not + */ + @Input() showLabel = true; + + /** + * The context we matched on to get this component + */ + @Input() context: Context; + + /** + * The viewmode we matched on to get this component + */ + @Input() viewMode: ViewMode; + /** * The available link types */ diff --git a/src/app/shared/object-collection/shared/selectable-list-item-control/selectable-list-item-control.component.html b/src/app/shared/object-collection/shared/selectable-list-item-control/selectable-list-item-control.component.html index 92d85d03f4..56a83913a7 100644 --- a/src/app/shared/object-collection/shared/selectable-list-item-control/selectable-list-item-control.component.html +++ b/src/app/shared/object-collection/shared/selectable-list-item-control/selectable-list-item-control.component.html @@ -4,7 +4,7 @@ [id]="'object' + index" [ngModel]="selected$ | async" (ngModelChange)="selectCheckbox($event)"> - + diff --git a/src/app/shared/object-grid/search-result-grid-element/item-search-result/publication/publication-search-result-grid-element.component.html b/src/app/shared/object-grid/search-result-grid-element/item-search-result/publication/publication-search-result-grid-element.component.html index ec0b792e34..3c2d54b003 100644 --- a/src/app/shared/object-grid/search-result-grid-element/item-search-result/publication/publication-search-result-grid-element.component.html +++ b/src/app/shared/object-grid/search-result-grid-element/item-search-result/publication/publication-search-result-grid-element.component.html @@ -15,7 +15,7 @@
    - +

    diff --git a/src/app/shared/object-list/item-list-element/item-types/publication/publication-list-element.component.html b/src/app/shared/object-list/item-list-element/item-types/publication/publication-list-element.component.html index acc3ee4194..dcebcfd56a 100644 --- a/src/app/shared/object-list/item-list-element/item-types/publication/publication-list-element.component.html +++ b/src/app/shared/object-list/item-list-element/item-types/publication/publication-list-element.component.html @@ -1 +1 @@ - \ No newline at end of file + diff --git a/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/publication/publication-search-result-list-element.component.html b/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/publication/publication-search-result-list-element.component.html index 3d2604585d..bd00e4aff1 100644 --- a/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/publication/publication-search-result-list-element.component.html +++ b/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/publication/publication-search-result-list-element.component.html @@ -1,4 +1,4 @@ - + = { diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index cc61a6d868..8729ffbc95 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -191,6 +191,7 @@ import { MissingTranslationHelper } from './translate/missing-translation.helper import { ItemVersionsNoticeComponent } from './item/item-versions/notice/item-versions-notice.component'; import { FileValidator } from './utils/require-file.validator'; import { FileValueAccessorDirective } from './utils/file-value-accessor.directive'; +import { ExistingRelationListElementComponent } from './form/builder/ds-dynamic-form-ui/existing-relation-list-element/existing-relation-list-element.component'; import { ModifyItemOverviewComponent } from '../+item-page/edit-item-page/modify-item-overview/modify-item-overview.component'; import { ClaimedTaskActionsLoaderComponent } from './mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component'; import { ClaimedTaskActionsDirective } from './mydspace-actions/claimed-task/switcher/claimed-task-actions.directive'; @@ -207,6 +208,7 @@ import { GroupSearchBoxComponent } from './resource-policies/form/eperson-group- import { FileDownloadLinkComponent } from './file-download-link/file-download-link.component'; import { CollectionDropdownComponent } from './collection-dropdown/collection-dropdown.component'; import { DsSelectComponent } from './ds-select/ds-select.component'; +import { CurationFormComponent } from '../curation-form/curation-form.component'; const MODULES = [ // Do NOT include UniversalModule, HttpModule, or JsonpModule here @@ -380,6 +382,7 @@ const COMPONENTS = [ ExternalSourceEntryImportModalComponent, ImportableListItemControlComponent, ExistingMetadataListElementComponent, + ExistingRelationListElementComponent, LogInShibbolethComponent, LogInPasswordComponent, LogInContainerComponent, @@ -459,6 +462,7 @@ const ENTRY_COMPONENTS = [ DsDynamicLookupRelationSelectionTabComponent, DsDynamicLookupRelationExternalSourceTabComponent, ExternalSourceEntryImportModalComponent, + ExistingRelationListElementComponent, LogInPasswordComponent, LogInShibbolethComponent, ItemVersionsComponent, @@ -469,6 +473,7 @@ const ENTRY_COMPONENTS = [ ClaimedTaskActionsReturnToPoolComponent, ClaimedTaskActionsEditMetadataComponent, FileDownloadLinkComponent, + CurationFormComponent, ]; const SHARED_ITEM_PAGE_COMPONENTS = [ @@ -526,7 +531,8 @@ const DIRECTIVES = [ ...PIPES, ...COMPONENTS, ...SHARED_ITEM_PAGE_COMPONENTS, - ...DIRECTIVES + ...DIRECTIVES, + CurationFormComponent ], entryComponents: [ ...ENTRY_COMPONENTS diff --git a/src/app/submission/edit/submission-edit.component.html b/src/app/submission/edit/submission-edit.component.html index dcd8d84edc..19bcd6f079 100644 --- a/src/app/submission/edit/submission-edit.component.html +++ b/src/app/submission/edit/submission-edit.component.html @@ -3,5 +3,6 @@ [sections]="sections" [selfUrl]="selfUrl" [submissionDefinition]="submissionDefinition" + [item]="item" [submissionId]="submissionId">
    diff --git a/src/app/submission/edit/submission-edit.component.ts b/src/app/submission/edit/submission-edit.component.ts index 908f473136..12811173dd 100644 --- a/src/app/submission/edit/submission-edit.component.ts +++ b/src/app/submission/edit/submission-edit.component.ts @@ -13,6 +13,7 @@ import { NotificationsService } from '../../shared/notifications/notifications.s import { SubmissionObject } from '../../core/submission/models/submission-object.model'; import { Collection } from '../../core/shared/collection.model'; import { RemoteData } from '../../core/data/remote-data'; +import { Item } from '../../core/shared/item.model'; /** * This component allows to edit an existing workspaceitem/workflowitem. @@ -59,6 +60,7 @@ export class SubmissionEditComponent implements OnDestroy, OnInit { * @type {Array} */ private subs: Subscription[] = []; + public item: Item; /** * Initialize instance variables @@ -96,6 +98,7 @@ export class SubmissionEditComponent implements OnDestroy, OnInit { this.collectionId = (submissionObjectRD.payload.collection as Collection).id; this.selfUrl = submissionObjectRD.payload._links.self.href; this.sections = submissionObjectRD.payload.sections; + this.item = submissionObjectRD.payload.item as Item; this.submissionDefinition = (submissionObjectRD.payload.submissionDefinition as SubmissionDefinitionsModel); this.changeDetectorRef.detectChanges(); } diff --git a/src/app/submission/form/submission-form.component.spec.ts b/src/app/submission/form/submission-form.component.spec.ts index ab5d3ad508..cebc12c987 100644 --- a/src/app/submission/form/submission-form.component.spec.ts +++ b/src/app/submission/form/submission-form.component.spec.ts @@ -20,6 +20,7 @@ import { AuthServiceStub } from '../../shared/testing/auth-service.stub'; import { AuthService } from '../../core/auth/auth.service'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { createTestComponent } from '../../shared/testing/utils.test'; +import { Item } from '../../core/shared/item.model'; describe('SubmissionFormComponent Component', () => { @@ -66,7 +67,7 @@ describe('SubmissionFormComponent Component', () => { `; + [submissionId]="submissionId" [item]="item">`; testFixture = createTestComponent(html, TestComponent) as ComponentFixture; testComp = testFixture.componentInstance; @@ -118,6 +119,7 @@ describe('SubmissionFormComponent Component', () => { comp.submissionDefinition = submissionDefinition; comp.selfUrl = selfUrl; comp.sections = sectionsData; + comp.item = new Item(); submissionServiceStub.getSubmissionObject.and.returnValue(observableOf(submissionState)); submissionServiceStub.getSubmissionSections.and.returnValue(observableOf(sectionsList)); @@ -143,6 +145,7 @@ describe('SubmissionFormComponent Component', () => { selfUrl, submissionDefinition, sectionsData, + comp.item, null); expect(submissionServiceStub.startAutoSave).toHaveBeenCalled(); }); @@ -153,6 +156,7 @@ describe('SubmissionFormComponent Component', () => { comp.submissionDefinition = submissionDefinition; comp.selfUrl = selfUrl; comp.sections = sectionsData; + comp.item = new Item(); comp.onCollectionChange(submissionObjectNew); @@ -168,7 +172,9 @@ describe('SubmissionFormComponent Component', () => { submissionId, selfUrl, submissionObjectNew.submissionDefinition, - submissionObjectNew.sections); + submissionObjectNew.sections, + comp.item, + ); }); it('should update only collection id on collection change when submission definition is not changed', () => { @@ -178,6 +184,7 @@ describe('SubmissionFormComponent Component', () => { comp.submissionDefinition = submissionDefinition; comp.selfUrl = selfUrl; comp.sections = sectionsData; + comp.item = new Item(); comp.onCollectionChange({ collection: { diff --git a/src/app/submission/form/submission-form.component.ts b/src/app/submission/form/submission-form.component.ts index 0b8cfce619..9aed2da792 100644 --- a/src/app/submission/form/submission-form.component.ts +++ b/src/app/submission/form/submission-form.component.ts @@ -14,6 +14,7 @@ import { UploaderOptions } from '../../shared/uploader/uploader-options.model'; import { SubmissionObjectEntry } from '../objects/submission-objects.reducer'; import { SectionDataObject } from '../sections/models/section-data.model'; import { SubmissionService } from '../submission.service'; +import { Item } from '../../core/shared/item.model'; /** * This component represents the submission form. @@ -30,6 +31,7 @@ export class SubmissionFormComponent implements OnChanges, OnDestroy { * @type {string} */ @Input() collectionId: string; + @Input() item: Item; /** * The list of submission's sections @@ -150,6 +152,7 @@ export class SubmissionFormComponent implements OnChanges, OnDestroy { this.selfUrl, this.submissionDefinition, this.sections, + this.item, null); this.changeDetectorRef.detectChanges(); }) @@ -191,7 +194,8 @@ export class SubmissionFormComponent implements OnChanges, OnDestroy { this.submissionId, submissionObject._links.self.href, this.submissionDefinition, - this.sections); + this.sections, + this.item); } else { this.changeDetectorRef.detectChanges(); } diff --git a/src/app/submission/objects/submission-objects.actions.ts b/src/app/submission/objects/submission-objects.actions.ts index 57226fc531..1e3e44aba9 100644 --- a/src/app/submission/objects/submission-objects.actions.ts +++ b/src/app/submission/objects/submission-objects.actions.ts @@ -10,6 +10,7 @@ import { import { SubmissionObject } from '../../core/submission/models/submission-object.model'; import { SubmissionDefinitionsModel } from '../../core/config/models/config-submission-definitions.model'; import { SectionsType } from '../sections/sections-type'; +import { Item } from '../../core/shared/item.model'; /** * For each action type in an action group, make a simple @@ -273,6 +274,7 @@ export class InitSubmissionFormAction implements Action { selfUrl: string; submissionDefinition: SubmissionDefinitionsModel; sections: WorkspaceitemSectionsObject; + item: Item; errors: SubmissionSectionError[]; }; @@ -297,8 +299,9 @@ export class InitSubmissionFormAction implements Action { selfUrl: string, submissionDefinition: SubmissionDefinitionsModel, sections: WorkspaceitemSectionsObject, + item: Item, errors: SubmissionSectionError[]) { - this.payload = { collectionId, submissionId, selfUrl, submissionDefinition, sections, errors }; + this.payload = { collectionId, submissionId, selfUrl, submissionDefinition, sections, item, errors }; } } @@ -378,6 +381,7 @@ export class SaveSubmissionFormSuccessAction implements Action { payload: { submissionId: string; submissionObject: SubmissionObject[]; + notify?: boolean }; /** @@ -388,8 +392,8 @@ export class SaveSubmissionFormSuccessAction implements Action { * @param submissionObject * the submission's Object */ - constructor(submissionId: string, submissionObject: SubmissionObject[]) { - this.payload = { submissionId, submissionObject }; + constructor(submissionId: string, submissionObject: SubmissionObject[], notify?: boolean) { + this.payload = { submissionId, submissionObject, notify }; } } @@ -435,6 +439,7 @@ export class SaveSubmissionSectionFormSuccessAction implements Action { payload: { submissionId: string; submissionObject: SubmissionObject[]; + notify?: boolean }; /** @@ -445,8 +450,8 @@ export class SaveSubmissionSectionFormSuccessAction implements Action { * @param submissionObject * the submission's Object */ - constructor(submissionId: string, submissionObject: SubmissionObject[]) { - this.payload = { submissionId, submissionObject }; + constructor(submissionId: string, submissionObject: SubmissionObject[], notify?: boolean) { + this.payload = { submissionId, submissionObject, notify }; } } @@ -475,6 +480,7 @@ export class ResetSubmissionFormAction implements Action { selfUrl: string; sections: WorkspaceitemSectionsObject; submissionDefinition: SubmissionDefinitionsModel; + item: Item; }; /** @@ -491,8 +497,8 @@ export class ResetSubmissionFormAction implements Action { * @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 }; + constructor(collectionId: string, submissionId: string, selfUrl: string, sections: WorkspaceitemSectionsObject, submissionDefinition: SubmissionDefinitionsModel, item: Item) { + this.payload = { collectionId, submissionId, selfUrl, sections, submissionDefinition, item }; } } diff --git a/src/app/submission/objects/submission-objects.effects.spec.ts b/src/app/submission/objects/submission-objects.effects.spec.ts index 6a8475b2d4..6c2e9eefc6 100644 --- a/src/app/submission/objects/submission-objects.effects.spec.ts +++ b/src/app/submission/objects/submission-objects.effects.spec.ts @@ -47,6 +47,10 @@ import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; import { StoreMock } from '../../shared/testing/store.mock'; import { AppState, storeModuleConfig } from '../../app.reducer'; import parseSectionErrors from '../utils/parseSectionErrors'; +import { Item } from '../../core/shared/item.model'; +import { WorkspaceitemDataService } from '../../core/submission/workspaceitem-data.service'; +import { WorkflowItemDataService } from '../../core/submission/workflowitem-data.service'; +import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; describe('SubmissionObjectEffects test suite', () => { let submissionObjectEffects: SubmissionObjectEffects; @@ -83,6 +87,10 @@ describe('SubmissionObjectEffects test suite', () => { { provide: SectionsService, useClass: SectionsServiceStub }, { provide: SubmissionService, useValue: submissionServiceStub }, { provide: SubmissionJsonPatchOperationsService, useValue: submissionJsonPatchOperationsServiceStub }, + { provide: WorkspaceitemDataService, useValue: {} }, + { provide: WorkflowItemDataService, useValue: {} }, + { provide: WorkflowItemDataService, useValue: {} }, + { provide: HALEndpointService, useValue: {} }, ], }); @@ -101,6 +109,7 @@ describe('SubmissionObjectEffects test suite', () => { selfUrl: selfUrl, submissionDefinition: submissionDefinition, sections: {}, + item: {metadata: {}}, errors: [], } } @@ -153,6 +162,7 @@ describe('SubmissionObjectEffects test suite', () => { selfUrl: selfUrl, submissionDefinition: submissionDefinition, sections: {}, + item: new Item(), errors: [], } } @@ -165,6 +175,7 @@ describe('SubmissionObjectEffects test suite', () => { selfUrl, submissionDefinition, {}, + new Item(), null ) }); diff --git a/src/app/submission/objects/submission-objects.effects.ts b/src/app/submission/objects/submission-objects.effects.ts index a2a3350c6a..2dfed9ee47 100644 --- a/src/app/submission/objects/submission-objects.effects.ts +++ b/src/app/submission/objects/submission-objects.effects.ts @@ -2,10 +2,10 @@ import { Injectable } from '@angular/core'; import { Actions, Effect, ofType } from '@ngrx/effects'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; -import { union } from 'lodash'; +import { isEqual, union } from 'lodash'; -import { from as observableFrom, of as observableOf } from 'rxjs'; -import { catchError, map, mergeMap, switchMap, tap, withLatestFrom } from 'rxjs/operators'; +import { from as observableFrom, Observable, of as observableOf } from 'rxjs'; +import { catchError, filter, map, mergeMap, switchMap, take, tap, withLatestFrom } from 'rxjs/operators'; import { SubmissionObject } from '../../core/submission/models/submission-object.model'; import { WorkflowItem } from '../../core/submission/models/workflowitem.model'; import { WorkspaceitemSectionUploadObject } from '../../core/submission/models/workspaceitem-section-upload.model'; @@ -19,7 +19,6 @@ import { SectionsService } from '../sections/sections.service'; import { SubmissionState } from '../submission.reducers'; import { SubmissionService } from '../submission.service'; import parseSectionErrors from '../utils/parseSectionErrors'; - import { CompleteInitSubmissionFormAction, DepositSubmissionAction, @@ -43,7 +42,12 @@ import { SubmissionObjectActionTypes, UpdateSectionDataAction } from './submission-objects.actions'; -import { SubmissionObjectEntry } from './submission-objects.reducer'; +import { SubmissionObjectEntry, SubmissionSectionObject } from './submission-objects.reducer'; +import { Item } from '../../core/shared/item.model'; +import { RemoteData } from '../../core/data/remote-data'; +import { getFirstSucceededRemoteDataPayload } from '../../core/shared/operators'; +import { SubmissionObjectDataService } from '../../core/submission/submission-object-data.service'; +import { followLink } from '../../shared/utils/follow-link-config.model'; @Injectable() export class SubmissionObjectEffects { @@ -61,7 +65,12 @@ export class SubmissionObjectEffects { const sectionId = selfLink.substr(selfLink.lastIndexOf('/') + 1); const config = sectionDefinition._links.config ? (sectionDefinition._links.config.href || 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); + let sectionData; + if (sectionDefinition.sectionType !== SectionsType.SubmissionForm) { + sectionData = (isNotUndefined(action.payload.sections) && isNotUndefined(action.payload.sections[sectionId])) ? action.payload.sections[sectionId] : Object.create(null); + } else { + sectionData = action.payload.item.metadata; + } const sectionErrors = null; mappedActions.push( new InitSectionAction( @@ -78,7 +87,7 @@ export class SubmissionObjectEffects { ) ) }); - return {action: action, definition: definition, mappedActions: mappedActions}; + return { action: action, definition: definition, mappedActions: mappedActions }; }), mergeMap((result) => { return observableFrom( @@ -99,6 +108,7 @@ export class SubmissionObjectEffects { action.payload.selfUrl, action.payload.submissionDefinition, action.payload.sections, + action.payload.item, null ))); @@ -137,7 +147,7 @@ export class SubmissionObjectEffects { 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); + return this.parseSaveResponse((currentState.submission as SubmissionState).objects[action.payload.submissionId], action.payload.submissionObject, action.payload.submissionId, action.payload.notify); }), mergeMap((actions) => observableFrom(actions))); @@ -159,7 +169,7 @@ export class SubmissionObjectEffects { /** * Show a notification on error */ - @Effect({dispatch: false}) saveError$ = this.actions$.pipe( + @Effect({ dispatch: false }) saveError$ = this.actions$.pipe( ofType(SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM_ERROR, SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM_ERROR), withLatestFrom(this.store$), tap(() => this.notificationsService.error(null, this.translate.get('submission.sections.general.save_error_notice')))); @@ -201,7 +211,7 @@ export class SubmissionObjectEffects { /** * Show a notification on success and redirect to MyDSpace page */ - @Effect({dispatch: false}) saveForLaterSubmissionSuccess$ = this.actions$.pipe( + @Effect({ dispatch: false }) saveForLaterSubmissionSuccess$ = this.actions$.pipe( ofType(SubmissionObjectActionTypes.SAVE_FOR_LATER_SUBMISSION_FORM_SUCCESS), tap(() => this.notificationsService.success(null, this.translate.get('submission.sections.general.save_success_notice'))), tap(() => this.submissionService.redirectToMyDSpace())); @@ -209,7 +219,7 @@ export class SubmissionObjectEffects { /** * Show a notification on success and redirect to MyDSpace page */ - @Effect({dispatch: false}) depositSubmissionSuccess$ = this.actions$.pipe( + @Effect({ dispatch: false }) depositSubmissionSuccess$ = this.actions$.pipe( ofType(SubmissionObjectActionTypes.DEPOSIT_SUBMISSION_SUCCESS), tap(() => this.notificationsService.success(null, this.translate.get('submission.sections.general.deposit_success_notice'))), tap(() => this.submissionService.redirectToMyDSpace())); @@ -217,7 +227,7 @@ export class SubmissionObjectEffects { /** * Show a notification on error */ - @Effect({dispatch: false}) depositSubmissionError$ = this.actions$.pipe( + @Effect({ dispatch: false }) depositSubmissionError$ = this.actions$.pipe( ofType(SubmissionObjectActionTypes.DEPOSIT_SUBMISSION_ERROR), tap(() => this.notificationsService.error(null, this.translate.get('submission.sections.general.deposit_error_notice')))); @@ -232,10 +242,40 @@ export class SubmissionObjectEffects { catchError(() => observableOf(new DiscardSubmissionErrorAction(action.payload.submissionId)))); })); + /** + * Adds all metadata an item to the SubmissionForm sections of the submission + */ + @Effect() addAllMetadataToSectionData = this.actions$.pipe( + ofType(SubmissionObjectActionTypes.UPLOAD_SECTION_DATA), + switchMap((action: UpdateSectionDataAction) => { + return this.sectionService.getSectionState(action.payload.submissionId, action.payload.sectionId) + .pipe(map((section: SubmissionSectionObject) => [action, section]), take(1)); + }), + filter(([action, section]: [UpdateSectionDataAction, SubmissionSectionObject]) => section.sectionType === SectionsType.SubmissionForm), + switchMap(([action, section]: [UpdateSectionDataAction, SubmissionSectionObject]) => { + const submissionObject$ = this.submissionObjectService + .findById(action.payload.submissionId, followLink('item')).pipe( + getFirstSucceededRemoteDataPayload() + ); + + const item$ = submissionObject$.pipe( + switchMap((submissionObject: SubmissionObject) => (submissionObject.item as Observable>).pipe( + getFirstSucceededRemoteDataPayload(), + ))); + + return item$.pipe( + map((item: Item) => item.metadata), + filter((metadata) => !isEqual(action.payload.data, metadata)), + map((metadata: any) => new UpdateSectionDataAction(action.payload.submissionId, action.payload.sectionId, metadata, action.payload.errors)) + ); + }), + ); + /** * Show a notification on success and redirect to MyDSpace page */ - @Effect({dispatch: false}) discardSubmissionSuccess$ = this.actions$.pipe( + @Effect({ dispatch: false }) + discardSubmissionSuccess$ = this.actions$.pipe( ofType(SubmissionObjectActionTypes.DISCARD_SUBMISSION_SUCCESS), tap(() => this.notificationsService.success(null, this.translate.get('submission.sections.general.discard_success_notice'))), tap(() => this.submissionService.redirectToMyDSpace())); @@ -243,7 +283,7 @@ export class SubmissionObjectEffects { /** * Show a notification on error */ - @Effect({dispatch: false}) discardSubmissionError$ = this.actions$.pipe( + @Effect({ dispatch: false }) discardSubmissionError$ = this.actions$.pipe( ofType(SubmissionObjectActionTypes.DISCARD_SUBMISSION_ERROR), tap(() => this.notificationsService.error(null, this.translate.get('submission.sections.general.discard_error_notice')))); @@ -253,6 +293,7 @@ export class SubmissionObjectEffects { private sectionService: SectionsService, private store$: Store, private submissionService: SubmissionService, + private submissionObjectService: SubmissionObjectDataService, private translate: TranslateService) { } @@ -267,7 +308,7 @@ export class SubmissionObjectEffects { if (isNotEmpty(response)) { response.forEach((item: WorkspaceItem | WorkflowItem) => { - const {errors} = item; + const { errors } = item; if (errors && !isEmpty(errors)) { canDeposit = false; @@ -307,7 +348,7 @@ export class SubmissionObjectEffects { response.forEach((item: WorkspaceItem | WorkflowItem) => { let errorsList = Object.create({}); - const {errors} = item; + const { errors } = item; if (errors && !isEmpty(errors)) { // to avoid dispatching an action for every error, create an array of errors per section @@ -336,11 +377,8 @@ export class SubmissionObjectEffects { } mappedActions.push(new UpdateSectionDataAction(submissionId, sectionId, sectionData, sectionErrors)); } - }); - } - return mappedActions; } diff --git a/src/app/submission/objects/submission-objects.reducer.spec.ts b/src/app/submission/objects/submission-objects.reducer.spec.ts index e7f3a24c89..0c585e4bca 100644 --- a/src/app/submission/objects/submission-objects.reducer.spec.ts +++ b/src/app/submission/objects/submission-objects.reducer.spec.ts @@ -39,6 +39,7 @@ import { mockSubmissionSelfUrl, mockSubmissionState } from '../../shared/mocks/submission.mock'; +import { Item } from '../../core/shared/item.model'; describe('submissionReducer test suite', () => { @@ -67,7 +68,7 @@ describe('submissionReducer test suite', () => { } }; - const action = new InitSubmissionFormAction(collectionId, submissionId, selfUrl, submissionDefinition, {}, []); + const action = new InitSubmissionFormAction(collectionId, submissionId, selfUrl, submissionDefinition, {}, new Item(), []); const newState = submissionObjectReducer({}, action); expect(newState).toEqual(expectedState); @@ -100,7 +101,7 @@ describe('submissionReducer test suite', () => { } }; - const action = new ResetSubmissionFormAction(collectionId, submissionId, selfUrl, {}, submissionDefinition); + const action = new ResetSubmissionFormAction(collectionId, submissionId, selfUrl, {}, submissionDefinition, new Item()); const newState = submissionObjectReducer(initState, action); expect(newState).toEqual(expectedState); @@ -241,7 +242,7 @@ describe('submissionReducer test suite', () => { isValid: false } as any; - let action: any = new InitSubmissionFormAction(collectionId, submissionId, selfUrl, submissionDefinition, {}, []); + let action: any = new InitSubmissionFormAction(collectionId, submissionId, selfUrl, submissionDefinition, {}, new Item(), []); let newState = submissionObjectReducer({}, action); action = new InitSectionAction( diff --git a/src/app/submission/sections/form/section-form-operations.service.spec.ts b/src/app/submission/sections/form/section-form-operations.service.spec.ts index de8e7da7f9..50803c9886 100644 --- a/src/app/submission/sections/form/section-form-operations.service.spec.ts +++ b/src/app/submission/sections/form/section-form-operations.service.spec.ts @@ -665,7 +665,7 @@ describe('SectionFormOperationsService test suite', () => { spyOn(service, 'getFieldPathFromEvent').and.returnValue('path/1'); spyOn(service, 'getFieldPathSegmentedFromChangeEvent').and.returnValue('path'); spyOn(service, 'getFieldValueFromChangeEvent').and.returnValue(new FormFieldMetadataValueObject('test')); - spyOn(service, 'getArrayIndexFromEvent').and.returnValue(1); + spyOn(service, 'getArrayIndexFromEvent').and.returnValue(0); spyOn(serviceAsAny, 'getValueMap'); spyOn(serviceAsAny, 'dispatchOperationsFromMap'); formBuilderService.isQualdropGroup.and.returnValue(false); @@ -676,8 +676,10 @@ describe('SectionFormOperationsService test suite', () => { serviceAsAny.dispatchOperationsFromChangeEvent(pathCombiner, event, previousValue, false); expect(jsonPatchOpBuilder.add).toHaveBeenCalledWith( - pathCombiner.getPath('path/1'), - new FormFieldMetadataValueObject('test')); + pathCombiner.getPath('path'), + new FormFieldMetadataValueObject('test'), + true + ); }); }); diff --git a/src/app/submission/sections/form/section-form-operations.service.ts b/src/app/submission/sections/form/section-form-operations.service.ts index 2d6b1c5477..6e7a35fe26 100644 --- a/src/app/submission/sections/form/section-form-operations.service.ts +++ b/src/app/submission/sections/form/section-form-operations.service.ts @@ -9,7 +9,15 @@ import { DynamicFormControlModel } from '@ng-dynamic-forms/core'; -import { isNotEmpty, isNotNull, isNotUndefined, isNull, isUndefined } from '../../../shared/empty.util'; +import { + hasNoValue, + hasValue, + 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'; @@ -61,6 +69,9 @@ export class SectionFormOperationsService { case 'change': this.dispatchOperationsFromChangeEvent(pathCombiner, event, previousValue, hasStoredValue); break; + case 'add': + this.dispatchOperationsFromAddEvent(pathCombiner, event); + break; default: break; } @@ -173,7 +184,7 @@ export class SectionFormOperationsService { metadataValueMap.set(groupModel.qualdropId, metadataValueList); } if (index === fieldIndex) { - path = groupModel.qualdropId + '/' + (metadataValueMap.get(groupModel.qualdropId).length - 1) + path = groupModel.qualdropId + '/' + (metadataValueList.length - 1) } }); @@ -286,6 +297,42 @@ export class SectionFormOperationsService { } } + /** + * Handle form add operations + * + * @param pathCombiner + * the [[JsonPatchOperationPathCombiner]] object for the specified operation + * @param event + * the [[DynamicFormControlEvent]] for the specified operation + */ + protected dispatchOperationsFromAddEvent( + pathCombiner: JsonPatchOperationPathCombiner, + event: DynamicFormControlEvent + ): void { + const path = this.getFieldPathSegmentedFromChangeEvent(event); + const value = this.getFieldValueFromChangeEvent(event); + if (isNotEmpty(value)) { + value.place = this.getArrayIndexFromEvent(event); + if (hasValue(event.group) && hasValue(event.group.value)) { + const valuesInGroup = event.group.value + .map((g) => Object.values(g)) + .reduce((accumulator, currentValue) => accumulator.concat(currentValue)) + .filter((v) => isNotEmpty(v)); + if (valuesInGroup.length === 1) { + // The first add for a field needs to be a different PATCH operation + // for some reason + this.operationsBuilder.add( + pathCombiner.getPath([path]), + [value], false); + } else { + this.operationsBuilder.add( + pathCombiner.getPath([path, '-']), + value, false); + } + } + } + } + /** * Handle form change operations * @@ -312,14 +359,29 @@ export class SectionFormOperationsService { } 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)) { + } else if (this.formBuilder.hasArrayGroupValue(event.model) && hasNoValue((event.model as any).relationshipConfig)) { // 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()) { + if (hasValue(event.$event) && hasValue(event.$event.previousIndex)) { + if (event.$event.previousIndex < 0) { + this.operationsBuilder.add( + pathCombiner.getPath(segmentedPath), + value, true); + } else { + const moveTo = pathCombiner.getPath(path); + const moveFrom = pathCombiner.getPath(segmentedPath + '/' + event.$event.previousIndex); + if (isNotEmpty(moveFrom.path) && isNotEmpty(moveTo.path) && moveFrom.path !== moveTo.path) { + this.operationsBuilder.move( + moveTo, + moveFrom.path + ) + } + } + } else if (!value.hasValue()) { // New value is empty, so dispatch a remove operation if (this.getArrayIndexFromEvent(event) === 0) { this.operationsBuilder.remove(pathCombiner.getPath(segmentedPath)); @@ -333,22 +395,13 @@ export class SectionFormOperationsService { 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) { + } else if (value.hasValue() && (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); - } } } diff --git a/src/app/submission/sections/form/section-form.component.spec.ts b/src/app/submission/sections/form/section-form.component.spec.ts index c8c0d671ec..b2bbd4e63f 100644 --- a/src/app/submission/sections/form/section-form.component.spec.ts +++ b/src/app/submission/sections/form/section-form.component.spec.ts @@ -41,6 +41,12 @@ import { SubmissionSectionError } from '../../objects/submission-objects.reducer import { DynamicFormControlEvent, DynamicFormControlEventType } from '@ng-dynamic-forms/core'; import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; import { FormRowModel } from '../../../core/config/models/config-submission-form.model'; +import { WorkspaceitemDataService } from '../../../core/submission/workspaceitem-data.service'; +import { RemoteData } from '../../../core/data/remote-data'; +import { WorkspaceItem } from '../../../core/submission/models/workspaceitem.model'; +import { SubmissionObjectDataService } from '../../../core/submission/submission-object-data.service'; +import { ObjectCacheService } from '../../../core/cache/object-cache.service'; +import { RequestService } from '../../../core/data/request.service'; function getMockSubmissionFormsConfigService(): SubmissionFormsConfigService { return jasmine.createSpyObj('FormOperationsService', { @@ -114,11 +120,11 @@ const testFormConfiguration = { const testFormModel = [ new DynamicRowGroupModel({ id: 'df-row-group-config-1', - group: [new DsDynamicInputModel({ id: 'dc.title', metadataFields: [], repeatable: false, submissionId: '1234' })], + group: [new DsDynamicInputModel({ id: 'dc.title', metadataFields: [], repeatable: false, submissionId: '1234', hasSelectableMetadata: false })], }), new DynamicRowGroupModel({ id: 'df-row-group-config-2', - group: [new DsDynamicInputModel({ id: 'dc.contributor', metadataFields: [], repeatable: false, submissionId: '1234' })], + group: [new DsDynamicInputModel({ id: 'dc.contributor', metadataFields: [], repeatable: false, submissionId: '1234', hasSelectableMetadata: false })], }) ]; @@ -173,9 +179,12 @@ describe('SubmissionSectionformComponent test suite', () => { { provide: SectionsService, useClass: SectionsServiceStub }, { provide: SubmissionService, useClass: SubmissionServiceStub }, { provide: TranslateService, useValue: getMockTranslateService() }, + { provide: ObjectCacheService, useValue: { remove: () => {/*do nothing*/}, hasBySelfLinkObservable: () => observableOf(false) } }, + { provide: RequestService, useValue: { removeByHrefSubstring: () => {/*do nothing*/}, hasByHrefObservable: () => observableOf(false) } }, { provide: 'collectionIdProvider', useValue: collectionId }, { provide: 'sectionDataProvider', useValue: sectionObject }, { provide: 'submissionIdProvider', useValue: submissionId }, + { provide: SubmissionObjectDataService, useValue: { getHrefByID: () => observableOf('testUrl'), findById: () => observableOf(new RemoteData(false, false, true, null, new WorkspaceItem())) } }, ChangeDetectorRef, SubmissionSectionformComponent ], @@ -248,7 +257,6 @@ describe('SubmissionSectionformComponent test suite', () => { expect(comp.isLoading).toBeFalsy(); expect(comp.initForm).toHaveBeenCalledWith(sectionData); expect(comp.subscriptions).toHaveBeenCalled(); - }); it('should init form model properly', () => { @@ -311,7 +319,6 @@ describe('SubmissionSectionformComponent test suite', () => { expect(comp.isUpdating).toBeFalsy(); expect(comp.initForm).toHaveBeenCalled(); expect(comp.checksForErrors).toHaveBeenCalled(); - expect(notificationsServiceStub.info).toHaveBeenCalled(); expect(comp.sectionData.data).toEqual(sectionData); }); @@ -328,7 +335,7 @@ describe('SubmissionSectionformComponent test suite', () => { comp.updateForm(sectionData, parsedSectionErrors); - expect(comp.initForm).not.toHaveBeenCalled(); + expect(comp.initForm).toHaveBeenCalled(); expect(comp.checksForErrors).toHaveBeenCalled(); expect(comp.sectionData.data).toEqual(sectionData); }); diff --git a/src/app/submission/sections/form/section-form.component.ts b/src/app/submission/sections/form/section-form.component.ts index 58cda897b0..f4a64c5976 100644 --- a/src/app/submission/sections/form/section-form.component.ts +++ b/src/app/submission/sections/form/section-form.component.ts @@ -1,8 +1,8 @@ import { ChangeDetectorRef, Component, Inject, ViewChild } from '@angular/core'; import { DynamicFormControlEvent, DynamicFormControlModel } from '@ng-dynamic-forms/core'; -import { Observable, Subscription } from 'rxjs'; -import { distinctUntilChanged, filter, find, flatMap, map, take, tap } from 'rxjs/operators'; +import { combineLatest as observableCombineLatest, Observable, Subscription } from 'rxjs'; +import { distinctUntilChanged, filter, find, flatMap, map, switchMap, take, tap } from 'rxjs/operators'; import { TranslateService } from '@ngx-translate/core'; import { isEqual } from 'lodash'; @@ -11,7 +11,7 @@ import { FormComponent } from '../../../shared/form/form.component'; import { FormService } from '../../../shared/form/form.service'; import { SectionModelComponent } from '../models/section.model'; import { SubmissionFormsConfigService } from '../../../core/config/submission-forms-config.service'; -import { hasValue, isNotEmpty, isUndefined } from '../../../shared/empty.util'; +import { hasNoValue, hasValue, isNotEmpty, 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 { SubmissionFormsModel } from '../../../core/config/models/config-submission-forms.model'; @@ -27,10 +27,13 @@ import { NotificationsService } from '../../../shared/notifications/notification import { SectionsService } from '../sections.service'; import { difference } from '../../../shared/object.util'; import { WorkspaceitemSectionFormObject } from '../../../core/submission/models/workspaceitem-section-form.model'; -import { WorkspaceitemDataService } from '../../../core/submission/workspaceitem-data.service'; -import { combineLatest as combineLatestObservable } from 'rxjs'; -import { getSucceededRemoteData } from '../../../core/shared/operators'; -import { RemoteData } from '../../../core/data/remote-data'; +import { WorkspaceItem } from '../../../core/submission/models/workspaceitem.model'; +import { getRemoteDataPayload, getSucceededRemoteData } from '../../../core/shared/operators'; +import { SubmissionObject } from '../../../core/submission/models/submission-object.model'; +import { SubmissionObjectDataService } from '../../../core/submission/submission-object-data.service'; +import { ObjectCacheService } from '../../../core/cache/object-cache.service'; +import { RequestService } from '../../../core/data/request.service'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; import { environment } from '../../../../environments/environment'; /** @@ -104,6 +107,7 @@ export class SubmissionSectionformComponent extends SectionModelComponent { */ protected subs: Subscription[] = []; + protected workspaceItem: WorkspaceItem; /** * The FormComponent reference */ @@ -121,7 +125,9 @@ export class SubmissionSectionformComponent extends SectionModelComponent { * @param {SectionsService} sectionService * @param {SubmissionService} submissionService * @param {TranslateService} translate - * @param {GlobalConfig} EnvConfig + * @param {SubmissionObjectDataService} submissionObjectService + * @param {ObjectCacheService} objectCache + * @param {RequestService} requestService * @param {string} injectedCollectionId * @param {SectionDataObject} injectedSectionData * @param {string} injectedSubmissionId @@ -135,6 +141,9 @@ export class SubmissionSectionformComponent extends SectionModelComponent { protected sectionService: SectionsService, protected submissionService: SubmissionService, protected translate: TranslateService, + protected submissionObjectService: SubmissionObjectDataService, + protected objectCache: ObjectCacheService, + protected requestService: RequestService, @Inject('collectionIdProvider') public injectedCollectionId: string, @Inject('sectionDataProvider') public injectedSectionData: SectionDataObject, @Inject('submissionIdProvider') public injectedSubmissionId: string) { @@ -150,11 +159,29 @@ export class SubmissionSectionformComponent extends SectionModelComponent { this.formConfigService.getConfigByHref(this.sectionData.config).pipe( map((configData: ConfigData) => configData.payload), tap((config: SubmissionFormsModel) => this.formConfig = config), - flatMap(() => this.sectionService.getSectionData(this.submissionId, this.sectionData.id)), + flatMap(() => + observableCombineLatest( + this.sectionService.getSectionData(this.submissionId, this.sectionData.id), + this.submissionObjectService.getHrefByID(this.submissionId).pipe(take(1)).pipe( + switchMap((href: string) => { + this.objectCache.remove(href); + this.requestService.removeByHrefSubstring(this.submissionId); + return observableCombineLatest( + this.objectCache.hasBySelfLinkObservable(href), + this.requestService.hasByHrefObservable(href) + ).pipe( + filter(([existsInOC, existsInRC]) => !existsInOC && !existsInRC), + take(1), + switchMap(() => this.submissionObjectService.findById(this.submissionId, followLink('item')).pipe(getSucceededRemoteData(), getRemoteDataPayload()) as Observable) + ) + }) + ) + )), take(1)) - .subscribe((sectionData: WorkspaceitemSectionFormObject) => { + .subscribe(([sectionData, workspaceItem]: [WorkspaceitemSectionFormObject, WorkspaceItem]) => { if (isUndefined(this.formModel)) { this.sectionData.errors = []; + this.workspaceItem = workspaceItem; // Is the first loading so init form this.initForm(sectionData); this.sectionData.data = sectionData; @@ -244,23 +271,15 @@ export class SubmissionSectionformComponent extends SectionModelComponent { */ updateForm(sectionData: WorkspaceitemSectionFormObject, errors: SubmissionSectionError[]): void { - if (isNotEmpty(sectionData) && !isEqual(sectionData, this.sectionData.data)) { + if (hasValue(sectionData) && !isEqual(sectionData, this.sectionData.data)) { this.sectionData.data = sectionData; - if (this.hasMetadataEnrichment(sectionData)) { - const msg = this.translate.instant( - 'submission.sections.general.metadata-extracted', - { sectionId: this.sectionData.id }); - this.notificationsService.info(null, msg, null, true); - this.isUpdating = true; - this.formModel = null; - this.cdr.detectChanges(); - this.initForm(sectionData); - this.checksForErrors(errors); - this.isUpdating = false; - this.cdr.detectChanges(); - } else if (isNotEmpty(errors) || isNotEmpty(this.sectionData.errors)) { - this.checksForErrors(errors); - } + this.isUpdating = true; + this.formModel = null; + this.cdr.detectChanges(); + this.initForm(sectionData); + this.checksForErrors(errors); + this.isUpdating = false; + this.cdr.detectChanges(); } else if (isNotEmpty(errors) || isNotEmpty(this.sectionData.errors)) { this.checksForErrors(errors); } @@ -320,16 +339,19 @@ export class SubmissionSectionformComponent extends SectionModelComponent { * the [[DynamicFormControlEvent]] emitted */ onChange(event: DynamicFormControlEvent): void { - 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); + // don't handle change events for things with an index < 0, those are template rows. + if (hasNoValue(event.context) || hasNoValue(event.context.index) || event.context.index >= 0) { + 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 (environment.submission.autosave.metadata.indexOf(metadata) !== -1 && isNotEmpty(value)) { - this.submissionService.dispatchSave(this.submissionId); + if (environment.submission.autosave.metadata.indexOf(metadata) !== -1 && isNotEmpty(value)) { + this.submissionService.dispatchSave(this.submissionId); + } } } diff --git a/src/app/submission/sections/sections.service.spec.ts b/src/app/submission/sections/sections.service.spec.ts index 1455a66f78..e5cb3ddc09 100644 --- a/src/app/submission/sections/sections.service.spec.ts +++ b/src/app/submission/sections/sections.service.spec.ts @@ -368,7 +368,6 @@ describe('SectionsService test suite', () => { scheduler.schedule(() => service.updateSectionData(submissionId, sectionId, data, [])); scheduler.flush(); - expect(notificationsServiceStub.info).toHaveBeenCalled(); expect(store.dispatch).toHaveBeenCalledWith(new UpdateSectionDataAction(submissionId, sectionId, data, [])); }); }); diff --git a/src/app/submission/sections/sections.service.ts b/src/app/submission/sections/sections.service.ts index f6ad5ef0cf..52ae941893 100644 --- a/src/app/submission/sections/sections.service.ts +++ b/src/app/submission/sections/sections.service.ts @@ -17,17 +17,8 @@ import { SectionStatusChangeAction, UpdateSectionDataAction } from '../objects/submission-objects.actions'; -import { - SubmissionObjectEntry, - SubmissionSectionError, - SubmissionSectionObject -} from '../objects/submission-objects.reducer'; -import { - submissionObjectFromIdSelector, - submissionSectionDataFromIdSelector, - submissionSectionErrorsFromIdSelector, - submissionSectionFromIdSelector -} from '../selectors'; +import { SubmissionObjectEntry, SubmissionSectionError, SubmissionSectionObject } from '../objects/submission-objects.reducer'; +import { submissionObjectFromIdSelector, submissionSectionDataFromIdSelector, submissionSectionErrorsFromIdSelector, submissionSectionFromIdSelector } from '../selectors'; import { SubmissionScopeType } from '../../core/submission/submission-scope-type'; import parseSectionErrorPaths, { SectionErrorPath } from '../utils/parseSectionErrorPaths'; import { FormAddError, FormClearErrorsAction, FormRemoveErrorAction } from '../../shared/form/form.actions'; @@ -175,7 +166,8 @@ export class SectionsService { return this.store.select(submissionSectionFromIdSelector(submissionId, sectionId)).pipe( filter((sectionObj: SubmissionSectionObject) => hasValue(sectionObj)), map((sectionObj: SubmissionSectionObject) => sectionObj), - distinctUntilChanged()); + distinctUntilChanged(), + ); } /** @@ -321,10 +313,6 @@ export class SectionsService { take(1), filter(([available, enabled]: [boolean, boolean]) => available)) .subscribe(([available, enabled]: [boolean, boolean]) => { - if (!enabled) { - const msg = this.translate.instant('submission.sections.general.metadata-extracted-new-section', {sectionId}); - this.notificationsService.info(null, msg, null, true); - } this.store.dispatch(new UpdateSectionDataAction(submissionId, sectionId, data, errors)); }); } diff --git a/src/app/submission/submission.service.spec.ts b/src/app/submission/submission.service.spec.ts index eb7538ec69..4e4d418b0d 100644 --- a/src/app/submission/submission.service.spec.ts +++ b/src/app/submission/submission.service.spec.ts @@ -45,6 +45,7 @@ import { getMockSearchService } from '../shared/mocks/search-service.mock'; import { getMockRequestService } from '../shared/mocks/request.service.mock'; import { RequestService } from '../core/data/request.service'; import { SearchService } from '../core/shared/search/search.service'; +import { Item } from '../core/shared/item.model'; import { storeModuleConfig } from '../app.reducer'; import { environment } from '../../environments/environment'; @@ -439,6 +440,7 @@ describe('SubmissionService test suite', () => { selfUrl, submissionDefinition, {}, + new Item(), [] ); const expected = new InitSubmissionFormAction( @@ -447,6 +449,7 @@ describe('SubmissionService test suite', () => { selfUrl, submissionDefinition, {}, + new Item(), []); expect((service as any).store.dispatch).toHaveBeenCalledWith(expected); @@ -842,14 +845,17 @@ describe('SubmissionService test suite', () => { submissionId, selfUrl, submissionDefinition, - {} - ); + {}, + new Item() + ) + ; const expected = new ResetSubmissionFormAction( collectionId, submissionId, selfUrl, {}, - submissionDefinition + submissionDefinition, + new Item() ); expect((service as any).store.dispatch).toHaveBeenCalledWith(expected); diff --git a/src/app/submission/submission.service.ts b/src/app/submission/submission.service.ts index f06f6ea069..262612d50c 100644 --- a/src/app/submission/submission.service.ts +++ b/src/app/submission/submission.service.ts @@ -45,6 +45,7 @@ import { RemoteDataError } from '../core/data/remote-data-error'; import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject } from '../shared/remote-data.utils'; import { RequestService } from '../core/data/request.service'; import { SearchService } from '../core/shared/search/search.service'; +import { Item } from '../core/shared/item.model'; import { environment } from '../../environments/environment'; /** @@ -163,8 +164,9 @@ export class SubmissionService { selfUrl: string, submissionDefinition: SubmissionDefinitionsModel, sections: WorkspaceitemSectionsObject, + item: Item, errors: SubmissionSectionError[]) { - this.store.dispatch(new InitSubmissionFormAction(collectionId, submissionId, selfUrl, submissionDefinition, sections, errors)); + this.store.dispatch(new InitSubmissionFormAction(collectionId, submissionId, selfUrl, submissionDefinition, sections, item, errors)); } /** @@ -502,9 +504,10 @@ export class SubmissionService { submissionId: string, selfUrl: string, submissionDefinition: SubmissionDefinitionsModel, - sections: WorkspaceitemSectionsObject + sections: WorkspaceitemSectionsObject, + item: Item ) { - this.store.dispatch(new ResetSubmissionFormAction(collectionId, submissionId, selfUrl, sections, submissionDefinition)); + this.store.dispatch(new ResetSubmissionFormAction(collectionId, submissionId, selfUrl, sections, submissionDefinition, item)); } /** diff --git a/src/app/submission/submit/submission-submit.component.html b/src/app/submission/submit/submission-submit.component.html index c9e8c6b51a..2ceaf5a6de 100644 --- a/src/app/submission/submit/submission-submit.component.html +++ b/src/app/submission/submit/submission-submit.component.html @@ -3,6 +3,7 @@ diff --git a/src/app/submission/submit/submission-submit.component.ts b/src/app/submission/submit/submission-submit.component.ts index d3d3ca4e66..deced3ef26 100644 --- a/src/app/submission/submit/submission-submit.component.ts +++ b/src/app/submission/submit/submission-submit.component.ts @@ -10,6 +10,7 @@ import { NotificationsService } from '../../shared/notifications/notifications.s import { SubmissionService } from '../submission.service'; import { SubmissionObject } from '../../core/submission/models/submission-object.model'; import { Collection } from '../../core/shared/collection.model'; +import { Item } from '../../core/shared/item.model'; /** * This component allows to submit a new workspaceitem. @@ -26,6 +27,7 @@ export class SubmissionSubmitComponent implements OnDestroy, OnInit { * @type {string} */ public collectionId: string; + public item: Item; /** * The collection id input to create a new submission @@ -98,6 +100,7 @@ export class SubmissionSubmitComponent implements OnDestroy, OnInit { this.selfUrl = submissionObject._links.self.href; this.submissionDefinition = (submissionObject.submissionDefinition as SubmissionDefinitionsModel); this.submissionId = submissionObject.id; + this.item = submissionObject.item as Item; this.changeDetectorRef.detectChanges(); } } diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 7fb4a13bea..bfa1c81aa6 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -14,7 +14,11 @@ "404.page-not-found": "page not found", + "admin.curation-tasks.breadcrumbs": "System curation tasks", + "admin.curation-tasks.title": "System curation tasks", + + "admin.curation-tasks.header": "System curation tasks", "admin.registries.bitstream-formats.breadcrumbs": "Format registry", @@ -560,6 +564,8 @@ "collection.create.sub-head": "Create a Collection for Community {{ parent }}", + "collection.curate.header": "Curate Collection: {{collection}}", + "collection.delete.cancel": "Cancel", "collection.delete.confirm": "Confirm", @@ -770,6 +776,8 @@ "community.create.sub-head": "Create a Sub-Community for Community {{ parent }}", + "community.curate.header": "Curate Community: {{community}}", + "community.delete.cancel": "Cancel", "community.delete.confirm": "Confirm", @@ -909,6 +917,38 @@ + "curation-task.task.checklinks.label": "Check Links in Metadata", + + "curation-task.task.noop.label": "NOOP", + + "curation-task.task.profileformats.label": "Profile Bitstream Formats", + + "curation-task.task.requiredmetadata.label": "Check for Required Metadata", + + "curation-task.task.translate.label": "Microsoft Translator", + + "curation-task.task.vscan.label": "Virus Scan", + + + + "curation.form.task-select.label": "Task:", + + "curation.form.submit": "Start", + + "curation.form.submit.success.head": "The curation task has been started successfully", + + "curation.form.submit.success.content": "You will be redirected to the corresponding process page.", + + "curation.form.submit.error.head": "Running the curation task failed", + + "curation.form.submit.error.content": "An error occured when trying to start the curation task.", + + "curation.form.handle.label": "Handle:", + + "curation.form.handle.hint": "Hint: Enter [your-handle-prefix]/0 to run a task across entire site (not all tasks may support this capability)", + + + "dso-selector.create.collection.head": "New collection", "dso-selector.create.community.head": "New community", @@ -1386,6 +1426,8 @@ "item.edit.relationships.save-button": "Save", + "item.edit.relationships.no-entity-type": "Add 'relationship.type' metadata to enable relationships for this item", + "item.edit.tabs.bitstreams.head": "Bitstreams", @@ -2738,13 +2780,13 @@ "submission.sections.describe.relationship-lookup.selected": "Selected {{ size }} items", - "submission.sections.describe.relationship-lookup.search-tab.tab-title.Author": "Local Authors ({{ count }})", + "submission.sections.describe.relationship-lookup.search-tab.tab-title.isAuthorOfPublication": "Local Authors ({{ count }})", - "submission.sections.describe.relationship-lookup.search-tab.tab-title.Journal": "Local Journals ({{ count }})", + "submission.sections.describe.relationship-lookup.search-tab.tab-title.isJournalOfPublication": "Local Journals ({{ count }})", - "submission.sections.describe.relationship-lookup.search-tab.tab-title.Journal Issue": "Local Journal Issues ({{ count }})", + "submission.sections.describe.relationship-lookup.search-tab.tab-title.isJournalIssueOfPublication": "Local Journal Issues ({{ count }})", - "submission.sections.describe.relationship-lookup.search-tab.tab-title.Journal Volume": "Local Journal Volumes ({{ count }})", + "submission.sections.describe.relationship-lookup.search-tab.tab-title.isJournalVolumeOfPublication": "Local Journal Volumes ({{ count }})", "submission.sections.describe.relationship-lookup.search-tab.tab-title.sherpaJournal": "Sherpa Journals ({{ count }})", @@ -2754,23 +2796,27 @@ "submission.sections.describe.relationship-lookup.search-tab.tab-title.lcname": "LC Names ({{ count }})", - "submission.sections.describe.relationship-lookup.search-tab.tab-title.Funding Agency": "Search for Funding Agencies", + "submission.sections.describe.relationship-lookup.search-tab.tab-title.isFundingAgencyOfPublication": "Search for Funding Agencies", - "submission.sections.describe.relationship-lookup.search-tab.tab-title.Funding": "Search for Funding", + "submission.sections.describe.relationship-lookup.search-tab.tab-title.isFundingOfPublication": "Search for Funding", + + "submission.sections.describe.relationship-lookup.search-tab.tab-title.isChildOrgUnitOf": "Search for Organizational Units", "submission.sections.describe.relationship-lookup.selection-tab.tab-title": "Current Selection ({{ count }})", - "submission.sections.describe.relationship-lookup.title.Journal Issue": "Journal Issues", + "submission.sections.describe.relationship-lookup.title.isJournalIssueOfPublication": "Journal Issues", - "submission.sections.describe.relationship-lookup.title.Journal Volume": "Journal Volumes", + "submission.sections.describe.relationship-lookup.title.isJournalVolumeOfPublication": "Journal Volumes", - "submission.sections.describe.relationship-lookup.title.Journal": "Journals", + "submission.sections.describe.relationship-lookup.title.isJournalOfPublication": "Journals", - "submission.sections.describe.relationship-lookup.title.Author": "Authors", + "submission.sections.describe.relationship-lookup.title.isAuthorOfPublication": "Authors", - "submission.sections.describe.relationship-lookup.title.Funding Agency": "Funding Agency", + "submission.sections.describe.relationship-lookup.title.isFundingAgencyOfPublication": "Funding Agency", - "submission.sections.describe.relationship-lookup.title.Funding": "Funding", + "submission.sections.describe.relationship-lookup.title.isFundingOfPublication": "Funding", + + "submission.sections.describe.relationship-lookup.title.isChildOrgUnitOf": "Parent Organizational Unit", "submission.sections.describe.relationship-lookup.search-tab.toggle-dropdown": "Toggle dropdown", @@ -2778,13 +2824,19 @@ "submission.sections.describe.relationship-lookup.selection-tab.no-selection": "Your selection is currently empty.", - "submission.sections.describe.relationship-lookup.selection-tab.title.Author": "Selected Authors", + "submission.sections.describe.relationship-lookup.selection-tab.title.isAuthorOfPublication": "Selected Authors", - "submission.sections.describe.relationship-lookup.selection-tab.title.Journal": "Selected Journals", + "submission.sections.describe.relationship-lookup.selection-tab.title.isJournalOfPublication": "Selected Journals", - "submission.sections.describe.relationship-lookup.selection-tab.title.Journal Volume": "Selected Journal Volume", + "submission.sections.describe.relationship-lookup.selection-tab.title.isJournalVolumeOfPublication": "Selected Journal Volume", - "submission.sections.describe.relationship-lookup.selection-tab.title.Journal Issue": "Selected Issue", + "submission.sections.describe.relationship-lookup.selection-tab.title.isJournalIssueOfPublication": "Selected Issue", + + "submission.sections.describe.relationship-lookup.selection-tab.title.isFundingAgencyOfPublication": "Selected Funding Agency", + + "submission.sections.describe.relationship-lookup.selection-tab.title.isFundingOfPublication": "Selected Funding", + + "submission.sections.describe.relationship-lookup.selection-tab.title.isChildOrgUnitOf": "Selected Organizational Unit", "submission.sections.describe.relationship-lookup.selection-tab.title.sherpaJournal": "Search Results", diff --git a/src/environments/environment.common.ts b/src/environments/environment.common.ts index 2b68eadcfb..32ae2f54b0 100644 --- a/src/environments/environment.common.ts +++ b/src/environments/environment.common.ts @@ -6,6 +6,7 @@ import { RestRequestMethod } from '../app/core/data/rest-request-method'; export const environment: GlobalConfig = { production: true, // Angular Universal server settings. + // NOTE: these must be "synced" with the 'dspace.ui.url' setting in your backend's local.cfg. ui: { ssl: false, host: 'localhost', @@ -14,6 +15,8 @@ export const environment: GlobalConfig = { nameSpace: '/', }, // The REST API server settings. + // NOTE: these must be "synced" with the 'dspace.server.url' setting in your backend's local.cfg. + // The 'nameSpace' must always end in "/api" as that's the subpath of the REST API in the backend. rest: { ssl: true, host: 'dspace7.4science.cloud', @@ -212,5 +215,5 @@ export const environment: GlobalConfig = { }, theme: { name: 'default', - } + }, }; diff --git a/src/environments/mock-environment.ts b/src/environments/mock-environment.ts index 7bf6d6c846..6e4d60e268 100644 --- a/src/environments/mock-environment.ts +++ b/src/environments/mock-environment.ts @@ -195,5 +195,5 @@ export const environment: Partial = { }, theme: { name: 'default', - } + }, }; diff --git a/src/modules/app/browser-app.module.ts b/src/modules/app/browser-app.module.ts index f43074e033..73a49b0211 100644 --- a/src/modules/app/browser-app.module.ts +++ b/src/modules/app/browser-app.module.ts @@ -44,6 +44,7 @@ export function getRequest(transferState: TransferState): any { RouterModule.forRoot([], { // enableTracing: true, useHash: false, + scrollPositionRestoration: 'enabled', preloadingStrategy: IdlePreload }), diff --git a/src/modules/app/server-app.module.ts b/src/modules/app/server-app.module.ts index 5abd8e3aa1..0ba09182cc 100644 --- a/src/modules/app/server-app.module.ts +++ b/src/modules/app/server-app.module.ts @@ -25,6 +25,10 @@ import { ServerSubmissionService } from '../../app/submission/server-submission. import { Angulartics2DSpace } from '../../app/statistics/angulartics/dspace-provider'; import { Angulartics2RouterlessModule } from 'angulartics2/routerlessmodule'; import { ModuleMapLoaderModule } from '@nguniversal/module-map-ngfactory-loader'; +import { ServerLocaleService } from 'src/app/core/locale/server-locale.service'; +import { LocaleService } from 'src/app/core/locale/locale.service'; +import { HTTP_INTERCEPTORS } from '@angular/common/http'; +import { ForwardClientIpInterceptor } from '../../app/core/forward-client-ip/forward-client-ip.interceptor'; export function createTranslateLoader() { return new TranslateJson5UniversalLoader('dist/server/assets/i18n/', '.json5'); @@ -73,7 +77,17 @@ export function createTranslateLoader() { { provide: SubmissionService, useClass: ServerSubmissionService - } + }, + { + provide: LocaleService, + useClass: ServerLocaleService + }, + // register ForwardClientIpInterceptor as HttpInterceptor + { + provide: HTTP_INTERCEPTORS, + useClass: ForwardClientIpInterceptor, + multi: true + }, ] }) export class ServerAppModule { diff --git a/yarn.lock b/yarn.lock index c48e53a226..3a36e692d7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2264,7 +2264,28 @@ cacache@12.0.2: unique-filename "^1.1.1" y18n "^4.0.0" -cacache@^12.0.0, cacache@^12.0.2, cacache@^12.0.3: +cacache@^12.0.0: + version "12.0.4" + resolved "https://registry.yarnpkg.com/cacache/-/cacache-12.0.4.tgz#668bcbd105aeb5f1d92fe25570ec9525c8faa40c" + integrity sha512-a0tMB40oefvuInr4Cwb3GerbL9xTj1D5yg0T5xrjGCGyfvbxseIXX7BAO/u/hIXdafzOI5JC3wDwHyf24buOAQ== + dependencies: + bluebird "^3.5.5" + chownr "^1.1.1" + figgy-pudding "^3.5.1" + glob "^7.1.4" + graceful-fs "^4.1.15" + infer-owner "^1.0.3" + lru-cache "^5.1.1" + mississippi "^3.0.0" + mkdirp "^0.5.1" + move-concurrently "^1.0.1" + promise-inflight "^1.0.1" + rimraf "^2.6.3" + ssri "^6.0.1" + unique-filename "^1.1.1" + y18n "^4.0.0" + +cacache@^12.0.2, cacache@^12.0.3: version "12.0.3" resolved "https://registry.yarnpkg.com/cacache/-/cacache-12.0.3.tgz#be99abba4e1bf5df461cd5a2c1071fc432573390" integrity sha512-kqdmfXEGFepesTuROHMs3MpFLWrPkSSpRqOw80RCflZXy/khxaArvFrQ7uJxSUduzAufc6G0g1VUCOZXxWavPw== @@ -3040,16 +3061,16 @@ coverage-istanbul-loader@2.0.3: merge-source-map "^1.1.0" schema-utils "^2.6.1" -coveralls@3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/coveralls/-/coveralls-3.0.0.tgz#22ef730330538080d29b8c151dc9146afde88a99" - integrity sha512-ZppXR9y5PraUOrf/DzHJY6gzNUhXYE3b9D43xEXs4QYZ7/Oe0Gy0CS+IPKWFfvQFXB3RG9QduaQUFehzSpGAFw== +coveralls@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/coveralls/-/coveralls-3.1.0.tgz#13c754d5e7a2dd8b44fe5269e21ca394fb4d615b" + integrity sha512-sHxOu2ELzW8/NC1UP5XVLbZDzO4S3VxfFye3XYCznopHy02YjNkHcj5bKaVw2O7hVaBdBjEdQGpie4II1mWhuQ== dependencies: - js-yaml "^3.6.1" - lcov-parse "^0.0.10" - log-driver "^1.2.5" - minimist "^1.2.0" - request "^2.79.0" + js-yaml "^3.13.1" + lcov-parse "^1.0.0" + log-driver "^1.2.7" + minimist "^1.2.5" + request "^2.88.2" create-ecdh@^4.0.0: version "4.0.3" @@ -4260,9 +4281,9 @@ faye-websocket@~0.11.1: websocket-driver ">=0.5.1" figgy-pudding@^3.4.1, figgy-pudding@^3.5.1: - version "3.5.1" - resolved "https://registry.yarnpkg.com/figgy-pudding/-/figgy-pudding-3.5.1.tgz#862470112901c727a0e495a80744bd5baa1d6790" - integrity sha512-vNKxJHTEKNThjfrdJwHc7brvM6eVevuO5nTj6ez8ZQ1qbXTvGthucRF7S4vf2cr71QVnT70V34v0S1DyQsti0w== + version "3.5.2" + resolved "https://registry.yarnpkg.com/figgy-pudding/-/figgy-pudding-3.5.2.tgz#b4eee8148abb01dcf1d1ac34367d59e12fa61d6e" + integrity sha512-0btnI/H8f2pavGMN8w40mlSKOfTK2SVJmBfBeVIj3kNw0swwgzyRq0d5TJVOwodFmtvpPeWPN/MCcfuWF0Ezbw== figures@^2.0.0: version "2.0.0" @@ -4797,12 +4818,12 @@ got@^6.7.1: unzip-response "^2.0.1" url-parse-lax "^1.0.0" -graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.2: +graceful-fs@^4.1.11, graceful-fs@^4.1.6, graceful-fs@^4.2.2: version "4.2.3" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.3.tgz#4a12ff1b60376ef09862c2093edd908328be8423" integrity sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ== -graceful-fs@^4.2.0, graceful-fs@^4.2.4: +graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.2.0, graceful-fs@^4.2.4: version "4.2.4" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.4.tgz#2256bde14d3632958c465ebc96dc467ca07a29fb" integrity sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw== @@ -4952,11 +4973,16 @@ hoopy@^0.1.4: resolved "https://registry.yarnpkg.com/hoopy/-/hoopy-0.1.4.tgz#609207d661100033a9a9402ad3dea677381c1b1d" integrity sha512-HRcs+2mr52W0K+x8RzcLzuPPmVIKMSv97RGHy0Ea9y/mpcaK+xTrjICA04KAHi4GRzxliNqNJEFYWHghy3rSfQ== -hosted-git-info@^2.1.4, hosted-git-info@^2.6.0, hosted-git-info@^2.7.1: +hosted-git-info@^2.1.4, hosted-git-info@^2.6.0: version "2.8.7" resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.7.tgz#4d2e0d5248e1cfabc984b0f6a6d75fe36e679511" integrity sha512-ChkjQtKJ3GI6SsI4O5jwr8q8EPrWCnxuc4Tbx+vRI5x6mDOpjKKltNo1lRlszw3xwgTOSns1ZRBiMmmwpcvLxg== +hosted-git-info@^2.7.1: + version "2.8.8" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488" + integrity sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg== + hpack.js@^2.1.6: version "2.1.6" resolved "https://registry.yarnpkg.com/hpack.js/-/hpack.js-2.1.6.tgz#87774c0949e513f42e84575b3c45681fade2a0b2" @@ -5902,7 +5928,7 @@ js-tokens@^3.0.2: resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b" integrity sha1-mGbfOVECEw449/mWvOtlRDIJwls= -js-yaml@^3.13.0, js-yaml@^3.13.1, js-yaml@^3.6.1: +js-yaml@^3.13.0, js-yaml@^3.13.1: version "3.13.1" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.13.1.tgz#aff151b30bfdfa8e49e05da22e7415e9dfa37847" integrity sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw== @@ -6143,10 +6169,10 @@ lcid@^2.0.0: dependencies: invert-kv "^2.0.0" -lcov-parse@^0.0.10: - version "0.0.10" - resolved "https://registry.yarnpkg.com/lcov-parse/-/lcov-parse-0.0.10.tgz#1b0b8ff9ac9c7889250582b70b71315d9da6d9a3" - integrity sha1-GwuP+ayceIklBYK3C3ExXZ2m2aM= +lcov-parse@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/lcov-parse/-/lcov-parse-1.0.0.tgz#eb0d46b54111ebc561acb4c408ef9363bdc8f7e0" + integrity sha1-6w1GtUER68VhrLTECO+TY73I9+A= less-loader@5.0.0: version "5.0.0" @@ -6298,7 +6324,7 @@ lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17 resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== -log-driver@^1.2.5: +log-driver@^1.2.7: version "1.2.7" resolved "https://registry.yarnpkg.com/log-driver/-/log-driver-1.2.7.tgz#63b95021f0702fedfa2c9bb0a24e7797d71871d8" integrity sha512-U7KCmLdqsGHBLeWqYlFA0V0Sl6P08EE1ZrmA9cxjUE0WVqT9qnyVDPz1kzpFEP0jdJuFnasWIfSd7fsaNXkpbg== @@ -6869,9 +6895,9 @@ no-case@^2.2.0: lower-case "^1.1.1" node-fetch-npm@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/node-fetch-npm/-/node-fetch-npm-2.0.2.tgz#7258c9046182dca345b4208eda918daf33697ff7" - integrity sha512-nJIxm1QmAj4v3nfCvEeCrYSoVwXyxLnaPBK5W1W5DGEJwjlKuC2VEUycGw5oxk+4zZahRrB84PUJJgEmhFTDFw== + version "2.0.4" + resolved "https://registry.yarnpkg.com/node-fetch-npm/-/node-fetch-npm-2.0.4.tgz#6507d0e17a9ec0be3bec516958a497cec54bf5a4" + integrity sha512-iOuIQDWDyjhv9qSDrj9aq/klt6F9z1p2otB3AV7v3zBDcL/x+OfGsvGQZZCcMZbUf4Ujw1xGNQkjvGnVT22cKg== dependencies: encoding "^0.1.11" json-parse-better-errors "^1.0.0" @@ -7056,9 +7082,9 @@ npm-pick-manifest@^2.2.3: semver "^5.4.1" npm-registry-fetch@^4.0.0: - version "4.0.3" - resolved "https://registry.yarnpkg.com/npm-registry-fetch/-/npm-registry-fetch-4.0.3.tgz#3c2179e39e04f9348b1c2979545951d36bee8766" - integrity sha512-WGvUx0lkKFhu9MbiGFuT9nG2NpfQ+4dCJwRwwtK2HK5izJEvwDxMeUyqbuMS7N/OkpVCqDorV6rO5E4V9F8lJw== + version "4.0.5" + resolved "https://registry.yarnpkg.com/npm-registry-fetch/-/npm-registry-fetch-4.0.5.tgz#cb87cf7f25bfb048d6c3ee19d115bebf93ea5bfa" + integrity sha512-yQ0/U4fYpCCqmueB2g8sc+89ckQ3eXpmU4+Yi2j5o/r0WkKvE2+Y0tK3DEILAtn2UaQTkjTHxIXe2/CSdit+/Q== dependencies: JSONStream "^1.3.4" bluebird "^3.5.1" @@ -8897,7 +8923,7 @@ repeat-string@^1.6.1: resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc= -request@^2.79.0, request@^2.83.0, request@^2.87.0, request@^2.88.0: +request@^2.83.0, request@^2.87.0, request@^2.88.0, request@^2.88.2: version "2.88.2" resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3" integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw== @@ -9115,9 +9141,9 @@ safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.0, safe-buffer@~5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.0.tgz#b74daec49b1148f88c64b68d49b1e815c1f2f519" - integrity sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg== + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== safe-regex@^1.1.0: version "1.1.0"